1use std::collections::BTreeMap;
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::{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 degraded = false;
1162
1163 for source in &resolved.sources {
1164 let primary_medium = match source {
1168 ResolvedSource::Primary(p) => Some(p.medium_type),
1169 ResolvedSource::Reference { .. } => None,
1170 };
1171 let (facet_ref, outcome) = match source {
1172 ResolvedSource::Primary(p) => {
1173 let key = format!("{}/{}#synced", resolved.name, p.name);
1174 let baseline = baseline_map.get(&key).map(String::as_str);
1175 let outcome = match resolve_change_strategy(p, workspace_root) {
1176 ChangeStrategy::Git => {
1177 compute_git_slice(p, &resolved.deny_paths, workspace_root, baseline)
1178 }
1179 ChangeStrategy::Graph if facet_unscoped(p) => SliceOutcome::NoSignal {
1185 reason: NoSignalReason::Unscoped,
1186 },
1187 ChangeStrategy::Graph => {
1188 compute_graph_slice(engine, Some(p), &p.pointer, baseline)
1189 }
1190 ChangeStrategy::Mtime => compute_mtime_slice(
1191 p,
1192 &resolved.name,
1193 &resolved.deny_paths,
1194 workspace_root,
1195 &cache_root,
1196 baseline,
1197 ),
1198 ChangeStrategy::None => SliceOutcome::NoSignal {
1200 reason: NoSignalReason::DetectionNone,
1201 },
1202 };
1203 (p.name.clone(), outcome)
1204 }
1205 ResolvedSource::Reference { mem } => {
1206 let key = format!("{}/{}#synced", resolved.name, mem);
1207 let baseline = baseline_map.get(&key).map(String::as_str);
1208 (
1209 mem.clone(),
1210 compute_graph_slice(engine, None, mem, baseline),
1211 )
1212 }
1213 };
1214
1215 let key = format!("{}/{}#synced", resolved.name, facet_ref);
1216 match outcome {
1217 SliceOutcome::Unchanged { .. } => {}
1221 SliceOutcome::NoSignal { reason } => no_signal.push(NoSignalNote {
1223 source: facet_ref.clone(),
1224 reason,
1225 medium_type: primary_medium,
1226 }),
1227 SliceOutcome::Reseed { token } => reseed.push(SyncCommand { key, token }),
1228 SliceOutcome::Changed {
1229 token,
1230 slice,
1231 degraded: d,
1232 } => {
1233 union.added.extend(slice.added);
1234 union.modified.extend(slice.modified);
1235 union.deleted.extend(slice.deleted);
1236 degraded |= d;
1237 write_commands.push(SyncCommand { key, token });
1238 }
1239 }
1240 }
1241
1242 dedupe_sort(&mut union.added);
1243 dedupe_sort(&mut union.modified);
1244 dedupe_sort(&mut union.deleted);
1245 let any_changes =
1246 !union.added.is_empty() || !union.modified.is_empty() || !union.deleted.is_empty();
1247
1248 SourceCursor {
1249 union,
1250 write_commands,
1251 reseed,
1252 no_signal,
1253 any_changes,
1254 degraded,
1255 dead_denies: dead_deny_entries(resolved, workspace_root),
1256 dest_mem: dest.clone(),
1257 binding_id: resolved.name.clone(),
1261 }
1262}
1263
1264fn dedupe_sort(v: &mut Vec<String>) {
1265 v.sort();
1266 v.dedup();
1267}
1268
1269#[cfg(test)]
1270mod tests {
1271 use super::*;
1272
1273 #[test]
1274 fn normalize_resolves_dot_and_dotdot() {
1275 assert_eq!(
1276 normalize_lexical(Path::new("/a/b/../c/./d")),
1277 PathBuf::from("/a/c/d")
1278 );
1279 assert_eq!(
1280 normalize_lexical(Path::new("/a/../../b")),
1281 PathBuf::from("/b"),
1282 "dotdot past root is clamped"
1283 );
1284 }
1285
1286 #[test]
1287 fn relative_computes_updowns() {
1288 assert_eq!(
1289 relative_path(Path::new("/a/b"), Path::new("/a/b/c/d")),
1290 PathBuf::from("c/d")
1291 );
1292 assert_eq!(
1293 relative_path(Path::new("/a/b/c"), Path::new("/a/x")),
1294 PathBuf::from("../../x")
1295 );
1296 assert_eq!(
1298 relative_path(Path::new("/m/public"), Path::new("/m/public/crates/x.rs")),
1299 PathBuf::from("crates/x.rs")
1300 );
1301 assert_eq!(
1302 relative_path(Path::new("/m/graph"), Path::new("/m/public/crates/x.rs")),
1303 PathBuf::from("../public/crates/x.rs")
1304 );
1305 }
1306
1307 #[test]
1308 fn pathspec_builds_glob_magic_relative_to_git_root() {
1309 let ws = Path::new("/m/graph");
1310 let git_root = Path::new("/m/public");
1311 assert_eq!(
1312 to_git_pathspec("../public/**/*.rs", git_root, ws, false),
1313 ":(glob)**/*.rs"
1314 );
1315 assert_eq!(
1316 to_git_pathspec("../public/target/**", git_root, ws, true),
1317 ":(glob,exclude)target/**"
1318 );
1319 }
1320
1321 #[test]
1327 fn wildcard_prefixed_pathspec_reanchors_verbatim() {
1328 let ws = Path::new("/m/ws");
1329 let git_root = Path::new("/m/ws/src");
1330 assert_eq!(to_git_pathspec("**/*", git_root, ws, false), ":(glob)**/*");
1331 assert_eq!(
1332 in_repo_pathspec("**/__pycache__/**", git_root, ws, true).as_deref(),
1333 Some(":(glob,exclude)**/__pycache__/**")
1334 );
1335 }
1336
1337 use crate::ingest::resolve::Source;
1338 use crate::pipeline::{MediumType, PatternEntry};
1339
1340 fn git(repo: &Path, args: &[&str]) {
1341 let status = std::process::Command::new("git")
1342 .args(args)
1343 .current_dir(repo)
1344 .env("GIT_AUTHOR_NAME", "t")
1345 .env("GIT_AUTHOR_EMAIL", "t@t")
1346 .env("GIT_COMMITTER_NAME", "t")
1347 .env("GIT_COMMITTER_EMAIL", "t@t")
1348 .output()
1349 .unwrap();
1350 assert!(
1351 status.status.success(),
1352 "git {args:?}: {}",
1353 String::from_utf8_lossy(&status.stderr)
1354 );
1355 }
1356
1357 fn primary(scope: Vec<PatternEntry>) -> Source {
1358 Source {
1359 name: "src".to_string(),
1360 medium_type: MediumType::Codebase,
1361 pointer: String::new(),
1362 change_detection: Some("git".to_string()),
1363 scope,
1364 engagement: None,
1365 preparation: None,
1366 }
1367 }
1368
1369 #[test]
1379 fn deny_dialect_agrees_between_slice_and_check() {
1380 let strs =
1381 |items: &[&str]| -> Vec<String> { items.iter().map(|s| s.to_string()).collect() };
1382 let entries = strs(&["dev/**", "**/VISION.md", "docs/meta/CLAUDE.md"]);
1383 let blocked = strs(&[
1384 "dev/notes/a.md",
1385 "dev/x.rs",
1386 "dev/deep/nested/y.txt",
1387 "VISION.md",
1388 "crates/foo/VISION.md",
1389 "docs/meta/CLAUDE.md",
1390 ]);
1391 let allowed = strs(&[
1392 "src/lib.rs",
1393 "dev-tools/x.rs",
1394 "VISION-draft.md",
1395 "docs/meta/README.md",
1396 "other/CLAUDE.md",
1397 "crates/foo/mod.rs",
1398 ]);
1399
1400 let ws = tempfile::tempdir().unwrap();
1401 for rel in blocked.iter().chain(allowed.iter()) {
1402 let path = ws.path().join(rel);
1403 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1404 std::fs::write(&path, "x").unwrap();
1405 }
1406
1407 let source = primary(vec![PatternEntry {
1409 path: "**".to_string(),
1410 mode: PatternMode::Allow,
1411 }]);
1412 let mut got = enumerate_facet_files(&source, &entries, ws.path());
1413 got.sort();
1414 let mut want = allowed.clone();
1415 want.sort();
1416 assert_eq!(
1417 got, want,
1418 "engine slice must equal the fixture `allowed` set"
1419 );
1420
1421 for b in &blocked {
1422 assert!(
1423 !got.contains(b),
1424 "denied `{b}` leaked into the engine slice"
1425 );
1426 }
1427 let all: Vec<String> = blocked.iter().chain(allowed.iter()).cloned().collect();
1430 let checks =
1431 super::super::check_path::check_deny_paths(&entries, &all, ws.path(), ws.path());
1432 for c in &checks {
1433 let expect = blocked.contains(&c.path);
1434 assert_eq!(
1435 c.denied, expect,
1436 "check_deny_paths disagrees with the slice on `{}`",
1437 c.path
1438 );
1439 }
1440 }
1441
1442 #[test]
1446 fn out_of_repo_deny_pathspec_is_dropped() {
1447 let ws = Path::new("/m/graph");
1448 let git_root = Path::new("/m/public");
1449 assert_eq!(in_repo_pathspec("../dev/**", git_root, ws, true), None);
1451 assert_eq!(in_repo_pathspec("../CLAUDE.md", git_root, ws, true), None);
1452 assert_eq!(
1454 in_repo_pathspec("../public/target/**", git_root, ws, true),
1455 Some(":(glob,exclude)target/**".to_string())
1456 );
1457 }
1458
1459 #[test]
1466 fn foreign_baseline_reseeds_instead_of_degrading() {
1467 let repo = tempfile::tempdir().unwrap();
1468 let root = repo.path();
1469 std::fs::write(root.join("keep.rs"), "one").unwrap();
1470 git(root, &["init", "-q"]);
1471 git(root, &["add", "-A"]);
1472 git(root, &["commit", "-qm", "seed"]);
1473
1474 let source = primary(vec![PatternEntry {
1475 path: "**/*.rs".to_string(),
1476 mode: PatternMode::Allow,
1477 }]);
1478 let foreign = "46ce8add0fe87250527b6fa21fcfdc2d943d51f0";
1480 match compute_git_slice(&source, &[], root, Some(foreign)) {
1481 SliceOutcome::Reseed { token } => {
1482 let head = String::from_utf8(
1484 std::process::Command::new("git")
1485 .args(["rev-parse", "HEAD"])
1486 .current_dir(root)
1487 .output()
1488 .unwrap()
1489 .stdout,
1490 )
1491 .unwrap()
1492 .trim()
1493 .to_string();
1494 assert_eq!(token, head);
1495 }
1496 other => panic!("foreign baseline must reseed, got {other:?}"),
1497 }
1498 }
1499
1500 #[test]
1505 fn git_slice_survives_cross_repo_deny() {
1506 let repo = tempfile::tempdir().unwrap();
1507 let root = repo.path();
1508 std::fs::write(root.join("keep.rs"), "one").unwrap();
1509 git(root, &["init", "-q"]);
1510 git(root, &["add", "-A"]);
1511 git(root, &["commit", "-qm", "seed"]);
1512 let baseline = String::from_utf8(
1513 std::process::Command::new("git")
1514 .args(["rev-parse", "HEAD"])
1515 .current_dir(root)
1516 .output()
1517 .unwrap()
1518 .stdout,
1519 )
1520 .unwrap()
1521 .trim()
1522 .to_string();
1523 std::fs::write(root.join("keep.rs"), "two").unwrap();
1524 git(root, &["add", "-A"]);
1525 git(root, &["commit", "-qm", "move"]);
1526
1527 let source = primary(vec![PatternEntry {
1528 path: "**/*.rs".to_string(),
1529 mode: PatternMode::Allow,
1530 }]);
1531 let outcome = compute_git_slice(&source, &["../dev/**".to_string()], root, Some(&baseline));
1533 match outcome {
1534 SliceOutcome::Changed { slice, .. } => {
1535 assert_eq!(slice.modified, vec!["keep.rs"]);
1536 }
1537 other => panic!("expected Changed (deny dropped), got {other:?}"),
1538 }
1539 }
1540
1541 #[test]
1544 fn git_slice_diffs_baseline_to_head() {
1545 let repo = tempfile::tempdir().unwrap();
1546 let root = repo.path();
1547 git(root, &["init", "-q"]);
1548 std::fs::write(root.join("keep.rs"), "one").unwrap();
1549 std::fs::write(root.join("gone.rs"), "bye").unwrap();
1550 std::fs::write(root.join("note.md"), "ignored-by-scope").unwrap();
1551 git(root, &["add", "-A"]);
1552 git(root, &["commit", "-qm", "base"]);
1553 let baseline = String::from_utf8(
1554 std::process::Command::new("git")
1555 .args(["rev-parse", "HEAD"])
1556 .current_dir(root)
1557 .output()
1558 .unwrap()
1559 .stdout,
1560 )
1561 .unwrap()
1562 .trim()
1563 .to_string();
1564
1565 std::fs::write(root.join("keep.rs"), "two").unwrap();
1567 std::fs::remove_file(root.join("gone.rs")).unwrap();
1568 std::fs::write(root.join("new.rs"), "hi").unwrap();
1569 std::fs::write(root.join("note.md"), "still ignored").unwrap();
1570 git(root, &["add", "-A"]);
1571 git(root, &["commit", "-qm", "move"]);
1572
1573 let source = primary(vec![PatternEntry {
1575 path: "**/*.rs".to_string(),
1576 mode: PatternMode::Allow,
1577 }]);
1578 let outcome = compute_git_slice(&source, &[], root, Some(&baseline));
1579 match outcome {
1580 SliceOutcome::Changed {
1581 slice, degraded, ..
1582 } => {
1583 assert!(!degraded);
1584 assert_eq!(slice.added, vec!["new.rs"]);
1585 assert_eq!(slice.modified, vec!["keep.rs"]);
1586 assert_eq!(slice.deleted, vec!["gone.rs"]);
1587 }
1588 other => panic!("expected Changed, got {other:?}"),
1589 }
1590
1591 let head = String::from_utf8(
1593 std::process::Command::new("git")
1594 .args(["rev-parse", "HEAD"])
1595 .current_dir(root)
1596 .output()
1597 .unwrap()
1598 .stdout,
1599 )
1600 .unwrap()
1601 .trim()
1602 .to_string();
1603 assert!(matches!(
1604 compute_git_slice(&source, &[], root, Some(&head)),
1605 SliceOutcome::Unchanged { .. }
1606 ));
1607
1608 assert!(matches!(
1610 compute_git_slice(&source, &[], root, None),
1611 SliceOutcome::Reseed { .. }
1612 ));
1613 }
1614
1615 #[test]
1618 fn enumerate_honours_allow_and_deny() {
1619 let ws = tempfile::tempdir().unwrap();
1620 let root = ws.path();
1621 std::fs::create_dir_all(root.join("sub")).unwrap();
1622 std::fs::write(root.join("a.rs"), "").unwrap();
1623 std::fs::write(root.join("sub/b.rs"), "").unwrap();
1624 std::fs::write(root.join("c.md"), "").unwrap();
1625
1626 let source = primary(vec![
1629 PatternEntry {
1630 path: "**/*.rs".to_string(),
1631 mode: PatternMode::Allow,
1632 },
1633 PatternEntry {
1634 path: "sub/**".to_string(),
1635 mode: PatternMode::Deny,
1636 },
1637 ]);
1638 assert_eq!(enumerate_facet_files(&source, &[], root), vec!["a.rs"]);
1639
1640 let mut graph_source = source.clone();
1645 graph_source.medium_type = MediumType::Graph;
1646 assert!(enumerate_facet_files(&graph_source, &[], root).is_empty());
1647 }
1648
1649 #[test]
1655 fn graph_enumeration_selects_the_source_mems_entities() {
1656 use crate::workspace::{
1657 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
1658 };
1659 use crate::workspace_store::WorkspaceStoreAdapter;
1660
1661 let tmp = tempfile::tempdir().unwrap();
1662 let root = tmp.path();
1663 let mem_dir = root.join("srcmem");
1664 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1665 std::fs::write(
1666 mem_dir.join(".memstead").join("config.json"),
1667 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1668 )
1669 .unwrap();
1670
1671 let entity = |slug: &str, ty: &str, title: &str| {
1672 std::fs::write(
1673 mem_dir.join(format!("{slug}.md")),
1674 format!("---\ntype: {ty}\n---\n\n# {title}\n\n## Decision\n\nBody.\n"),
1675 )
1676 .unwrap();
1677 };
1678 entity("alpha-choice", "decision", "Alpha choice");
1679 entity("beta-choice", "decision", "Beta choice");
1680 entity("gamma-note", "memo", "Gamma note");
1681
1682 std::fs::create_dir_all(root.join(".memstead")).unwrap();
1683 std::fs::write(
1684 root.join(".memstead").join("workspace.toml"),
1685 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1686 )
1687 .unwrap();
1688 crate::FileWorkspaceStore::new()
1689 .save_state(
1690 root,
1691 &Workspace {
1692 mounts: vec![Mount {
1693 mem: "srcmem".to_string(),
1694 schema: Some("default@1.0.0".parse().unwrap()),
1695 storage: MountStorage::Folder {
1696 path: mem_dir.clone(),
1697 },
1698 capability: MountCapability::Write,
1699 lifecycle: MountLifecycle::Eager,
1700 cross_linkable: false,
1701 migration_target: None,
1702 }],
1703 settings: WorkspaceSettings::default(),
1704 },
1705 )
1706 .unwrap();
1707
1708 let engine = crate::Engine::from_workspace_root(root).unwrap();
1709
1710 let graph_source = |patterns: Vec<(&str, PatternMode)>| Source {
1711 name: "g".to_string(),
1712 medium_type: MediumType::Graph,
1713 pointer: "srcmem".to_string(),
1714 change_detection: None,
1715 scope: patterns
1716 .into_iter()
1717 .map(|(p, mode)| crate::pipeline::PatternEntry {
1718 path: p.to_string(),
1719 mode,
1720 })
1721 .collect(),
1722 engagement: None,
1723 preparation: None,
1724 };
1725
1726 let all = enumerate_graph_entities(&engine, &graph_source(vec![("*", PatternMode::Allow)]));
1728 assert_eq!(
1729 all,
1730 vec![
1731 "srcmem--alpha-choice".to_string(),
1732 "srcmem--beta-choice".to_string(),
1733 "srcmem--gamma-note".to_string(),
1734 ],
1735 "the whole-mem selector enumerates every real entity"
1736 );
1737
1738 let decisions = enumerate_graph_entities(
1740 &engine,
1741 &graph_source(vec![("type:decision", PatternMode::Allow)]),
1742 );
1743 assert_eq!(
1744 decisions,
1745 vec![
1746 "srcmem--alpha-choice".to_string(),
1747 "srcmem--beta-choice".to_string()
1748 ],
1749 "type selector excludes the memo"
1750 );
1751
1752 let globbed = enumerate_graph_entities(
1754 &engine,
1755 &graph_source(vec![
1756 ("id:srcmem--*-choice", PatternMode::Allow),
1757 ("id:srcmem--beta-*", PatternMode::Deny),
1758 ]),
1759 );
1760 assert_eq!(
1761 globbed,
1762 vec!["srcmem--alpha-choice".to_string()],
1763 "deny subtracts from allow in the entity namespace too"
1764 );
1765
1766 assert!(
1770 enumerate_graph_entities(&engine, &graph_source(vec![])).is_empty(),
1771 "an unscoped graph facet is never silently 'everything'"
1772 );
1773
1774 assert_eq!(
1776 enumerate_source_artifacts(
1777 &engine,
1778 &graph_source(vec![("*", PatternMode::Allow)]),
1779 &[],
1780 root
1781 ),
1782 all,
1783 "enumerate_source_artifacts routes a graph source to the graph arm"
1784 );
1785 }
1786
1787 #[test]
1793 fn a_stale_entity_anchor_over_a_changed_entity_is_drifted() {
1794 use crate::anchor::{
1795 Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
1796 };
1797 use crate::entity::EntityId;
1798 use crate::workspace::{
1799 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
1800 };
1801 use crate::workspace_store::WorkspaceStoreAdapter;
1802
1803 let tmp = tempfile::tempdir().unwrap();
1804 let root = tmp.path();
1805 let mem_dir = root.join("mem");
1806 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1807 std::fs::write(
1808 mem_dir.join(".memstead").join("config.json"),
1809 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1810 )
1811 .unwrap();
1812 std::fs::write(
1813 mem_dir.join("pinned.md"),
1814 "---\ntype: decision\n---\n\n# Pinned\n\n## Decision\n\nOriginal body.\n",
1815 )
1816 .unwrap();
1817 std::fs::write(
1818 mem_dir.join("steady.md"),
1819 "---\ntype: decision\n---\n\n# Steady\n\n## Decision\n\nUnchanged body.\n",
1820 )
1821 .unwrap();
1822
1823 std::fs::create_dir_all(root.join(".memstead")).unwrap();
1824 std::fs::write(
1825 root.join(".memstead").join("workspace.toml"),
1826 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1827 )
1828 .unwrap();
1829 crate::FileWorkspaceStore::new()
1830 .save_state(
1831 root,
1832 &Workspace {
1833 mounts: vec![Mount {
1834 mem: "mem".to_string(),
1835 schema: Some("default@1.0.0".parse().unwrap()),
1836 storage: MountStorage::Folder {
1837 path: mem_dir.clone(),
1838 },
1839 capability: MountCapability::Write,
1840 lifecycle: MountLifecycle::Eager,
1841 cross_linkable: false,
1842 migration_target: None,
1843 }],
1844 settings: WorkspaceSettings::default(),
1845 },
1846 )
1847 .unwrap();
1848
1849 let engine = crate::Engine::from_workspace_root(root).unwrap();
1851 let hash_of = |engine: &crate::Engine, id: &str| {
1852 let e = engine.store().get(&EntityId::canonical(id)).unwrap();
1853 crate::anchor::prepared_content_hash(
1854 crate::render::render_entity_markdown(e, None).as_bytes(),
1855 )
1856 };
1857 let pinned_hash = hash_of(&engine, "mem--pinned");
1858 let steady_hash = hash_of(&engine, "mem--steady");
1859
1860 let entity_anchor = |artifact: &str, hash: &str| Anchor {
1861 artifact: artifact.to_string(),
1862 grain: AnchorGrain::Entity,
1863 class: AnchorProvenanceClass::Anchored,
1864 hash: Some(hash.to_string()),
1865 source: None,
1866 binding: None,
1867 at_version: None,
1868 derived_from: Vec::new(),
1869 hash_stability: crate::anchor::AnchorHashStability::Stable,
1870 };
1871
1872 let mut sidecar = AnchorSidecar::default();
1873 sidecar.set(
1874 "mem--holder",
1875 vec![
1876 entity_anchor("mem--pinned", &pinned_hash),
1877 entity_anchor("mem--steady", &steady_hash),
1878 entity_anchor("mem--vanished", "deadbeefdeadbeef"),
1880 ],
1881 );
1882 std::fs::write(
1883 mem_dir.join(".memstead").join("anchors.json"),
1884 sidecar.to_bytes(),
1885 )
1886 .unwrap();
1887 std::fs::write(
1888 mem_dir.join("holder.md"),
1889 "---\ntype: decision\n---\n\n# Holder\n\n## Decision\n\nHolds anchors.\n",
1890 )
1891 .unwrap();
1892
1893 std::fs::write(
1895 mem_dir.join("pinned.md"),
1896 "---\ntype: decision\n---\n\n# Pinned\n\n## Decision\n\nBody rewritten.\n",
1897 )
1898 .unwrap();
1899
1900 let engine = crate::Engine::from_workspace_root(root).unwrap();
1901 let resolved = engine.entity_anchors_resolved(&EntityId::canonical("mem--holder"));
1902 let state_of = |artifact: &str| {
1903 resolved
1904 .iter()
1905 .find(|r| r.anchor.artifact == artifact)
1906 .unwrap_or_else(|| panic!("no resolved anchor for {artifact}"))
1907 .state
1908 };
1909
1910 assert_eq!(
1911 state_of("mem--pinned"),
1912 Some(AnchorState::Drifted),
1913 "a stale-pinned anchor over a CHANGED entity must be drifted — \
1914 this is the pilot failure that went unflagged"
1915 );
1916 assert_eq!(
1917 state_of("mem--steady"),
1918 Some(AnchorState::Resolves),
1919 "an anchor over an unchanged entity still resolves"
1920 );
1921 assert_eq!(
1922 state_of("mem--vanished"),
1923 Some(AnchorState::Orphaned),
1924 "an anchor over an entity that is not there is orphaned, not unobserved"
1925 );
1926
1927 let mut sc2 = AnchorSidecar::default();
1930 sc2.set(
1931 "mem--holder",
1932 vec![Anchor {
1933 artifact: "https://example.invalid/doc".to_string(),
1934 grain: AnchorGrain::Url,
1935 class: AnchorProvenanceClass::InformedBy,
1936 hash: None,
1937 source: None,
1938 binding: None,
1939 at_version: None,
1940 derived_from: Vec::new(),
1941 hash_stability: crate::anchor::AnchorHashStability::Stable,
1942 }],
1943 );
1944 std::fs::write(
1945 mem_dir.join(".memstead").join("anchors.json"),
1946 sc2.to_bytes(),
1947 )
1948 .unwrap();
1949 let engine = crate::Engine::from_workspace_root(root).unwrap();
1950 let url_state =
1951 engine.entity_anchors_resolved(&EntityId::canonical("mem--holder"))[0].state;
1952 assert_eq!(
1953 url_state, None,
1954 "url anchors stay unobserved — the fix widens observation, never the \
1955 scoring of non-observation"
1956 );
1957 }
1958
1959 #[test]
1968 fn graph_scoping_changes_only_the_unscoped_arm() {
1969 use crate::binding::BuildMode;
1970
1971 let tmp = tempfile::tempdir().unwrap();
1972 let root = tmp.path();
1973 let mem_dir = root.join("srcmem");
1974 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1975 std::fs::write(
1976 mem_dir.join(".memstead").join("config.json"),
1977 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1978 )
1979 .unwrap();
1980 std::fs::write(
1981 mem_dir.join("one.md"),
1982 "---\ntype: decision\n---\n\n# One\n\n## Decision\n\nBody.\n",
1983 )
1984 .unwrap();
1985 std::fs::create_dir_all(root.join(".memstead")).unwrap();
1986 std::fs::write(
1987 root.join(".memstead").join("workspace.toml"),
1988 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1989 )
1990 .unwrap();
1991 {
1992 use crate::workspace::{
1993 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
1994 };
1995 use crate::workspace_store::WorkspaceStoreAdapter;
1996 crate::FileWorkspaceStore::new()
1997 .save_state(
1998 root,
1999 &Workspace {
2000 mounts: vec![Mount {
2001 mem: "srcmem".to_string(),
2002 schema: Some("default@1.0.0".parse().unwrap()),
2003 storage: MountStorage::Folder {
2004 path: mem_dir.clone(),
2005 },
2006 capability: MountCapability::Write,
2007 lifecycle: MountLifecycle::Eager,
2008 cross_linkable: false,
2009 migration_target: None,
2010 }],
2011 settings: WorkspaceSettings::default(),
2012 },
2013 )
2014 .unwrap();
2015 }
2016 let engine = crate::Engine::from_workspace_root(root).unwrap();
2017
2018 let resolved_with = |scope: Vec<crate::pipeline::PatternEntry>| ResolvedIngest {
2019 name: "srcmem/p".to_string(),
2020 mode: BuildMode::Discovery,
2021 trigger: crate::pipeline::IngestTrigger::Manual,
2022 batch_size: 20,
2023 deny_paths: Vec::new(),
2024 projection_ref: "srcmem/p".to_string(),
2025 projection_mem: "srcmem".to_string(),
2026 projection_name: "p".to_string(),
2027 intent: None,
2028 sources: vec![ResolvedSource::Primary(Source {
2029 name: "g".to_string(),
2030 medium_type: MediumType::Graph,
2031 pointer: "srcmem".to_string(),
2032 change_detection: None,
2033 scope,
2034 engagement: None,
2035 preparation: None,
2036 })],
2037 destination_mem: "srcmem".to_string(),
2038 rules: None,
2039 post_actions: None,
2040 };
2041
2042 let scoped = compute_source_cursor(
2043 &engine,
2044 &resolved_with(vec![crate::pipeline::PatternEntry {
2045 path: "*".to_string(),
2046 mode: PatternMode::Allow,
2047 }]),
2048 root,
2049 );
2050 let unscoped = compute_source_cursor(&engine, &resolved_with(Vec::new()), root);
2051
2052 let reason_of = |c: &SourceCursor| c.no_signal.first().map(|n| n.reason);
2053 assert_eq!(
2054 reason_of(&scoped),
2055 Some(NoSignalReason::GraphSnapshotMissing),
2056 "a scoped graph facet still routes to the graph strategy and reports \
2057 its own no-signal reason — the change-detection half is untouched"
2058 );
2059 assert_eq!(
2060 reason_of(&unscoped),
2061 Some(NoSignalReason::Unscoped),
2062 "an unscoped graph facet refuses like every other medium's, instead of \
2063 silently proceeding"
2064 );
2065 }
2066
2067 #[test]
2074 fn git_medium_enumerates_through_the_path_walk() {
2075 let ws = tempfile::tempdir().unwrap();
2076 let root = ws.path();
2077 std::fs::write(root.join("a.rs"), "").unwrap();
2078 std::fs::write(root.join("b.rs"), "").unwrap();
2079
2080 let source = |medium: MediumType| Source {
2081 name: "s".to_string(),
2082 medium_type: medium,
2083 pointer: ".".to_string(),
2084 change_detection: None,
2085 scope: vec![crate::pipeline::PatternEntry {
2086 path: "**/*.rs".to_string(),
2087 mode: PatternMode::Allow,
2088 }],
2089 engagement: None,
2090 preparation: None,
2091 };
2092
2093 let want = vec!["a.rs".to_string(), "b.rs".to_string()];
2094 for medium in [
2095 MediumType::Codebase,
2096 MediumType::Filesystem,
2097 MediumType::Git,
2098 ] {
2099 assert_eq!(
2100 enumerate_facet_files(&source(medium), &[], root),
2101 want,
2102 "{medium:?} walks the file tree — every medium the matrix marks \
2103 enumerable with a path namespace must actually enumerate"
2104 );
2105 assert!(
2106 crate::binding::medium_capabilities(medium).enumerable,
2107 "{medium:?} claims enumerability, and now delivers it"
2108 );
2109 }
2110 }
2111
2112 #[test]
2120 fn a_narrowing_selector_bounds_the_changed_slice_too() {
2121 use crate::workspace::{
2122 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2123 };
2124 use crate::workspace_store::WorkspaceStoreAdapter;
2125
2126 let tmp = tempfile::tempdir().unwrap();
2127 let root = tmp.path();
2128 let mem_dir = root.join("srcmem");
2129 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2130 std::fs::write(
2131 mem_dir.join(".memstead").join("config.json"),
2132 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2133 )
2134 .unwrap();
2135 let write = |slug: &str, ty: &str| {
2136 std::fs::write(
2137 mem_dir.join(format!("{slug}.md")),
2138 format!("---\ntype: {ty}\n---\n\n# {slug}\n\n## Decision\n\nBody.\n"),
2139 )
2140 .unwrap();
2141 };
2142 write("kept", "decision");
2143 write("other", "memo");
2144 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2145 std::fs::write(
2146 root.join(".memstead").join("workspace.toml"),
2147 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2148 )
2149 .unwrap();
2150 crate::FileWorkspaceStore::new()
2151 .save_state(
2152 root,
2153 &Workspace {
2154 mounts: vec![Mount {
2155 mem: "srcmem".to_string(),
2156 schema: Some("default@1.0.0".parse().unwrap()),
2157 storage: MountStorage::Folder {
2158 path: mem_dir.clone(),
2159 },
2160 capability: MountCapability::Write,
2161 lifecycle: MountLifecycle::Eager,
2162 cross_linkable: false,
2163 migration_target: None,
2164 }],
2165 settings: WorkspaceSettings::default(),
2166 },
2167 )
2168 .unwrap();
2169 let engine = crate::Engine::from_workspace_root(root).unwrap();
2170
2171 let source = Source {
2172 name: "g".to_string(),
2173 medium_type: MediumType::Graph,
2174 pointer: "srcmem".to_string(),
2175 change_detection: None,
2176 scope: vec![crate::pipeline::PatternEntry {
2177 path: "type:decision".to_string(),
2178 mode: PatternMode::Allow,
2179 }],
2180 engagement: None,
2181 preparation: None,
2182 };
2183
2184 let mut slice = Slice {
2185 added: vec!["srcmem--other".to_string()],
2186 modified: vec!["srcmem--kept".to_string(), "srcmem--other".to_string()],
2187 deleted: vec!["srcmem--vanished".to_string()],
2188 };
2189 filter_graph_slice_to_scope(&engine, &source, &mut slice);
2190
2191 assert_eq!(
2192 slice.modified,
2193 vec!["srcmem--kept".to_string()],
2194 "the out-of-scope memo is dropped from the slice the brief presents"
2195 );
2196 assert!(
2197 slice.added.is_empty(),
2198 "an added out-of-scope entity is out of scope too"
2199 );
2200 assert_eq!(
2201 slice.deleted,
2202 vec!["srcmem--vanished".to_string()],
2203 "a DELETED entity is kept even though its type can no longer be \
2204 read — a deletion that cannot be classified must be reported, \
2205 never silently dropped"
2206 );
2207
2208 let mut wide = Slice {
2210 added: Vec::new(),
2211 modified: vec!["srcmem--kept".to_string(), "srcmem--other".to_string()],
2212 deleted: Vec::new(),
2213 };
2214 let mut all = source.clone();
2215 all.scope = vec![crate::pipeline::PatternEntry {
2216 path: "*".to_string(),
2217 mode: PatternMode::Allow,
2218 }];
2219 filter_graph_slice_to_scope(&engine, &all, &mut wide);
2220 assert_eq!(wide.modified.len(), 2, "`*` selects the whole mem");
2221 }
2222
2223 #[test]
2227 fn entity_selector_grammar_is_closed() {
2228 use super::EntitySelector;
2229 assert_eq!(parse_entity_selector("*"), Some(EntitySelector::All));
2230 assert_eq!(
2231 parse_entity_selector("type:decision"),
2232 Some(EntitySelector::Type("decision".to_string()))
2233 );
2234 assert_eq!(
2235 parse_entity_selector("id:engine--*"),
2236 Some(EntitySelector::Id("engine--*".to_string()))
2237 );
2238 assert_eq!(parse_entity_selector("**/*"), None);
2241 assert_eq!(parse_entity_selector("src/**"), None);
2242 assert_eq!(parse_entity_selector("type:"), None);
2243 assert_eq!(parse_entity_selector("id:"), None);
2244 assert_eq!(parse_entity_selector(""), None);
2245 }
2246
2247 #[test]
2250 fn mtime_driver_reseeds_then_diffs_precisely() {
2251 let ws = tempfile::tempdir().unwrap();
2252 let root = ws.path();
2253 let cache = root.join(".memstead.cache").join("ingest");
2254 std::fs::write(root.join("a.rs"), "one").unwrap();
2255 std::fs::write(root.join("gone.rs"), "bye").unwrap();
2256 let source = primary(vec![PatternEntry {
2257 path: "**/*.rs".to_string(),
2258 mode: PatternMode::Allow,
2259 }]);
2260
2261 let token = match compute_mtime_slice(&source, "ing", &[], root, &cache, None) {
2263 SliceOutcome::Reseed { token } => token,
2264 other => panic!("expected Reseed, got {other:?}"),
2265 };
2266
2267 std::fs::write(root.join("a.rs"), "one-longer").unwrap();
2269 std::fs::remove_file(root.join("gone.rs")).unwrap();
2270 std::fs::write(root.join("new.rs"), "x").unwrap();
2271
2272 match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&token)) {
2274 SliceOutcome::Changed {
2275 slice, degraded, ..
2276 } => {
2277 assert!(
2278 !degraded,
2279 "memo present → precise, not a degraded full scan"
2280 );
2281 assert_eq!(slice.added, vec!["new.rs"]);
2282 assert_eq!(slice.modified, vec!["a.rs"]);
2283 assert_eq!(
2284 slice.deleted,
2285 vec!["gone.rs"],
2286 "deletions come from the memo"
2287 );
2288 }
2289 other => panic!("expected Changed, got {other:?}"),
2290 }
2291
2292 let stale = super::super::change_detection::serialize_digest_token(
2295 &super::super::change_detection::digest_stat_map(&stat_map_for(&["absent.rs"])),
2296 );
2297 match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&stale)) {
2298 SliceOutcome::Changed { degraded, .. } => assert!(degraded, "memo miss → degraded"),
2299 other => panic!("expected degraded Changed, got {other:?}"),
2300 }
2301 }
2302
2303 fn head_sha(repo: &Path) -> String {
2304 String::from_utf8(
2305 std::process::Command::new("git")
2306 .args(["rev-parse", "HEAD"])
2307 .current_dir(repo)
2308 .output()
2309 .unwrap()
2310 .stdout,
2311 )
2312 .unwrap()
2313 .trim()
2314 .to_string()
2315 }
2316
2317 fn slice_contains(slice: &Slice, path: &str) -> bool {
2318 let p = path.to_string();
2319 slice.added.contains(&p) || slice.modified.contains(&p) || slice.deleted.contains(&p)
2320 }
2321
2322 fn mtime_token(source: &Source, deny: &[String], root: &Path) -> String {
2326 let files = enumerate_facet_files(source, deny, root);
2327 serialize_digest_token(&digest_stat_map(&compute_stat_map(&files, root)))
2328 }
2329
2330 #[test]
2336 fn deny_paths_excluded_from_every_strategy_and_token() {
2337 use crate::binding::BuildMode;
2338 use crate::ingest::refinement::next_batch;
2339 use crate::pipeline::IngestTrigger;
2340
2341 let repo = tempfile::tempdir().unwrap();
2342 let root = repo.path();
2343 let cache = root.join(".memstead.cache").join("ingest");
2344
2345 git(root, &["init", "-q"]);
2348 std::fs::write(root.join("keep.rs"), "one").unwrap();
2349 std::fs::write(root.join("denied.rs"), "secret-one").unwrap();
2350 git(root, &["add", "-A"]);
2351 git(root, &["commit", "-qm", "base"]);
2352 let baseline = head_sha(root);
2353
2354 std::fs::write(root.join("keep.rs"), "two").unwrap();
2356 std::fs::write(root.join("denied.rs"), "secret-two").unwrap();
2357 git(root, &["add", "-A"]);
2358 git(root, &["commit", "-qm", "move"]);
2359
2360 let source = primary(vec![PatternEntry {
2363 path: "**/*.rs".to_string(),
2364 mode: PatternMode::Allow,
2365 }]);
2366 let deny = vec!["denied.rs".to_string()];
2367
2368 match compute_git_slice(&source, &deny, root, Some(&baseline)) {
2370 SliceOutcome::Changed { slice, .. } => {
2371 assert_eq!(slice.modified, vec!["keep.rs"]);
2372 assert!(!slice_contains(&slice, "denied.rs"), "git deny leak");
2373 }
2374 other => panic!("git: expected Changed, got {other:?}"),
2375 }
2376 match compute_git_slice(&source, &[], root, Some(&baseline)) {
2379 SliceOutcome::Changed { slice, .. } => {
2380 assert!(
2381 slice_contains(&slice, "denied.rs"),
2382 "un-denied, denied.rs is a genuine git change"
2383 );
2384 }
2385 other => panic!("git(no-deny): expected Changed, got {other:?}"),
2386 }
2387
2388 assert_eq!(enumerate_facet_files(&source, &deny, root), vec!["keep.rs"]);
2390 assert!(
2391 enumerate_facet_files(&source, &[], root).contains(&"denied.rs".to_string()),
2392 "un-denied, denied.rs is enumerated"
2393 );
2394
2395 let token = match compute_mtime_slice(&source, "ing", &deny, root, &cache, None) {
2397 SliceOutcome::Reseed { token } => token,
2398 other => panic!("mtime reseed expected, got {other:?}"),
2399 };
2400 std::fs::write(root.join("keep.rs"), "three-longer").unwrap();
2401 std::fs::write(root.join("denied.rs"), "secret-three-longer").unwrap();
2402 match compute_mtime_slice(&source, "ing", &deny, root, &cache, Some(&token)) {
2403 SliceOutcome::Changed { slice, .. } => {
2404 assert_eq!(slice.modified, vec!["keep.rs"]);
2405 assert!(!slice_contains(&slice, "denied.rs"), "mtime deny leak");
2406 }
2407 other => panic!("mtime: expected Changed, got {other:?}"),
2408 }
2409
2410 let token_present = mtime_token(&source, &deny, root);
2415 std::fs::remove_file(root.join("denied.rs")).unwrap();
2416 let token_absent = mtime_token(&source, &deny, root);
2417 assert_eq!(
2418 token_present, token_absent,
2419 "denied.rs must not influence the mtime digest / source_moved token"
2420 );
2421 std::fs::write(root.join("denied.rs"), "secret-restored").unwrap();
2422
2423 let resolved = ResolvedIngest {
2425 name: "ing".to_string(),
2426 mode: BuildMode::Discovery,
2427 trigger: IngestTrigger::Loop,
2428 batch_size: 50,
2429 deny_paths: deny.clone(),
2430 projection_ref: "m/p".to_string(),
2431 projection_mem: "m".to_string(),
2432 projection_name: "p".to_string(),
2433 intent: None,
2434 sources: vec![ResolvedSource::Primary(source.clone())],
2435 destination_mem: "m".to_string(),
2436 rules: None,
2437 post_actions: None,
2438 };
2439 let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
2440 let batch = next_batch(&engine, &resolved, root, &cache, 20).unwrap();
2441 assert!(
2442 batch.files.contains(&"keep.rs".to_string()),
2443 "keep.rs batched"
2444 );
2445 assert!(
2446 !batch.files.contains(&"denied.rs".to_string()),
2447 "denied.rs must never enter a refinement batch"
2448 );
2449 }
2450
2451 #[test]
2458 fn unscoped_facet_refuses_uniformly_and_empty_deny_is_distinct() {
2459 let repo = tempfile::tempdir().unwrap();
2460 let root = repo.path();
2461 let cache = root.join(".memstead.cache").join("ingest");
2462 git(root, &["init", "-q"]);
2463 std::fs::write(root.join("a.rs"), "one").unwrap();
2464 git(root, &["add", "-A"]);
2465 git(root, &["commit", "-qm", "base"]);
2466 let baseline = head_sha(root);
2467 std::fs::write(root.join("a.rs"), "two").unwrap();
2468 git(root, &["add", "-A"]);
2469 git(root, &["commit", "-qm", "move"]);
2470
2471 let unscoped = primary(vec![PatternEntry {
2474 path: "target/**".to_string(),
2475 mode: PatternMode::Deny,
2476 }]);
2477 assert_eq!(
2478 compute_git_slice(&unscoped, &[], root, Some(&baseline)),
2479 SliceOutcome::NoSignal {
2480 reason: NoSignalReason::Unscoped
2481 },
2482 "git refuses an unscoped facet"
2483 );
2484 assert_eq!(
2485 compute_mtime_slice(&unscoped, "ing", &[], root, &cache, None),
2486 SliceOutcome::NoSignal {
2487 reason: NoSignalReason::Unscoped
2488 },
2489 "mtime refuses an unscoped facet identically"
2490 );
2491 let empty_scope = primary(vec![]);
2493 assert_eq!(
2494 compute_git_slice(&empty_scope, &[], root, Some(&baseline)),
2495 SliceOutcome::NoSignal {
2496 reason: NoSignalReason::Unscoped
2497 }
2498 );
2499
2500 let scoped = primary(vec![PatternEntry {
2503 path: "**/*.rs".to_string(),
2504 mode: PatternMode::Allow,
2505 }]);
2506 assert!(
2507 matches!(
2508 compute_git_slice(&scoped, &[], root, Some(&baseline)),
2509 SliceOutcome::Changed { .. }
2510 ),
2511 "scoped facet + empty deny_paths → normal git slice, not a refusal"
2512 );
2513 assert!(
2514 matches!(
2515 compute_mtime_slice(&scoped, "ing", &[], root, &cache, None),
2516 SliceOutcome::Reseed { .. }
2517 ),
2518 "scoped facet + empty deny_paths → normal mtime reseed, not a refusal"
2519 );
2520 }
2521
2522 #[test]
2525 fn unscoped_facet_emits_no_refinement_batch() {
2526 use crate::binding::BuildMode;
2527 use crate::ingest::refinement::next_batch;
2528 use crate::pipeline::IngestTrigger;
2529
2530 let ws = tempfile::tempdir().unwrap();
2531 let root = ws.path();
2532 let cache = root.join(".memstead.cache").join("ingest");
2533 std::fs::write(root.join("a.rs"), "x").unwrap();
2534
2535 let resolved = ResolvedIngest {
2536 name: "ing".to_string(),
2537 mode: BuildMode::Discovery,
2538 trigger: IngestTrigger::Loop,
2539 batch_size: 50,
2540 deny_paths: vec![],
2541 projection_ref: "m/p".to_string(),
2542 projection_mem: "m".to_string(),
2543 projection_name: "p".to_string(),
2544 intent: None,
2545 sources: vec![ResolvedSource::Primary(primary(vec![]))],
2547 destination_mem: "m".to_string(),
2548 rules: None,
2549 post_actions: None,
2550 };
2551 assert!(
2552 next_batch(
2553 &crate::Engine::from_mounts(Vec::new()).unwrap(),
2554 &resolved,
2555 root,
2556 &cache,
2557 20
2558 )
2559 .is_none(),
2560 "an all-unscoped ingest emits no refinement batch"
2561 );
2562 }
2563
2564 #[test]
2570 fn compute_source_cursor_notes_no_signal_reasons() {
2571 use crate::binding::BuildMode;
2572 use crate::pipeline::IngestTrigger;
2573
2574 let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
2575 let ws = tempfile::tempdir().unwrap();
2577 let root = ws.path();
2578 std::fs::write(root.join("a.rs"), "x").unwrap();
2579
2580 let allow_rs = || {
2581 vec![PatternEntry {
2582 path: "**/*.rs".to_string(),
2583 mode: PatternMode::Allow,
2584 }]
2585 };
2586 let src = |facet: &str, declared: &str, scope: Vec<PatternEntry>| {
2587 ResolvedSource::Primary(Source {
2588 name: facet.to_string(),
2589 medium_type: MediumType::Filesystem,
2590 pointer: String::new(),
2591 change_detection: Some(declared.to_string()),
2592 scope,
2593 engagement: None,
2594 preparation: None,
2595 })
2596 };
2597
2598 let resolved = ResolvedIngest {
2599 name: "ing".to_string(),
2600 mode: BuildMode::Discovery,
2601 trigger: IngestTrigger::Loop,
2602 batch_size: 20,
2603 deny_paths: vec![],
2604 projection_ref: "m/p".to_string(),
2605 projection_mem: "m".to_string(),
2606 projection_name: "p".to_string(),
2607 intent: None,
2608 sources: vec![
2609 src("plan", "none", allow_rs()),
2611 src("blind", "mtime", vec![]),
2613 src("watched", "mtime", allow_rs()),
2615 ],
2616 destination_mem: "m".to_string(),
2617 rules: None,
2618 post_actions: None,
2619 };
2620
2621 let cursor = compute_source_cursor(&engine, &resolved, root);
2622 let reasons: BTreeMap<&str, NoSignalReason> = cursor
2623 .no_signal
2624 .iter()
2625 .map(|n| (n.source.as_str(), n.reason))
2626 .collect();
2627 assert_eq!(reasons.get("plan"), Some(&NoSignalReason::DetectionNone));
2628 assert_eq!(reasons.get("blind"), Some(&NoSignalReason::Unscoped));
2629 assert!(
2630 !reasons.contains_key("watched"),
2631 "a first-seen (reseed) source is not a no-signal note"
2632 );
2633 assert_eq!(cursor.no_signal.len(), 2);
2634 assert!(cursor.reseed.iter().any(|c| c.key == "ing/watched#synced"));
2636
2637 let out = crate::ingest::brief::render_changed_slice(&cursor);
2639 assert!(out.contains("- `plan`: `signal:none`"));
2640 assert!(out.contains("- `blind`: unscoped facet"));
2641 }
2642
2643 fn stat_map_for(paths: &[&str]) -> super::super::change_detection::StatMap {
2644 paths
2645 .iter()
2646 .map(|p| {
2647 (
2648 (*p).to_string(),
2649 super::super::change_detection::StatEntry { mtime: 1, size: 1 },
2650 )
2651 })
2652 .collect()
2653 }
2654
2655 #[test]
2661 fn engine_state_never_enumerates_even_when_allowed() {
2662 let ws = tempfile::tempdir().unwrap();
2663 let root = ws.path();
2664 for rel in [
2665 ".memstead/state/findings/muehle/f.json",
2666 ".memstead/projections/muehle/f.json",
2667 ".memstead.cache/ingest/source-cursor/muehle/f/f.json",
2668 "custom-repo/README.md",
2669 "Allgemein/Protokoll.md",
2670 "Allgemein/Vertrag.md",
2671 ] {
2672 let path = root.join(rel);
2673 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2674 std::fs::write(&path, "x").unwrap();
2675 }
2676 std::fs::write(
2680 root.join(".memstead/workspace.toml"),
2681 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2682 )
2683 .unwrap();
2684 std::fs::write(
2685 root.join(".memstead/state/mounts.json"),
2686 serde_json::json!({
2687 "format": "memstead-mounts-3",
2688 "mounts": [{
2689 "mem": "muehle",
2690 "schema": "default@1.0.0",
2691 "storage": {
2692 "type": "git-branch",
2693 "gitdir": "custom-repo/.git",
2694 "branch": "refs/heads/muehle"
2695 },
2696 "capability": "write",
2697 "lifecycle": "eager",
2698 "cross_linkable": true
2699 }]
2700 })
2701 .to_string(),
2702 )
2703 .unwrap();
2704
2705 let source = primary(vec![
2707 PatternEntry {
2708 path: "**/*".to_string(),
2709 mode: PatternMode::Allow,
2710 },
2711 PatternEntry {
2712 path: ".memstead/**".to_string(),
2713 mode: PatternMode::Allow,
2714 },
2715 PatternEntry {
2716 path: "custom-repo/**".to_string(),
2717 mode: PatternMode::Allow,
2718 },
2719 ]);
2720 let got = enumerate_facet_files(&source, &[], root);
2721 assert_eq!(
2722 got,
2723 vec!["Allgemein/Protokoll.md", "Allgemein/Vertrag.md"],
2724 "only source artifacts may enter the denominator"
2725 );
2726 }
2727
2728 #[test]
2733 fn git_slice_excludes_engine_state() {
2734 let repo = tempfile::tempdir().unwrap();
2735 let root = repo.path();
2736 git(root, &["init", "-q"]);
2737 std::fs::write(
2738 root.join("workspace.rs"), "x",
2740 )
2741 .unwrap();
2742 std::fs::create_dir_all(root.join(".memstead/state")).unwrap();
2743 std::fs::write(
2744 root.join(".memstead/workspace.toml"),
2745 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2746 )
2747 .unwrap();
2748 std::fs::write(
2749 root.join(".memstead/state/mounts.json"),
2750 serde_json::json!({
2751 "format": "memstead-mounts-3",
2752 "mounts": [{
2753 "mem": "muehle",
2754 "schema": "default@1.0.0",
2755 "storage": {
2756 "type": "git-branch",
2757 "gitdir": "custom-repo/.git",
2758 "branch": "refs/heads/muehle"
2759 },
2760 "capability": "write",
2761 "lifecycle": "eager",
2762 "cross_linkable": true
2763 }]
2764 })
2765 .to_string(),
2766 )
2767 .unwrap();
2768 git(root, &["add", "-A"]);
2769 git(root, &["commit", "-qm", "base"]);
2770 let baseline = String::from_utf8(
2771 std::process::Command::new("git")
2772 .args(["rev-parse", "HEAD"])
2773 .current_dir(root)
2774 .output()
2775 .unwrap()
2776 .stdout,
2777 )
2778 .unwrap()
2779 .trim()
2780 .to_string();
2781
2782 std::fs::write(root.join("real.md"), "signal").unwrap();
2784 std::fs::write(root.join(".memstead/state/findings.json"), "self").unwrap();
2785 std::fs::create_dir_all(root.join("custom-repo")).unwrap();
2786 std::fs::write(root.join("custom-repo/README.md"), "repo").unwrap();
2787 git(root, &["add", "-A"]);
2788 git(root, &["commit", "-qm", "move"]);
2789
2790 let source = primary(vec![PatternEntry {
2791 path: "**/*".to_string(),
2792 mode: PatternMode::Allow,
2793 }]);
2794 match compute_git_slice(&source, &[], root, Some(&baseline)) {
2795 SliceOutcome::Changed { slice, .. } => {
2796 assert_eq!(
2797 slice.added,
2798 vec!["real.md"],
2799 "engine state leaked: {slice:?}"
2800 );
2801 assert!(slice.modified.is_empty(), "{slice:?}");
2802 }
2803 other => panic!("expected Changed, got {other:?}"),
2804 }
2805 }
2806
2807 #[test]
2813 fn dead_deny_lint_exempts_scaffold_defaults_but_not_user_typos() {
2814 use crate::binding::{BuildMode, DEFAULT_SCAFFOLD_DENY_PATHS};
2815 use crate::pipeline::IngestTrigger;
2816
2817 let repo = tempfile::tempdir().unwrap();
2818 let root = repo.path();
2819 git(root, &["init", "-q"]);
2820 std::fs::create_dir_all(root.join("src")).unwrap();
2821 std::fs::write(root.join("src/lib.rs"), "code").unwrap();
2822
2823 let mut deny_paths: Vec<String> = DEFAULT_SCAFFOLD_DENY_PATHS
2824 .iter()
2825 .map(|s| s.to_string())
2826 .collect();
2827 deny_paths.push("typo/**".to_string()); deny_paths.push("src/**".to_string()); let resolved = ResolvedIngest {
2831 name: "ing".to_string(),
2832 mode: BuildMode::Discovery,
2833 trigger: IngestTrigger::Loop,
2834 batch_size: 20,
2835 deny_paths,
2836 projection_ref: "m/p".to_string(),
2837 projection_mem: "m".to_string(),
2838 projection_name: "p".to_string(),
2839 intent: None,
2840 sources: vec![ResolvedSource::Primary(primary(vec![PatternEntry {
2841 path: "**/*.rs".to_string(),
2842 mode: PatternMode::Allow,
2843 }]))],
2844 destination_mem: "m".to_string(),
2845 rules: None,
2846 post_actions: None,
2847 };
2848
2849 let dead = dead_deny_entries(&resolved, root);
2850 assert_eq!(
2851 dead,
2852 vec!["typo/**".to_string()],
2853 "only the user typo is flagged — scaffold defaults and matching \
2854 entries stay silent"
2855 );
2856 }
2857}