1use std::collections::{BTreeMap, BTreeSet};
81use std::path::{Component, Path, PathBuf};
82use std::process::Command;
83
84use globset::{Glob, GlobSet, GlobSetBuilder};
85
86use crate::Engine;
87use crate::pipeline::{MediumType, PatternMode};
88
89use super::brief::{DeliveredUnit, DeliverySequence, NoSignalNote, SourceCursor, SyncCommand};
90use super::change_detection::{
91 StatMap, compute_stat_map, digest_stat_map, parse_digest_token, serialize_digest_token,
92};
93use super::resolve::{
94 ChangeStrategy, ResolvedIngest, ResolvedSource, find_git_root, resolve_change_strategy,
95};
96use super::slice::{
97 NoSignalReason, Slice, SliceOutcome, graph_slice_outcome, is_git_token, mtime_slice_outcome,
98};
99use crate::pipeline::Source;
100
101pub(super) fn normalize_lexical(path: &Path) -> PathBuf {
105 let mut out: Vec<Component> = Vec::new();
106 for comp in path.components() {
107 match comp {
108 Component::CurDir => {}
109 Component::ParentDir => match out.last() {
110 Some(Component::Normal(_)) => {
111 out.pop();
112 }
113 Some(Component::RootDir | Component::Prefix(_)) => {}
114 _ => out.push(comp),
115 },
116 other => out.push(other),
117 }
118 }
119 out.iter().collect()
120}
121
122pub(super) fn relative_path(from: &Path, to: &Path) -> PathBuf {
125 let from = normalize_lexical(from);
126 let to = normalize_lexical(to);
127 let from_comps: Vec<Component> = from.components().collect();
128 let to_comps: Vec<Component> = to.components().collect();
129 let mut common = 0;
130 while common < from_comps.len()
131 && common < to_comps.len()
132 && from_comps[common] == to_comps[common]
133 {
134 common += 1;
135 }
136 let mut result = PathBuf::new();
137 for _ in common..from_comps.len() {
138 result.push("..");
139 }
140 for comp in &to_comps[common..] {
141 result.push(comp.as_os_str());
142 }
143 result
144}
145
146pub fn medium_base(pointer: &str, workspace_root: &Path) -> PathBuf {
151 if pointer.is_empty() {
152 workspace_root.to_path_buf()
153 } else {
154 normalize_lexical(&workspace_root.join(pointer))
155 }
156}
157
158pub fn relative_to(from: &Path, to: &Path) -> PathBuf {
163 relative_path(from, to)
164}
165
166pub fn out_of_root_layout_warning(
178 pointer: &str,
179 workspace_root: &Path,
180 medium_type: crate::pipeline::MediumType,
181) -> Option<String> {
182 use crate::pipeline::MediumType;
183 if !matches!(medium_type, MediumType::Codebase | MediumType::Filesystem) {
184 return None;
185 }
186 let base = medium_base(pointer, workspace_root);
187 let canon_base = std::fs::canonicalize(&base).unwrap_or(base);
191 let canon_root =
192 std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
193 if canon_base.starts_with(&canon_root) {
194 return None;
195 }
196 Some(format!(
197 "medium base '{}' resolves outside the workspace root '{}': supported — \
198 enumeration, change detection, and anchor resolution all work on this shape — \
199 but artifact ids render as workspace-relative '../…' chains and the \
200 workspace-to-source relative layout must stay fixed (moving either side \
201 breaks the pointer). To avoid the '../…' ids, root the workspace at the \
202 common parent directory containing every source tree.",
203 canon_base.display(),
204 canon_root.display()
205 ))
206}
207
208fn engine_state_denies(workspace_root: &Path) -> Vec<String> {
219 use crate::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
220
221 let mut denies: Vec<String> = vec![
222 ".memstead/**".to_string(),
223 ".memstead.cache/**".to_string(),
224 "**/.memstead/**".to_string(),
225 "**/.memstead.cache/**".to_string(),
226 ];
227 if let Ok(ws) = FileWorkspaceStore.load(workspace_root) {
228 for mount in &ws.mounts {
229 let dir: Option<PathBuf> = match &mount.storage {
230 crate::workspace::MountStorage::GitBranch { gitdir, .. } => {
231 gitdir.parent().map(Path::to_path_buf)
232 }
233 crate::workspace::MountStorage::Folder { path } => Some(path.clone()),
234 crate::workspace::MountStorage::Archive { path, .. } => {
235 let rel = relative_path(workspace_root, &normalize_lexical(path));
237 denies.push(rel.to_string_lossy().to_string());
238 None
239 }
240 crate::workspace::MountStorage::InMemory => None,
242 };
243 if let Some(dir) = dir {
244 let rel = relative_path(workspace_root, &normalize_lexical(&dir));
245 if !rel.as_os_str().is_empty() {
249 denies.push(format!("{}/**", rel.to_string_lossy()));
250 }
251 }
252 }
253 }
254 denies
255}
256
257fn commit_exists(git_root: &Path, sha: &str) -> bool {
260 Command::new("git")
261 .args(["cat-file", "-e", &format!("{sha}^{{commit}}")])
262 .current_dir(git_root)
263 .output()
264 .map(|o| o.status.success())
265 .unwrap_or(false)
266}
267
268fn git_head(git_root: &Path) -> Option<String> {
270 let out = Command::new("git")
271 .args(["rev-parse", "HEAD"])
272 .current_dir(git_root)
273 .output()
274 .ok()?;
275 if !out.status.success() {
276 return None;
277 }
278 let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
279 (!sha.is_empty()).then_some(sha)
280}
281
282fn to_git_pathspec(pattern: &str, git_root: &Path, base: &Path, exclude: bool) -> String {
303 let magic = if exclude {
304 ":(glob,exclude)"
305 } else {
306 ":(glob)"
307 };
308 let resolved = normalize_lexical(&base.join(pattern));
309 let git_rel = relative_path(git_root, &resolved);
310 if pattern.starts_with("**")
311 && git_rel
312 .components()
313 .next()
314 .is_some_and(|c| c == Component::ParentDir)
315 {
316 return format!("{magic}{pattern}");
317 }
318 format!("{magic}{}", git_rel.to_string_lossy())
319}
320
321fn in_repo_pathspec(pattern: &str, git_root: &Path, base: &Path, exclude: bool) -> Option<String> {
326 if pattern.starts_with("**") {
328 return Some(to_git_pathspec(pattern, git_root, base, exclude));
329 }
330 let resolved = normalize_lexical(&base.join(pattern));
331 let git_rel = relative_path(git_root, &resolved);
332 if git_rel
333 .components()
334 .next()
335 .is_some_and(|c| c == Component::ParentDir)
336 {
337 return None;
338 }
339 let magic = if exclude {
340 ":(glob,exclude)"
341 } else {
342 ":(glob)"
343 };
344 Some(format!("{magic}{}", git_rel.to_string_lossy()))
345}
346
347pub(crate) fn build_glob_set(patterns: &[&str]) -> Option<GlobSet> {
352 build_glob_set_reporting(patterns).0
353}
354
355pub(crate) fn build_glob_set_reporting(patterns: &[&str]) -> (Option<GlobSet>, Vec<String>) {
365 let mut builder = GlobSetBuilder::new();
366 let mut malformed = Vec::new();
367 let mut any = false;
368 for pattern in patterns {
369 match Glob::new(pattern) {
370 Ok(g) => {
371 builder.add(g);
372 any = true;
373 }
374 Err(_) => malformed.push((*pattern).to_string()),
375 }
376 }
377 let set = if any { builder.build().ok() } else { None };
378 (set, malformed)
379}
380
381fn facet_unscoped(source: &Source) -> bool {
388 !source.scope.iter().any(|r| r.mode == PatternMode::Allow)
389}
390
391pub fn enumerate_facet_files(
430 source: &Source,
431 deny_paths: &[String],
432 workspace_root: &Path,
433) -> Vec<String> {
434 enumerate_facet_files_reported(source, deny_paths, workspace_root).files
435}
436
437pub fn enumerate_facet_files_reported(
442 source: &Source,
443 deny_paths: &[String],
444 workspace_root: &Path,
445) -> ScopeEnumeration {
446 if !matches!(
447 source.medium_type,
448 MediumType::Codebase | MediumType::Filesystem | MediumType::Git
449 ) {
450 return ScopeEnumeration::default();
451 }
452 let legacy_dialect = scope_migration_notes(source);
453 let mut allows: Vec<&str> = Vec::new();
454 let mut scope_denies: Vec<&str> = Vec::new();
455 for rule in &source.scope {
456 match rule.mode {
457 PatternMode::Allow => allows.push(&rule.path),
458 PatternMode::Deny => scope_denies.push(&rule.path),
459 }
460 }
461 let mut ws_denies: Vec<&str> = deny_paths.iter().map(String::as_str).collect();
467 let forced = engine_state_denies(workspace_root);
471 for f in &forced {
472 ws_denies.push(f);
473 }
474 if allows.is_empty() {
475 return ScopeEnumeration {
476 legacy_dialect,
477 ..Default::default()
478 };
479 }
480 let (allow_set, mut malformed) = build_glob_set_reporting(&allows);
485 let (scope_deny_set, deny_malformed) = build_glob_set_reporting(&scope_denies);
486 malformed.extend(deny_malformed);
487 let ws_oracle = super::check_path::DenyOracle::new(
493 &ws_denies
494 .iter()
495 .map(|d| (*d).to_string())
496 .collect::<Vec<_>>(),
497 );
498 let Some(allow_set) = allow_set else {
499 return ScopeEnumeration {
500 malformed,
501 legacy_dialect,
502 ..Default::default()
503 };
504 };
505
506 let base = medium_base(&source.pointer, workspace_root);
515 let allow_prefixes: Vec<Vec<String>> = allows.iter().map(|p| glob_literal_prefix(p)).collect();
527 let mut out: Vec<String> = Vec::new();
528 let mut stack = vec![base.clone()];
529 while let Some(dir) = stack.pop() {
530 let Ok(entries) = std::fs::read_dir(&dir) else {
531 continue;
532 };
533 for entry in entries.flatten() {
534 let Ok(file_type) = entry.file_type() else {
535 continue;
536 };
537 let path = entry.path();
538 if file_type.is_dir() {
539 let skip = path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
540 VCS_INTERNAL_DIRS.contains(&n) || n == ".memstead" || n == ".memstead.cache"
545 });
546 if !skip {
547 let dir_src_rel = relative_path(&base, &normalize_lexical(&path))
548 .to_string_lossy()
549 .to_string();
550 if allow_could_match_under(&allow_prefixes, &dir_src_rel) {
551 stack.push(path);
552 }
553 }
554 } else if file_type.is_file() {
555 let normalized = normalize_lexical(&path);
556 let rel = relative_path(workspace_root, &normalized)
557 .to_string_lossy()
558 .to_string();
559 let src_rel = relative_path(&base, &normalized)
560 .to_string_lossy()
561 .to_string();
562 let denied = scope_deny_set
563 .as_ref()
564 .is_some_and(|d| d.is_match(&src_rel))
565 || ws_oracle.is_denied(&rel);
566 if allow_set.is_match(&src_rel) && !denied {
567 out.push(rel);
568 }
569 }
570 }
571 }
572 out.sort();
573 out.dedup();
574 ScopeEnumeration {
575 files: out,
576 malformed,
577 legacy_dialect,
578 }
579}
580
581fn glob_literal_prefix(pattern: &str) -> Vec<String> {
585 pattern
586 .split('/')
587 .take_while(|seg| {
588 !seg.chars()
589 .any(|c| matches!(c, '*' | '?' | '[' | ']' | '{' | '}'))
590 })
591 .map(str::to_string)
592 .collect()
593}
594
595fn allow_could_match_under(prefixes: &[Vec<String>], dir_rel: &str) -> bool {
603 let dir_segs: Vec<&str> = dir_rel.split('/').filter(|s| !s.is_empty()).collect();
604 prefixes.iter().any(|prefix| {
605 let n = dir_segs.len().min(prefix.len());
606 dir_segs[..n]
607 .iter()
608 .zip(prefix[..n].iter())
609 .all(|(d, p)| *d == p.as_str())
610 })
611}
612
613#[derive(Debug, Clone, Default, PartialEq, Eq)]
621pub struct ScopeEnumeration {
622 pub files: Vec<String>,
624 pub malformed: Vec<String>,
627 pub legacy_dialect: Vec<ScopePatternNote>,
631}
632
633impl ScopeEnumeration {
634 pub fn is_partial(&self) -> bool {
645 !self.malformed.is_empty() || !self.legacy_dialect.is_empty()
646 }
647
648 pub fn partiality_reason(&self) -> Option<String> {
651 let mut causes = Vec::new();
652 if !self.malformed.is_empty() {
653 causes.push(format!(
654 "scope pattern(s) that would not compile were skipped: {}",
655 self.malformed.join(", ")
656 ));
657 }
658 if !self.legacy_dialect.is_empty() {
659 causes.push(format!(
660 "scope pattern(s) are still written against the workspace root rather than the \
661 source pointer, so they select nothing under the pointer join: {}",
662 self.legacy_dialect
663 .iter()
664 .map(|n| n.pattern.as_str())
665 .collect::<Vec<_>>()
666 .join(", ")
667 ));
668 }
669 (!causes.is_empty()).then(|| causes.join("; "))
670 }
671}
672
673#[derive(Debug, Clone, PartialEq, Eq)]
679pub struct ScopePatternNote {
680 pub pattern: String,
682 pub deny: bool,
684 pub suggested: Option<String>,
688}
689
690pub fn scope_migration_notes(source: &Source) -> Vec<ScopePatternNote> {
700 let pointer = source.pointer.trim_end_matches('/');
701 if pointer.is_empty() {
702 return Vec::new();
703 }
704 let prefix = format!("{pointer}/");
705 source
706 .scope
707 .iter()
708 .filter(|r| !r.path.starts_with("**"))
709 .filter_map(|rule| {
710 let stripped = rule.path.strip_prefix(&prefix)?;
711 Some(ScopePatternNote {
712 pattern: rule.path.clone(),
713 deny: rule.mode == PatternMode::Deny,
714 suggested: (!stripped.is_empty()).then(|| stripped.to_string()),
715 })
716 })
717 .collect()
718}
719
720fn compute_git_slice(
723 source: &Source,
724 deny_paths: &[String],
725 workspace_root: &Path,
726 baseline: Option<&str>,
727) -> SliceOutcome {
728 let base = medium_base(&source.pointer, workspace_root);
729 let Some(git_root) = find_git_root(&base) else {
730 return SliceOutcome::NoSignal {
731 reason: NoSignalReason::GitUnavailable,
732 };
733 };
734 let Some(head) = git_head(&git_root) else {
735 return SliceOutcome::NoSignal {
736 reason: NoSignalReason::GitUnavailable,
737 };
738 };
739
740 let baseline = match baseline {
741 Some(b) if is_git_token(b) => b,
742 _ => return SliceOutcome::Reseed { token: head },
744 };
745 if baseline == head {
746 return SliceOutcome::Unchanged { token: head };
747 }
748 if !commit_exists(&git_root, baseline) {
757 return SliceOutcome::Reseed { token: head };
758 }
759
760 let mut allows: Vec<&str> = Vec::new();
762 let mut scope_denies: Vec<&str> = Vec::new();
763 for rule in &source.scope {
764 match rule.mode {
765 PatternMode::Allow => allows.push(&rule.path),
766 PatternMode::Deny => scope_denies.push(&rule.path),
767 }
768 }
769 if allows.is_empty() {
770 return SliceOutcome::NoSignal {
773 reason: NoSignalReason::Unscoped,
774 };
775 }
776 let mut ws_denies: Vec<&str> = deny_paths.iter().map(String::as_str).collect();
777 let forced = engine_state_denies(workspace_root);
781 for f in &forced {
782 ws_denies.push(f);
783 }
784 let mut specs: Vec<String> =
785 Vec::with_capacity(allows.len() + scope_denies.len() + ws_denies.len());
786 for a in &allows {
791 specs.push(to_git_pathspec(a, &git_root, &base, false));
792 }
793 for d in &scope_denies {
802 if let Some(spec) = in_repo_pathspec(d, &git_root, &base, true) {
803 specs.push(spec);
804 }
805 }
806 for d in &ws_denies {
807 if let Some(spec) = in_repo_pathspec(d, &git_root, workspace_root, true) {
808 specs.push(spec);
809 }
810 }
811
812 let mut cmd = Command::new("git");
813 cmd.args([
814 "diff",
815 "--no-renames",
816 "--name-status",
817 baseline,
818 &head,
819 "--",
820 ]);
821 cmd.args(&specs);
822 cmd.current_dir(&git_root);
823 let out = match cmd.output() {
824 Ok(o) if o.status.success() => o,
825 _ => {
828 return SliceOutcome::NoSignal {
829 reason: NoSignalReason::GitUnavailable,
830 };
831 }
832 };
833 let text = String::from_utf8_lossy(&out.stdout);
834
835 let ws_oracle = super::check_path::DenyOracle::new(
845 &ws_denies
846 .iter()
847 .map(|d| (*d).to_string())
848 .collect::<Vec<_>>(),
849 );
850
851 let mut slice = Slice::default();
852 for line in text.lines() {
853 if line.trim().is_empty() {
854 continue;
855 }
856 let Some(tab) = line.find('\t') else { continue };
857 let status = line[..tab].trim();
858 let git_path = line[tab + 1..].trim();
859 let ws_path = relative_path(workspace_root, &normalize_lexical(&git_root.join(git_path)))
860 .to_string_lossy()
861 .to_string();
862 if ws_oracle.is_denied(&ws_path) {
863 continue;
864 }
865 match status.chars().next() {
866 Some('A') => slice.added.push(ws_path),
867 Some('D') => slice.deleted.push(ws_path),
868 _ => slice.modified.push(ws_path),
870 }
871 }
872 slice.added.sort();
873 slice.modified.sort();
874 slice.deleted.sort();
875 SliceOutcome::Changed {
876 token: head,
877 slice,
878 degraded: false,
879 }
880}
881
882#[derive(Debug, Clone, PartialEq, Eq)]
898pub enum EntitySelector {
899 All,
901 Type(String),
903 Id(String),
905}
906
907pub fn parse_entity_selector(pattern: &str) -> Option<EntitySelector> {
911 let pattern = pattern.trim();
912 if pattern == "*" {
913 return Some(EntitySelector::All);
914 }
915 if let Some(rest) = pattern.strip_prefix("type:") {
916 let rest = rest.trim();
917 if rest.is_empty() {
918 return None;
919 }
920 return Some(EntitySelector::Type(rest.to_string()));
921 }
922 if let Some(rest) = pattern.strip_prefix("id:") {
923 let rest = rest.trim();
924 if rest.is_empty() {
925 return None;
926 }
927 Glob::new(rest).ok()?;
929 return Some(EntitySelector::Id(rest.to_string()));
930 }
931 None
932}
933
934fn selector_matches(selector: &EntitySelector, id: &str, entity_type: &str) -> bool {
936 match selector {
937 EntitySelector::All => true,
938 EntitySelector::Type(t) => entity_type == t,
939 EntitySelector::Id(g) => Glob::new(g)
940 .ok()
941 .map(|glob| glob.compile_matcher().is_match(id))
942 .unwrap_or(false),
943 }
944}
945
946pub fn enumerate_graph_entities(engine: &Engine, source: &Source) -> Vec<String> {
961 if source.medium_type != MediumType::Graph {
962 return Vec::new();
963 }
964 let mut allows: Vec<EntitySelector> = Vec::new();
965 let mut denies: Vec<EntitySelector> = Vec::new();
966 for rule in &source.scope {
967 let Some(sel) = parse_entity_selector(&rule.path) else {
971 continue;
972 };
973 match rule.mode {
974 PatternMode::Allow => allows.push(sel),
975 PatternMode::Deny => denies.push(sel),
976 }
977 }
978 if allows.is_empty() {
979 return Vec::new();
980 }
981 let mem = source.pointer.as_str();
982 let mut out: Vec<String> = Vec::new();
983 for entity in engine.store().all_entities() {
984 if entity.mem != mem || entity.stub {
985 continue;
986 }
987 let id = entity.id.0.as_str();
988 let ty = entity.entity_type.as_str();
989 if !allows.iter().any(|s| selector_matches(s, id, ty)) {
990 continue;
991 }
992 if denies.iter().any(|s| selector_matches(s, id, ty)) {
993 continue;
994 }
995 out.push(id.to_string());
996 }
997 out.sort();
998 out.dedup();
999 out
1000}
1001
1002pub fn enumerate_source_artifacts(
1014 engine: &Engine,
1015 source: &Source,
1016 deny_paths: &[String],
1017 workspace_root: &Path,
1018) -> Vec<String> {
1019 enumerate_source_artifacts_reported(engine, source, deny_paths, workspace_root).files
1020}
1021
1022pub fn enumerate_source_artifacts_reported(
1026 engine: &Engine,
1027 source: &Source,
1028 deny_paths: &[String],
1029 workspace_root: &Path,
1030) -> ScopeEnumeration {
1031 match source.medium_type {
1032 MediumType::Codebase | MediumType::Filesystem | MediumType::Git => {
1033 enumerate_facet_files_reported(source, deny_paths, workspace_root)
1034 }
1035 MediumType::Graph => ScopeEnumeration {
1038 files: enumerate_graph_entities(engine, source),
1039 ..Default::default()
1040 },
1041 MediumType::Web => ScopeEnumeration::default(),
1042 }
1043}
1044
1045fn filter_graph_slice_to_scope(engine: &Engine, source: &Source, slice: &mut Slice) {
1062 let mut allows: Vec<EntitySelector> = Vec::new();
1063 let mut denies: Vec<EntitySelector> = Vec::new();
1064 for rule in &source.scope {
1065 let Some(sel) = parse_entity_selector(&rule.path) else {
1066 continue;
1067 };
1068 match rule.mode {
1069 PatternMode::Allow => allows.push(sel),
1070 PatternMode::Deny => denies.push(sel),
1071 }
1072 }
1073 if allows.is_empty() {
1074 return;
1075 }
1076 let in_scope = |id: &str, known_type: Option<&str>| {
1077 let matches = |s: &EntitySelector| match (s, known_type) {
1081 (EntitySelector::Type(_), None) => true,
1082 _ => selector_matches(s, id, known_type.unwrap_or_default()),
1083 };
1084 allows.iter().any(&matches) && !denies.iter().any(&matches)
1085 };
1086 let type_of = |id: &str| {
1087 engine
1088 .store()
1089 .get(&crate::entity::EntityId::canonical(id))
1090 .map(|e| e.entity_type.clone())
1091 };
1092 slice
1093 .added
1094 .retain(|id| in_scope(id, type_of(id).as_deref()));
1095 slice
1096 .modified
1097 .retain(|id| in_scope(id, type_of(id).as_deref()));
1098 slice.deleted.retain(|id| in_scope(id, None));
1099}
1100
1101fn compute_graph_slice(
1102 engine: &Engine,
1103 source: Option<&Source>,
1104 source_mem: &str,
1105 baseline: Option<&str>,
1106) -> SliceOutcome {
1107 let current = match engine.mem_head_sha(source_mem) {
1108 Ok(Some(sha)) => sha,
1109 _ => {
1111 return SliceOutcome::NoSignal {
1112 reason: NoSignalReason::GraphSnapshotMissing,
1113 };
1114 }
1115 };
1116 let changed = matches!(baseline, Some(b) if is_git_token(b) && b != current);
1118 let mut outcome = if changed {
1119 let baseline = baseline.expect("changed implies a baseline");
1120 match engine.changes_since(source_mem, baseline, None) {
1121 Ok(report) => graph_slice_outcome(Some(baseline), ¤t, &report.changes),
1122 Err(_) => SliceOutcome::NoSignal {
1124 reason: NoSignalReason::GraphSnapshotMissing,
1125 },
1126 }
1127 } else {
1128 graph_slice_outcome(baseline, ¤t, &[])
1129 };
1130 if let (Some(source), SliceOutcome::Changed { slice, .. }) = (source, &mut outcome) {
1136 filter_graph_slice_to_scope(engine, source, slice);
1137 }
1138 outcome
1139}
1140
1141fn cursor_memo_path(cache_root: &Path, ingest_name: &str, facet_ref: &str) -> PathBuf {
1157 let safe: String = facet_ref
1158 .chars()
1159 .map(|c| {
1160 if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
1161 c
1162 } else {
1163 '_'
1164 }
1165 })
1166 .collect();
1167 cache_root
1168 .join("source-cursor")
1169 .join(ingest_name)
1170 .join(format!("{safe}.json"))
1171}
1172
1173fn read_cursor_memo(
1175 cache_root: &Path,
1176 ingest: &str,
1177 facet: &str,
1178 aggregate: &str,
1179) -> Option<StatMap> {
1180 let bytes = std::fs::read(cursor_memo_path(cache_root, ingest, facet)).ok()?;
1181 let memo: BTreeMap<String, StatMap> = serde_json::from_slice(&bytes).ok()?;
1182 memo.get(aggregate).cloned()
1183}
1184
1185fn write_cursor_memo(cache_root: &Path, ingest: &str, facet: &str, aggregate: &str, map: &StatMap) {
1188 let path = cursor_memo_path(cache_root, ingest, facet);
1189 let mut memo: BTreeMap<String, StatMap> = std::fs::read(&path)
1190 .ok()
1191 .and_then(|b| serde_json::from_slice(&b).ok())
1192 .unwrap_or_default();
1193 memo.insert(aggregate.to_string(), map.clone());
1194 if memo.len() > 3 {
1195 let drop: Vec<String> = memo
1197 .keys()
1198 .filter(|k| k.as_str() != aggregate)
1199 .skip(2)
1200 .cloned()
1201 .collect();
1202 for key in drop {
1203 memo.remove(&key);
1204 }
1205 }
1206 if let Some(parent) = path.parent() {
1207 let _ = std::fs::create_dir_all(parent);
1208 }
1209 if let Ok(bytes) = serde_json::to_vec(&memo) {
1210 let _ = std::fs::write(&path, bytes);
1211 }
1212}
1213
1214const VCS_INTERNAL_DIRS: &[&str] = &[".git", ".svn", ".hg"];
1217
1218const DEAD_DENY_SKIP_DIRS: &[&str] = &[
1222 ".git",
1223 "node_modules",
1224 "target",
1225 "dist",
1226 ".memstead.cache",
1227 ".sqlx",
1228 ".svn",
1229 ".hg",
1230];
1231
1232fn walk_tree_bounded(base: &Path, workspace_root: &Path, cap: usize) -> Option<Vec<String>> {
1238 let mut out: Vec<String> = Vec::new();
1239 let mut stack = vec![base.to_path_buf()];
1240 while let Some(dir) = stack.pop() {
1241 let Ok(entries) = std::fs::read_dir(&dir) else {
1242 continue;
1243 };
1244 for entry in entries.flatten() {
1245 let Ok(file_type) = entry.file_type() else {
1246 continue;
1247 };
1248 let path = entry.path();
1249 if file_type.is_dir() {
1250 let skip = path
1251 .file_name()
1252 .and_then(|n| n.to_str())
1253 .is_some_and(|n| DEAD_DENY_SKIP_DIRS.contains(&n));
1254 if !skip {
1255 stack.push(path);
1256 }
1257 } else if file_type.is_file() {
1258 if out.len() >= cap {
1259 return None;
1260 }
1261 out.push(
1262 relative_path(workspace_root, &normalize_lexical(&path))
1263 .to_string_lossy()
1264 .to_string(),
1265 );
1266 }
1267 }
1268 }
1269 Some(out)
1270}
1271
1272fn dead_deny_entries(resolved: &ResolvedIngest, workspace_root: &Path) -> Vec<String> {
1299 if resolved.deny_paths.is_empty() {
1300 return Vec::new();
1301 }
1302 let base = find_git_root(workspace_root).unwrap_or_else(|| workspace_root.to_path_buf());
1303 let Some(files) = walk_tree_bounded(&base, workspace_root, 100_000) else {
1304 return Vec::new();
1305 };
1306 let mut dead: Vec<String> = Vec::new();
1307 for entry in &resolved.deny_paths {
1308 if crate::binding::DEFAULT_SCAFFOLD_DENY_PATHS.contains(&entry.as_str()) {
1309 continue;
1310 }
1311 let oracle = super::check_path::DenyOracle::new(std::slice::from_ref(entry));
1312 if oracle.is_empty() {
1313 continue;
1316 }
1317 if !files.iter().any(|f| oracle.is_denied(f)) {
1318 dead.push(entry.clone());
1319 }
1320 }
1321 dead
1322}
1323
1324fn compute_mtime_slice(
1329 source: &Source,
1330 ingest_name: &str,
1331 deny_paths: &[String],
1332 workspace_root: &Path,
1333 cache_root: &Path,
1334 baseline: Option<&str>,
1335) -> SliceOutcome {
1336 if facet_unscoped(source) {
1337 return SliceOutcome::NoSignal {
1340 reason: NoSignalReason::Unscoped,
1341 };
1342 }
1343 let files = enumerate_facet_files(source, deny_paths, workspace_root);
1344 let now_map = compute_stat_map(&files, workspace_root);
1345 let now_digest = digest_stat_map(&now_map);
1346 write_cursor_memo(
1347 cache_root,
1348 ingest_name,
1349 &source.name,
1350 &now_digest.aggregate,
1351 &now_map,
1352 );
1353 let prev_map = baseline
1354 .and_then(parse_digest_token)
1355 .and_then(|base| read_cursor_memo(cache_root, ingest_name, &source.name, &base.aggregate));
1356 mtime_slice_outcome(baseline, prev_map.as_ref(), &now_map)
1357}
1358
1359fn current_primary_token(
1363 engine: &Engine,
1364 source: &Source,
1365 deny_paths: &[String],
1366 workspace_root: &Path,
1367) -> Option<String> {
1368 match resolve_change_strategy(source, workspace_root) {
1369 ChangeStrategy::Git => git_head(&find_git_root(&medium_base(
1370 &source.pointer,
1371 workspace_root,
1372 ))?),
1373 ChangeStrategy::Graph => {
1374 if facet_unscoped(source) {
1375 None
1378 } else {
1379 engine.mem_head_sha(&source.pointer).ok().flatten()
1380 }
1381 }
1382 ChangeStrategy::Mtime => {
1383 if facet_unscoped(source) {
1384 None
1387 } else {
1388 let files = enumerate_facet_files(source, deny_paths, workspace_root);
1389 Some(serialize_digest_token(&digest_stat_map(&compute_stat_map(
1390 &files,
1391 workspace_root,
1392 ))))
1393 }
1394 }
1395 ChangeStrategy::None => None,
1396 }
1397}
1398
1399pub fn source_moved(engine: &Engine, resolved: &ResolvedIngest, workspace_root: &Path) -> bool {
1406 source_moved_since(engine, resolved, workspace_root, "synced", false)
1407}
1408
1409pub fn source_moved_since(
1421 engine: &Engine,
1422 resolved: &ResolvedIngest,
1423 workspace_root: &Path,
1424 state: &str,
1425 missing_baseline_is_moved: bool,
1426) -> bool {
1427 let dest = &resolved.destination_mem;
1428 let baseline_map = engine
1429 .mem_config_for(dest)
1430 .map(|c| c.sync_state.clone())
1431 .unwrap_or_default();
1432
1433 for source in &resolved.sources {
1434 let (facet_ref, current) = match source {
1435 ResolvedSource::Primary(p) => (
1436 p.name.clone(),
1437 current_primary_token(engine, p, &resolved.deny_paths, workspace_root),
1438 ),
1439 ResolvedSource::Reference { mem } => {
1440 (mem.clone(), engine.mem_head_sha(mem).ok().flatten())
1441 }
1442 };
1443 let key = format!("{}/{}#{state}", resolved.name, facet_ref);
1444 let Some(baseline) = baseline_map.get(&key) else {
1445 if missing_baseline_is_moved && current.as_deref().is_some_and(|c| !c.is_empty()) {
1447 return true;
1448 }
1449 continue;
1450 };
1451 if let Some(current) = current
1452 && !current.is_empty()
1453 && current != *baseline
1454 {
1455 return true;
1456 }
1457 }
1458 false
1459}
1460
1461pub fn compute_source_cursor(
1464 engine: &Engine,
1465 resolved: &ResolvedIngest,
1466 workspace_root: &Path,
1467) -> SourceCursor {
1468 let dest = &resolved.destination_mem;
1469 let baseline_map = engine
1470 .mem_config_for(dest)
1471 .map(|c| c.sync_state.clone())
1472 .unwrap_or_default();
1473
1474 let cache_root = workspace_root.join(".memstead.cache").join("ingest");
1475 let mut union = Slice::default();
1476 let mut write_commands: Vec<SyncCommand> = Vec::new();
1477 let mut reseed: Vec<SyncCommand> = Vec::new();
1478 let mut no_signal: Vec<NoSignalNote> = Vec::new();
1479 let mut delivery: Vec<DeliverySequence> = Vec::new();
1480 let mut degraded = false;
1481 let disposed_units: std::cell::OnceCell<BTreeSet<String>> = std::cell::OnceCell::new();
1485 let disposed_units = || {
1486 disposed_units.get_or_init(|| {
1487 resolved
1488 .name
1489 .split_once('/')
1490 .and_then(|(mem, name)| {
1491 super::advance::read_advance_store(workspace_root, mem, name)
1492 .ok()
1493 .flatten()
1494 })
1495 .map(|state| state.dispositions.keys().cloned().collect())
1496 .unwrap_or_default()
1497 })
1498 };
1499
1500 for source in &resolved.sources {
1501 let primary_medium = match source {
1505 ResolvedSource::Primary(p) => Some(p.medium_type),
1506 ResolvedSource::Reference { .. } => None,
1507 };
1508 let (facet_ref, outcome) = match source {
1509 ResolvedSource::Primary(p) => {
1510 let key = format!("{}/{}#synced", resolved.name, p.name);
1511 let baseline = baseline_map.get(&key).map(String::as_str);
1512 let outcome = match resolve_change_strategy(p, workspace_root) {
1513 ChangeStrategy::Git => {
1514 compute_git_slice(p, &resolved.deny_paths, workspace_root, baseline)
1515 }
1516 ChangeStrategy::Graph if facet_unscoped(p) => SliceOutcome::NoSignal {
1522 reason: NoSignalReason::Unscoped,
1523 },
1524 ChangeStrategy::Graph => {
1525 compute_graph_slice(engine, Some(p), &p.pointer, baseline)
1526 }
1527 ChangeStrategy::Mtime => compute_mtime_slice(
1528 p,
1529 &resolved.name,
1530 &resolved.deny_paths,
1531 workspace_root,
1532 &cache_root,
1533 baseline,
1534 ),
1535 ChangeStrategy::None => SliceOutcome::NoSignal {
1537 reason: NoSignalReason::DetectionNone,
1538 },
1539 };
1540 let outcome =
1545 match crate::preparation::delivery_preparation(p.preparation.as_deref()) {
1546 Some(prep)
1547 if matches!(
1548 p.medium_type,
1549 MediumType::Codebase | MediumType::Filesystem | MediumType::Git
1550 ) =>
1551 {
1552 let (outcome, sequence) = deliver_units(
1553 p,
1554 prep.id,
1555 &resolved.deny_paths,
1556 workspace_root,
1557 baseline,
1558 resolved.batch_size as usize,
1559 disposed_units(),
1560 outcome,
1561 );
1562 delivery.extend(sequence);
1563 outcome
1564 }
1565 _ => outcome,
1566 };
1567 (p.name.clone(), outcome)
1568 }
1569 ResolvedSource::Reference { mem } => {
1570 let key = format!("{}/{}#synced", resolved.name, mem);
1571 let baseline = baseline_map.get(&key).map(String::as_str);
1572 (
1573 mem.clone(),
1574 compute_graph_slice(engine, None, mem, baseline),
1575 )
1576 }
1577 };
1578
1579 let key = format!("{}/{}#synced", resolved.name, facet_ref);
1580 match outcome {
1581 SliceOutcome::Unchanged { .. } => {}
1585 SliceOutcome::NoSignal { reason } => no_signal.push(NoSignalNote {
1587 source: facet_ref.clone(),
1588 reason,
1589 medium_type: primary_medium,
1590 }),
1591 SliceOutcome::Reseed { token } => reseed.push(SyncCommand { key, token }),
1592 SliceOutcome::Changed {
1593 token,
1594 slice,
1595 degraded: d,
1596 } => {
1597 union.added.extend(slice.added);
1598 union.modified.extend(slice.modified);
1599 union.deleted.extend(slice.deleted);
1600 degraded |= d;
1601 write_commands.push(SyncCommand { key, token });
1602 }
1603 }
1604 }
1605
1606 dedupe_sort(&mut union.added);
1607 dedupe_sort(&mut union.modified);
1608 dedupe_sort(&mut union.deleted);
1609 let any_changes =
1610 !union.added.is_empty() || !union.modified.is_empty() || !union.deleted.is_empty();
1611
1612 SourceCursor {
1613 union,
1614 write_commands,
1615 reseed,
1616 no_signal,
1617 any_changes,
1618 degraded,
1619 dead_denies: dead_deny_entries(resolved, workspace_root),
1620 delivery,
1621 dest_mem: dest.clone(),
1622 binding_id: resolved.name.clone(),
1626 }
1627}
1628
1629fn dedupe_sort(v: &mut Vec<String>) {
1630 v.sort();
1631 v.dedup();
1632}
1633
1634fn read_workspace_file(workspace_root: &Path, ws_rel: &str) -> Option<String> {
1636 std::fs::read(workspace_root.join(ws_rel))
1637 .ok()
1638 .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
1639}
1640
1641fn git_baseline_content(
1647 source: &Source,
1648 workspace_root: &Path,
1649 baseline: Option<&str>,
1650 ws_rel: &str,
1651) -> Option<String> {
1652 let baseline = baseline.filter(|b| is_git_token(b))?;
1653 if !matches!(
1654 resolve_change_strategy(source, workspace_root),
1655 ChangeStrategy::Git
1656 ) {
1657 return None;
1658 }
1659 let git_root = find_git_root(&medium_base(&source.pointer, workspace_root))?;
1660 let abs = normalize_lexical(&workspace_root.join(ws_rel));
1661 let rel = relative_path(&git_root, &abs);
1662 let spec = format!("{baseline}:{}", rel.to_string_lossy().replace('\\', "/"));
1663 let out = Command::new("git")
1664 .args(["show", &spec])
1665 .current_dir(&git_root)
1666 .output()
1667 .ok()?;
1668 out.status
1669 .success()
1670 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
1671}
1672
1673pub(crate) fn sequence_units(units: &mut Vec<DeliveredUnit>) {
1680 fn rank(id: &str) -> (&str, u64, &str) {
1681 let (path, key) = crate::preparation::split_unit_id(id);
1682 let key = key.unwrap_or("");
1683 let ordinal = key
1684 .rsplit_once('.')
1685 .and_then(|(_, n)| n.parse::<u64>().ok())
1686 .unwrap_or(1);
1687 (path, ordinal, key)
1688 }
1689 units.sort_by(|a, b| (&a.order_key, rank(&a.id)).cmp(&(&b.order_key, rank(&b.id))));
1690 units.dedup_by(|a, b| a.id == b.id);
1691}
1692
1693#[allow(clippy::too_many_arguments)]
1706fn deliver_units(
1707 source: &Source,
1708 preparation: &str,
1709 deny_paths: &[String],
1710 workspace_root: &Path,
1711 baseline: Option<&str>,
1712 batch: usize,
1713 disposed: &BTreeSet<String>,
1714 outcome: SliceOutcome,
1715) -> (SliceOutcome, Option<DeliverySequence>) {
1716 use crate::preparation::{DeliveryUnit, UnitChange, diff_units, unit_id, unitize};
1717
1718 let units_of =
1719 |text: &str| -> Vec<DeliveryUnit> { unitize(preparation, text).unwrap_or_default() };
1720 let delivered = |path: &str, u: &DeliveryUnit, change: UnitChange| DeliveredUnit {
1721 id: unit_id(path, &u.key),
1722 order_key: u.order_key.clone(),
1723 change,
1724 disposed: false,
1725 };
1726
1727 let mut units: Vec<DeliveredUnit> = Vec::new();
1728 let mut file_level_deleted: Vec<String> = Vec::new();
1729 let mut degraded_units = false;
1730 let (token, first_run, degraded) = match outcome {
1731 SliceOutcome::Reseed { token } => {
1732 for f in enumerate_facet_files(source, deny_paths, workspace_root) {
1733 if let Some(text) = read_workspace_file(workspace_root, &f) {
1734 for u in units_of(&text) {
1735 units.push(delivered(&f, &u, UnitChange::Added));
1736 }
1737 }
1738 }
1739 if units.is_empty() {
1740 return (SliceOutcome::Reseed { token }, None);
1742 }
1743 (token, true, false)
1744 }
1745 SliceOutcome::Changed {
1746 token,
1747 slice,
1748 degraded,
1749 } => {
1750 for f in &slice.added {
1751 if let Some(text) = read_workspace_file(workspace_root, f) {
1752 for u in units_of(&text) {
1753 units.push(delivered(f, &u, UnitChange::Added));
1754 }
1755 }
1756 }
1757 for f in &slice.modified {
1758 let Some(now) = read_workspace_file(workspace_root, f) else {
1759 continue;
1760 };
1761 let new_units = units_of(&now);
1762 match git_baseline_content(source, workspace_root, baseline, f) {
1763 Some(old) => {
1764 for (u, change) in diff_units(&units_of(&old), &new_units) {
1765 units.push(delivered(f, &u, change));
1766 }
1767 }
1768 None => {
1769 degraded_units = true;
1770 for u in new_units {
1771 units.push(delivered(f, &u, UnitChange::Modified));
1772 }
1773 }
1774 }
1775 }
1776 for f in &slice.deleted {
1777 match git_baseline_content(source, workspace_root, baseline, f) {
1778 Some(old) => {
1779 for u in units_of(&old) {
1780 units.push(delivered(f, &u, UnitChange::Deleted));
1781 }
1782 }
1783 None => file_level_deleted.push(f.clone()),
1784 }
1785 }
1786 (token, false, degraded)
1787 }
1788 other => return (other, None),
1789 };
1790
1791 sequence_units(&mut units);
1792 for u in &mut units {
1793 u.disposed = disposed.contains(&u.id);
1794 }
1795
1796 let mut slice = Slice::default();
1797 for u in &units {
1798 match u.change {
1799 UnitChange::Added => slice.added.push(u.id.clone()),
1800 UnitChange::Modified => slice.modified.push(u.id.clone()),
1801 UnitChange::Deleted => slice.deleted.push(u.id.clone()),
1802 }
1803 }
1804 slice.deleted.extend(file_level_deleted);
1805 dedupe_sort(&mut slice.added);
1806 dedupe_sort(&mut slice.modified);
1807 dedupe_sort(&mut slice.deleted);
1808
1809 let sequence = DeliverySequence {
1810 source: source.name.clone(),
1811 preparation: preparation.to_string(),
1812 first_run,
1813 degraded: degraded_units,
1814 batch,
1815 units,
1816 };
1817 (
1818 SliceOutcome::Changed {
1819 token,
1820 slice,
1821 degraded,
1822 },
1823 Some(sequence),
1824 )
1825}
1826
1827#[cfg(test)]
1828mod tests {
1829 use super::*;
1830
1831 #[test]
1832 fn normalize_resolves_dot_and_dotdot() {
1833 assert_eq!(
1834 normalize_lexical(Path::new("/a/b/../c/./d")),
1835 PathBuf::from("/a/c/d")
1836 );
1837 assert_eq!(
1838 normalize_lexical(Path::new("/a/../../b")),
1839 PathBuf::from("/b"),
1840 "dotdot past root is clamped"
1841 );
1842 }
1843
1844 #[test]
1845 fn relative_computes_updowns() {
1846 assert_eq!(
1847 relative_path(Path::new("/a/b"), Path::new("/a/b/c/d")),
1848 PathBuf::from("c/d")
1849 );
1850 assert_eq!(
1851 relative_path(Path::new("/a/b/c"), Path::new("/a/x")),
1852 PathBuf::from("../../x")
1853 );
1854 assert_eq!(
1856 relative_path(Path::new("/m/public"), Path::new("/m/public/crates/x.rs")),
1857 PathBuf::from("crates/x.rs")
1858 );
1859 assert_eq!(
1860 relative_path(Path::new("/m/graph"), Path::new("/m/public/crates/x.rs")),
1861 PathBuf::from("../public/crates/x.rs")
1862 );
1863 }
1864
1865 #[test]
1866 fn pathspec_builds_glob_magic_relative_to_git_root() {
1867 let ws = Path::new("/m/graph");
1868 let git_root = Path::new("/m/public");
1869 assert_eq!(
1870 to_git_pathspec("../public/**/*.rs", git_root, ws, false),
1871 ":(glob)**/*.rs"
1872 );
1873 assert_eq!(
1874 to_git_pathspec("../public/target/**", git_root, ws, true),
1875 ":(glob,exclude)target/**"
1876 );
1877 }
1878
1879 #[test]
1885 fn wildcard_prefixed_pathspec_reanchors_verbatim() {
1886 let ws = Path::new("/m/ws");
1887 let git_root = Path::new("/m/ws/src");
1888 assert_eq!(to_git_pathspec("**/*", git_root, ws, false), ":(glob)**/*");
1889 assert_eq!(
1890 in_repo_pathspec("**/__pycache__/**", git_root, ws, true).as_deref(),
1891 Some(":(glob,exclude)**/__pycache__/**")
1892 );
1893 }
1894
1895 use crate::ingest::resolve::Source;
1896 use crate::pipeline::{MediumType, PatternEntry};
1897
1898 fn git(repo: &Path, args: &[&str]) {
1899 let status = std::process::Command::new("git")
1900 .args(args)
1901 .current_dir(repo)
1902 .env("GIT_AUTHOR_NAME", "t")
1903 .env("GIT_AUTHOR_EMAIL", "t@t")
1904 .env("GIT_COMMITTER_NAME", "t")
1905 .env("GIT_COMMITTER_EMAIL", "t@t")
1906 .output()
1907 .unwrap();
1908 assert!(
1909 status.status.success(),
1910 "git {args:?}: {}",
1911 String::from_utf8_lossy(&status.stderr)
1912 );
1913 }
1914
1915 fn primary(scope: Vec<PatternEntry>) -> Source {
1916 Source {
1917 name: "src".to_string(),
1918 medium_type: MediumType::Codebase,
1919 pointer: String::new(),
1920 change_detection: Some("git".to_string()),
1921 scope,
1922 engagement: None,
1923 preparation: None,
1924 }
1925 }
1926
1927 #[test]
1937 fn deny_dialect_agrees_between_slice_and_check() {
1938 let strs =
1939 |items: &[&str]| -> Vec<String> { items.iter().map(|s| s.to_string()).collect() };
1940 let entries = strs(&["dev/**", "**/VISION.md", "docs/meta/CLAUDE.md"]);
1941 let blocked = strs(&[
1942 "dev/notes/a.md",
1943 "dev/x.rs",
1944 "dev/deep/nested/y.txt",
1945 "VISION.md",
1946 "crates/foo/VISION.md",
1947 "docs/meta/CLAUDE.md",
1948 ]);
1949 let allowed = strs(&[
1950 "src/lib.rs",
1951 "dev-tools/x.rs",
1952 "VISION-draft.md",
1953 "docs/meta/README.md",
1954 "other/CLAUDE.md",
1955 "crates/foo/mod.rs",
1956 ]);
1957
1958 let ws = tempfile::tempdir().unwrap();
1959 for rel in blocked.iter().chain(allowed.iter()) {
1960 let path = ws.path().join(rel);
1961 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1962 std::fs::write(&path, "x").unwrap();
1963 }
1964
1965 let source = primary(vec![PatternEntry {
1967 path: "**".to_string(),
1968 mode: PatternMode::Allow,
1969 }]);
1970 let mut got = enumerate_facet_files(&source, &entries, ws.path());
1971 got.sort();
1972 let mut want = allowed.clone();
1973 want.sort();
1974 assert_eq!(
1975 got, want,
1976 "engine slice must equal the fixture `allowed` set"
1977 );
1978
1979 for b in &blocked {
1980 assert!(
1981 !got.contains(b),
1982 "denied `{b}` leaked into the engine slice"
1983 );
1984 }
1985 let all: Vec<String> = blocked.iter().chain(allowed.iter()).cloned().collect();
1988 let checks =
1989 super::super::check_path::check_deny_paths(&entries, &all, ws.path(), ws.path());
1990 for c in &checks {
1991 let expect = blocked.contains(&c.path);
1992 assert_eq!(
1993 c.denied, expect,
1994 "check_deny_paths disagrees with the slice on `{}`",
1995 c.path
1996 );
1997 }
1998 }
1999
2000 #[test]
2004 fn out_of_repo_deny_pathspec_is_dropped() {
2005 let ws = Path::new("/m/graph");
2006 let git_root = Path::new("/m/public");
2007 assert_eq!(in_repo_pathspec("../dev/**", git_root, ws, true), None);
2009 assert_eq!(in_repo_pathspec("../CLAUDE.md", git_root, ws, true), None);
2010 assert_eq!(
2012 in_repo_pathspec("../public/target/**", git_root, ws, true),
2013 Some(":(glob,exclude)target/**".to_string())
2014 );
2015 }
2016
2017 #[test]
2024 fn foreign_baseline_reseeds_instead_of_degrading() {
2025 let repo = tempfile::tempdir().unwrap();
2026 let root = repo.path();
2027 std::fs::write(root.join("keep.rs"), "one").unwrap();
2028 git(root, &["init", "-q"]);
2029 git(root, &["add", "-A"]);
2030 git(root, &["commit", "-qm", "seed"]);
2031
2032 let source = primary(vec![PatternEntry {
2033 path: "**/*.rs".to_string(),
2034 mode: PatternMode::Allow,
2035 }]);
2036 let foreign = "46ce8add0fe87250527b6fa21fcfdc2d943d51f0";
2038 match compute_git_slice(&source, &[], root, Some(foreign)) {
2039 SliceOutcome::Reseed { token } => {
2040 let head = String::from_utf8(
2042 std::process::Command::new("git")
2043 .args(["rev-parse", "HEAD"])
2044 .current_dir(root)
2045 .output()
2046 .unwrap()
2047 .stdout,
2048 )
2049 .unwrap()
2050 .trim()
2051 .to_string();
2052 assert_eq!(token, head);
2053 }
2054 other => panic!("foreign baseline must reseed, got {other:?}"),
2055 }
2056 }
2057
2058 #[test]
2063 fn git_slice_survives_cross_repo_deny() {
2064 let repo = tempfile::tempdir().unwrap();
2065 let root = repo.path();
2066 std::fs::write(root.join("keep.rs"), "one").unwrap();
2067 git(root, &["init", "-q"]);
2068 git(root, &["add", "-A"]);
2069 git(root, &["commit", "-qm", "seed"]);
2070 let baseline = String::from_utf8(
2071 std::process::Command::new("git")
2072 .args(["rev-parse", "HEAD"])
2073 .current_dir(root)
2074 .output()
2075 .unwrap()
2076 .stdout,
2077 )
2078 .unwrap()
2079 .trim()
2080 .to_string();
2081 std::fs::write(root.join("keep.rs"), "two").unwrap();
2082 git(root, &["add", "-A"]);
2083 git(root, &["commit", "-qm", "move"]);
2084
2085 let source = primary(vec![PatternEntry {
2086 path: "**/*.rs".to_string(),
2087 mode: PatternMode::Allow,
2088 }]);
2089 let outcome = compute_git_slice(&source, &["../dev/**".to_string()], root, Some(&baseline));
2091 match outcome {
2092 SliceOutcome::Changed { slice, .. } => {
2093 assert_eq!(slice.modified, vec!["keep.rs"]);
2094 }
2095 other => panic!("expected Changed (deny dropped), got {other:?}"),
2096 }
2097 }
2098
2099 #[test]
2106 fn git_slice_confines_to_the_medium_subtree() {
2107 let repo = tempfile::tempdir().unwrap();
2108 let root = repo.path();
2109 std::fs::create_dir_all(root.join("sub")).unwrap();
2110 std::fs::write(root.join("sub/in.md"), "one").unwrap();
2111 std::fs::write(root.join("out.md"), "one").unwrap();
2112 git(root, &["init", "-q"]);
2113 git(root, &["add", "-A"]);
2114 git(root, &["commit", "-qm", "seed"]);
2115 let baseline = String::from_utf8(
2116 std::process::Command::new("git")
2117 .args(["rev-parse", "HEAD"])
2118 .current_dir(root)
2119 .output()
2120 .unwrap()
2121 .stdout,
2122 )
2123 .unwrap()
2124 .trim()
2125 .to_string();
2126 std::fs::write(root.join("sub/in.md"), "two").unwrap();
2127 std::fs::write(root.join("out.md"), "two").unwrap();
2128 git(root, &["add", "-A"]);
2129 git(root, &["commit", "-qm", "move both"]);
2130
2131 let source = Source {
2132 pointer: "sub".to_string(),
2133 ..primary(vec![PatternEntry {
2134 path: "**/*.md".to_string(),
2135 mode: PatternMode::Allow,
2136 }])
2137 };
2138 let outcome = compute_git_slice(&source, &[], root, Some(&baseline));
2139 match outcome {
2140 SliceOutcome::Changed { slice, .. } => {
2141 assert_eq!(slice.modified, vec!["sub/in.md"]);
2142 assert!(slice.added.is_empty());
2143 assert!(slice.deleted.is_empty());
2144 }
2145 other => panic!("expected Changed confined to the subtree, got {other:?}"),
2146 }
2147 }
2148
2149 #[test]
2152 fn git_slice_diffs_baseline_to_head() {
2153 let repo = tempfile::tempdir().unwrap();
2154 let root = repo.path();
2155 git(root, &["init", "-q"]);
2156 std::fs::write(root.join("keep.rs"), "one").unwrap();
2157 std::fs::write(root.join("gone.rs"), "bye").unwrap();
2158 std::fs::write(root.join("note.md"), "ignored-by-scope").unwrap();
2159 git(root, &["add", "-A"]);
2160 git(root, &["commit", "-qm", "base"]);
2161 let baseline = String::from_utf8(
2162 std::process::Command::new("git")
2163 .args(["rev-parse", "HEAD"])
2164 .current_dir(root)
2165 .output()
2166 .unwrap()
2167 .stdout,
2168 )
2169 .unwrap()
2170 .trim()
2171 .to_string();
2172
2173 std::fs::write(root.join("keep.rs"), "two").unwrap();
2175 std::fs::remove_file(root.join("gone.rs")).unwrap();
2176 std::fs::write(root.join("new.rs"), "hi").unwrap();
2177 std::fs::write(root.join("note.md"), "still ignored").unwrap();
2178 git(root, &["add", "-A"]);
2179 git(root, &["commit", "-qm", "move"]);
2180
2181 let source = primary(vec![PatternEntry {
2183 path: "**/*.rs".to_string(),
2184 mode: PatternMode::Allow,
2185 }]);
2186 let outcome = compute_git_slice(&source, &[], root, Some(&baseline));
2187 match outcome {
2188 SliceOutcome::Changed {
2189 slice, degraded, ..
2190 } => {
2191 assert!(!degraded);
2192 assert_eq!(slice.added, vec!["new.rs"]);
2193 assert_eq!(slice.modified, vec!["keep.rs"]);
2194 assert_eq!(slice.deleted, vec!["gone.rs"]);
2195 }
2196 other => panic!("expected Changed, got {other:?}"),
2197 }
2198
2199 let head = String::from_utf8(
2201 std::process::Command::new("git")
2202 .args(["rev-parse", "HEAD"])
2203 .current_dir(root)
2204 .output()
2205 .unwrap()
2206 .stdout,
2207 )
2208 .unwrap()
2209 .trim()
2210 .to_string();
2211 assert!(matches!(
2212 compute_git_slice(&source, &[], root, Some(&head)),
2213 SliceOutcome::Unchanged { .. }
2214 ));
2215
2216 assert!(matches!(
2218 compute_git_slice(&source, &[], root, None),
2219 SliceOutcome::Reseed { .. }
2220 ));
2221 }
2222
2223 #[test]
2231 fn scope_patterns_resolve_against_the_source_pointer() {
2232 let tmp = tempfile::tempdir().unwrap();
2233 let root = tmp.path();
2234 std::fs::create_dir_all(root.join("src/lib/deep")).unwrap();
2235 std::fs::write(root.join("src/lib/a.rs"), "").unwrap();
2236 std::fs::write(root.join("src/lib/deep/b.rs"), "").unwrap();
2237 std::fs::write(root.join("src/lib/skip.md"), "").unwrap();
2238 std::fs::write(root.join("outside.rs"), "").unwrap();
2239
2240 let mut source = primary(vec![
2241 PatternEntry {
2242 path: "**/*.rs".to_string(),
2243 mode: PatternMode::Allow,
2244 },
2245 PatternEntry {
2246 path: "deep/**".to_string(),
2247 mode: PatternMode::Deny,
2248 },
2249 ]);
2250 source.pointer = "src/lib".to_string();
2251 let got = enumerate_facet_files(&source, &[], root);
2252 assert_eq!(
2253 got,
2254 vec!["src/lib/a.rs".to_string()],
2255 "the deny is source-relative too, and nothing outside the pointer enters"
2256 );
2257 }
2258
2259 #[test]
2263 fn a_bare_literal_under_the_pointer_selects_its_file() {
2264 let tmp = tempfile::tempdir().unwrap();
2265 let root = tmp.path();
2266 std::fs::create_dir_all(root.join("pkg")).unwrap();
2267 std::fs::write(root.join("pkg/Cargo.toml"), "").unwrap();
2268 std::fs::write(root.join("pkg/other.toml"), "").unwrap();
2269
2270 let mut source = primary(vec![PatternEntry {
2271 path: "Cargo.toml".to_string(),
2272 mode: PatternMode::Allow,
2273 }]);
2274 source.pointer = "pkg".to_string();
2275 assert_eq!(
2276 enumerate_facet_files(&source, &[], root),
2277 vec!["pkg/Cargo.toml".to_string()]
2278 );
2279 }
2280
2281 #[test]
2285 fn ingest_denies_stay_workspace_relative() {
2286 let tmp = tempfile::tempdir().unwrap();
2287 let root = tmp.path();
2288 std::fs::create_dir_all(root.join("src/secret")).unwrap();
2289 std::fs::write(root.join("src/keep.rs"), "").unwrap();
2290 std::fs::write(root.join("src/secret/hide.rs"), "").unwrap();
2291
2292 let mut source = primary(vec![PatternEntry {
2293 path: "**/*.rs".to_string(),
2294 mode: PatternMode::Allow,
2295 }]);
2296 source.pointer = "src".to_string();
2297 let got = enumerate_facet_files(&source, &["src/secret/**".to_string()], root);
2298 assert_eq!(got, vec!["src/keep.rs".to_string()]);
2299 }
2300
2301 #[test]
2304 fn an_empty_pointer_is_unaffected_by_the_join() {
2305 let tmp = tempfile::tempdir().unwrap();
2306 let root = tmp.path();
2307 std::fs::create_dir_all(root.join("docs")).unwrap();
2308 std::fs::write(root.join("docs/a.md"), "").unwrap();
2309 std::fs::write(root.join("docs/b.txt"), "").unwrap();
2310
2311 let source = primary(vec![PatternEntry {
2312 path: "docs/**/*.md".to_string(),
2313 mode: PatternMode::Allow,
2314 }]);
2315 assert_eq!(
2316 enumerate_facet_files(&source, &[], root),
2317 vec!["docs/a.md".to_string()]
2318 );
2319 }
2320
2321 #[test]
2328 fn the_enumerator_applies_the_oracle_extra_rules_too() {
2329 let tmp = tempfile::tempdir().unwrap();
2330 let root = tmp.path();
2331 for dir in ["bare", "broken"] {
2332 std::fs::create_dir_all(root.join(dir)).unwrap();
2333 std::fs::write(root.join(dir).join("f.rs"), "").unwrap();
2334 }
2335 std::fs::write(root.join("keep.rs"), "").unwrap();
2336
2337 let denies = vec!["bare".to_string(), "broken[".to_string()];
2340 let source = primary(vec![PatternEntry {
2341 path: "**/*.rs".to_string(),
2342 mode: PatternMode::Allow,
2343 }]);
2344 let enumerated = enumerate_facet_files(&source, &denies, root);
2345 assert_eq!(
2346 enumerated,
2347 vec!["keep.rs".to_string()],
2348 "both extra rules must exclude from S(D)"
2349 );
2350
2351 let verdicts = super::super::check_path::check_deny_paths(
2353 &denies,
2354 &[
2355 "bare/f.rs".to_string(),
2356 "broken/f.rs".to_string(),
2357 "keep.rs".to_string(),
2358 ],
2359 root,
2360 root,
2361 );
2362 let denied: Vec<bool> = verdicts.iter().map(|v| v.denied).collect();
2363 assert_eq!(
2364 denied,
2365 vec![true, true, false],
2366 "the hook and the enumerator must agree path for path"
2367 );
2368 }
2369
2370 #[test]
2377 fn the_dead_deny_lint_agrees_with_the_resolution_that_enforces() {
2378 use crate::binding::BuildMode;
2379 use crate::pipeline::IngestTrigger;
2380 let tmp = tempfile::tempdir().unwrap();
2381 let root = tmp.path();
2382 std::fs::create_dir_all(root.join("bare")).unwrap();
2383 std::fs::write(root.join("bare/f.md"), "").unwrap();
2384 std::fs::write(root.join("keep.md"), "").unwrap();
2385
2386 let live = super::super::check_path::DenyOracle::new(&["bare".to_string()]);
2387 assert!(
2388 live.is_denied("bare/f.md"),
2389 "the enforcing resolution blocks a bare name by its literal base"
2390 );
2391
2392 let resolved = ResolvedIngest {
2393 name: "ing".to_string(),
2394 mode: BuildMode::Discovery,
2395 trigger: IngestTrigger::Loop,
2396 batch_size: 50,
2397 deny_paths: vec!["bare".to_string(), "nothing-here/**".to_string()],
2398 projection_ref: "m/p".to_string(),
2399 projection_mem: "m".to_string(),
2400 projection_name: "p".to_string(),
2401 intent: None,
2402 sources: vec![ResolvedSource::Primary(primary(vec![PatternEntry {
2403 path: "**/*.md".to_string(),
2404 mode: PatternMode::Allow,
2405 }]))],
2406 destination_mem: "m".to_string(),
2407 rules: None,
2408 post_actions: None,
2409 };
2410 let dead = dead_deny_entries(&resolved, root);
2411 assert_eq!(
2412 dead,
2413 vec!["nothing-here/**".to_string()],
2414 "only the genuinely dead entry is reported; got {dead:?}"
2415 );
2416 }
2417
2418 #[test]
2424 fn a_legacy_dialect_pattern_makes_the_enumeration_partial() {
2425 let tmp = tempfile::tempdir().unwrap();
2426 let root = tmp.path();
2427 std::fs::create_dir_all(root.join("src")).unwrap();
2428 std::fs::write(root.join("src/a.rs"), "").unwrap();
2429 std::fs::write(root.join("src/b.md"), "").unwrap();
2430
2431 let mut source = primary(vec![
2432 PatternEntry {
2433 path: "**/*.rs".to_string(),
2434 mode: PatternMode::Allow,
2435 },
2436 PatternEntry {
2439 path: "src/b.md".to_string(),
2440 mode: PatternMode::Allow,
2441 },
2442 ]);
2443 source.pointer = "src".to_string();
2444
2445 let got = enumerate_facet_files_reported(&source, &[], root);
2446 assert_eq!(got.files, vec!["src/a.rs".to_string()]);
2447 assert!(got.malformed.is_empty(), "nothing here fails to compile");
2448 assert!(
2449 got.is_partial(),
2450 "a legacy-dialect pattern truncates the set just as a malformed one does"
2451 );
2452 let why = got.partiality_reason().expect("a reason is stated");
2453 assert!(
2454 why.contains("src/b.md") && why.contains("pointer"),
2455 "the reason must name the pattern and the cause: {why}"
2456 );
2457 }
2458
2459 #[test]
2463 fn a_malformed_allow_does_not_empty_the_enumeration() {
2464 let tmp = tempfile::tempdir().unwrap();
2465 let root = tmp.path();
2466 std::fs::write(root.join("a.rs"), "").unwrap();
2467
2468 let source = primary(vec![
2469 PatternEntry {
2470 path: "**/*.rs".to_string(),
2471 mode: PatternMode::Allow,
2472 },
2473 PatternEntry {
2474 path: "[unclosed".to_string(),
2475 mode: PatternMode::Allow,
2476 },
2477 ]);
2478 let got = enumerate_facet_files_reported(&source, &[], root);
2479 assert_eq!(got.files, vec!["a.rs".to_string()]);
2480 assert_eq!(got.malformed, vec!["[unclosed".to_string()]);
2481 assert!(got.is_partial(), "a skipped pattern makes the set partial");
2482 }
2483
2484 #[test]
2487 fn a_malformed_deny_does_not_disable_the_other_denies() {
2488 let tmp = tempfile::tempdir().unwrap();
2489 let root = tmp.path();
2490 std::fs::create_dir_all(root.join("skip")).unwrap();
2491 std::fs::write(root.join("keep.rs"), "").unwrap();
2492 std::fs::write(root.join("skip/gone.rs"), "").unwrap();
2493
2494 let source = primary(vec![
2495 PatternEntry {
2496 path: "**/*.rs".to_string(),
2497 mode: PatternMode::Allow,
2498 },
2499 PatternEntry {
2500 path: "skip/**".to_string(),
2501 mode: PatternMode::Deny,
2502 },
2503 PatternEntry {
2504 path: "[unclosed".to_string(),
2505 mode: PatternMode::Deny,
2506 },
2507 ]);
2508 let got = enumerate_facet_files_reported(&source, &[], root);
2509 assert_eq!(
2510 got.files,
2511 vec!["keep.rs".to_string()],
2512 "the good deny still applies"
2513 );
2514 assert_eq!(got.malformed, vec!["[unclosed".to_string()]);
2515 }
2516
2517 #[test]
2521 fn validation_refuses_a_malformed_scope_pattern_by_name() {
2522 use crate::binding::CapabilityError;
2523 let mut binding = crate::binding::scaffold_binding(crate::binding::ScaffoldParams {
2524 destination_mem: "home",
2525 source_name: "src",
2526 medium_type: MediumType::Codebase,
2527 pointer: "src",
2528 intent: None,
2529 additional_deny_paths: Vec::new(),
2530 })
2531 .binding;
2532 binding.sources[0].scope.push(PatternEntry {
2533 path: "[unclosed".to_string(),
2534 mode: PatternMode::Allow,
2535 });
2536 let errs = crate::binding::validate_binding(&binding).unwrap_err();
2537 assert!(
2538 errs.iter().any(|e| matches!(
2539 e,
2540 CapabilityError::MalformedScopePattern { pattern, .. } if pattern == "[unclosed"
2541 )),
2542 "expected MalformedScopePattern naming the pattern, got {errs:?}"
2543 );
2544 }
2545
2546 #[test]
2547 fn migration_notes_name_old_dialect_patterns_and_suggest_the_rewrite() {
2548 let mut source = primary(vec![
2549 PatternEntry {
2550 path: "../public/plugins/claude-code/**/*.md".to_string(),
2551 mode: PatternMode::Allow,
2552 },
2553 PatternEntry {
2554 path: "../public/plugins/claude-code/dist/**".to_string(),
2555 mode: PatternMode::Deny,
2556 },
2557 PatternEntry {
2558 path: "**/*.mjs".to_string(),
2559 mode: PatternMode::Allow,
2560 },
2561 ]);
2562 source.pointer = "../public/plugins/claude-code".to_string();
2563 let notes = scope_migration_notes(&source);
2564 assert_eq!(notes.len(), 2, "the prefix-free pattern is not reported");
2565 assert_eq!(notes[0].suggested.as_deref(), Some("**/*.md"));
2566 assert!(!notes[0].deny);
2567 assert_eq!(notes[1].suggested.as_deref(), Some("dist/**"));
2568 assert!(notes[1].deny);
2569 }
2570
2571 #[test]
2573 fn migration_notes_are_empty_without_a_pointer() {
2574 let source = primary(vec![PatternEntry {
2575 path: "notes/**/*.md".to_string(),
2576 mode: PatternMode::Allow,
2577 }]);
2578 assert!(scope_migration_notes(&source).is_empty());
2579 }
2580
2581 #[test]
2582 fn enumerate_honours_allow_and_deny() {
2583 let ws = tempfile::tempdir().unwrap();
2584 let root = ws.path();
2585 std::fs::create_dir_all(root.join("sub")).unwrap();
2586 std::fs::write(root.join("a.rs"), "").unwrap();
2587 std::fs::write(root.join("sub/b.rs"), "").unwrap();
2588 std::fs::write(root.join("c.md"), "").unwrap();
2589
2590 let source = primary(vec![
2593 PatternEntry {
2594 path: "**/*.rs".to_string(),
2595 mode: PatternMode::Allow,
2596 },
2597 PatternEntry {
2598 path: "sub/**".to_string(),
2599 mode: PatternMode::Deny,
2600 },
2601 ]);
2602 assert_eq!(enumerate_facet_files(&source, &[], root), vec!["a.rs"]);
2603
2604 let mut graph_source = source.clone();
2609 graph_source.medium_type = MediumType::Graph;
2610 assert!(enumerate_facet_files(&graph_source, &[], root).is_empty());
2611 }
2612
2613 #[test]
2619 fn enumerate_prunes_directories_outside_every_allow_prefix() {
2620 let ws = tempfile::tempdir().unwrap();
2621 let root = ws.path();
2622 std::fs::create_dir_all(root.join("notes/drafts")).unwrap();
2623 std::fs::create_dir_all(root.join("target/debug/build")).unwrap();
2624 std::fs::write(root.join("notes/drafts/a.md"), "").unwrap();
2625 std::fs::write(root.join("notes/top.md"), "").unwrap();
2626 std::fs::write(root.join("VISION.md"), "").unwrap();
2627 std::fs::write(root.join("target/debug/build/junk.md"), "").unwrap();
2628
2629 let source = primary(vec![
2630 PatternEntry {
2631 path: "notes/**/*.md".to_string(),
2632 mode: PatternMode::Allow,
2633 },
2634 PatternEntry {
2635 path: "VISION.md".to_string(),
2636 mode: PatternMode::Allow,
2637 },
2638 ]);
2639 assert_eq!(
2640 enumerate_facet_files(&source, &[], root),
2641 vec!["VISION.md", "notes/drafts/a.md", "notes/top.md"],
2642 );
2643 }
2644
2645 #[test]
2651 fn allow_prefix_pruning_helpers() {
2652 assert_eq!(glob_literal_prefix("notes/**/*.md"), vec!["notes"]);
2653 assert_eq!(glob_literal_prefix("VISION.md"), vec!["VISION.md"]);
2654 assert!(glob_literal_prefix("**/*.rs").is_empty());
2655 assert_eq!(glob_literal_prefix("a/b/{c,d}/e"), vec!["a", "b"]);
2656
2657 let prefixes = vec![vec!["notes".to_string()], vec!["VISION.md".to_string()]];
2658 assert!(allow_could_match_under(&prefixes, "notes"));
2659 assert!(allow_could_match_under(&prefixes, "notes/drafts"));
2660 assert!(!allow_could_match_under(&prefixes, "no"));
2661 assert!(!allow_could_match_under(&prefixes, "public"));
2662 assert!(!allow_could_match_under(&prefixes, "target/debug"));
2663
2664 let unanchored = vec![Vec::<String>::new()];
2665 assert!(allow_could_match_under(&unanchored, "anything/at/all"));
2666 }
2667
2668 #[test]
2674 fn graph_enumeration_selects_the_source_mems_entities() {
2675 use crate::workspace::{
2676 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2677 };
2678 use crate::workspace_store::WorkspaceStoreAdapter;
2679
2680 let tmp = tempfile::tempdir().unwrap();
2681 let root = tmp.path();
2682 let mem_dir = root.join("srcmem");
2683 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2684 std::fs::write(
2685 mem_dir.join(".memstead").join("config.json"),
2686 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2687 )
2688 .unwrap();
2689
2690 let entity = |slug: &str, ty: &str, title: &str| {
2691 std::fs::write(
2692 mem_dir.join(format!("{slug}.md")),
2693 format!("---\ntype: {ty}\n---\n\n# {title}\n\n## Decision\n\nBody.\n"),
2694 )
2695 .unwrap();
2696 };
2697 entity("alpha-choice", "decision", "Alpha choice");
2698 entity("beta-choice", "decision", "Beta choice");
2699 entity("gamma-note", "memo", "Gamma note");
2700
2701 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2702 std::fs::write(
2703 root.join(".memstead").join("workspace.toml"),
2704 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2705 )
2706 .unwrap();
2707 crate::FileWorkspaceStore::new()
2708 .save_state(
2709 root,
2710 &Workspace {
2711 mounts: vec![Mount {
2712 mem: "srcmem".to_string(),
2713 schema: Some("default@1.0.0".parse().unwrap()),
2714 storage: MountStorage::Folder {
2715 path: mem_dir.clone(),
2716 },
2717 capability: MountCapability::Write,
2718 lifecycle: MountLifecycle::Eager,
2719 cross_linkable: false,
2720 migration_target: None,
2721 }],
2722 settings: WorkspaceSettings::default(),
2723 },
2724 )
2725 .unwrap();
2726
2727 let engine = crate::Engine::from_workspace_root(root).unwrap();
2728
2729 let graph_source = |patterns: Vec<(&str, PatternMode)>| Source {
2730 name: "g".to_string(),
2731 medium_type: MediumType::Graph,
2732 pointer: "srcmem".to_string(),
2733 change_detection: None,
2734 scope: patterns
2735 .into_iter()
2736 .map(|(p, mode)| crate::pipeline::PatternEntry {
2737 path: p.to_string(),
2738 mode,
2739 })
2740 .collect(),
2741 engagement: None,
2742 preparation: None,
2743 };
2744
2745 let all = enumerate_graph_entities(&engine, &graph_source(vec![("*", PatternMode::Allow)]));
2747 assert_eq!(
2748 all,
2749 vec![
2750 "srcmem--alpha-choice".to_string(),
2751 "srcmem--beta-choice".to_string(),
2752 "srcmem--gamma-note".to_string(),
2753 ],
2754 "the whole-mem selector enumerates every real entity"
2755 );
2756
2757 let decisions = enumerate_graph_entities(
2759 &engine,
2760 &graph_source(vec![("type:decision", PatternMode::Allow)]),
2761 );
2762 assert_eq!(
2763 decisions,
2764 vec![
2765 "srcmem--alpha-choice".to_string(),
2766 "srcmem--beta-choice".to_string()
2767 ],
2768 "type selector excludes the memo"
2769 );
2770
2771 let globbed = enumerate_graph_entities(
2773 &engine,
2774 &graph_source(vec![
2775 ("id:srcmem--*-choice", PatternMode::Allow),
2776 ("id:srcmem--beta-*", PatternMode::Deny),
2777 ]),
2778 );
2779 assert_eq!(
2780 globbed,
2781 vec!["srcmem--alpha-choice".to_string()],
2782 "deny subtracts from allow in the entity namespace too"
2783 );
2784
2785 assert!(
2789 enumerate_graph_entities(&engine, &graph_source(vec![])).is_empty(),
2790 "an unscoped graph facet is never silently 'everything'"
2791 );
2792
2793 assert_eq!(
2795 enumerate_source_artifacts(
2796 &engine,
2797 &graph_source(vec![("*", PatternMode::Allow)]),
2798 &[],
2799 root
2800 ),
2801 all,
2802 "enumerate_source_artifacts routes a graph source to the graph arm"
2803 );
2804 }
2805
2806 #[test]
2812 fn a_stale_entity_anchor_over_a_changed_entity_is_drifted() {
2813 use crate::anchor::{
2814 Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
2815 };
2816 use crate::entity::EntityId;
2817 use crate::workspace::{
2818 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2819 };
2820 use crate::workspace_store::WorkspaceStoreAdapter;
2821
2822 let tmp = tempfile::tempdir().unwrap();
2823 let root = tmp.path();
2824 let mem_dir = root.join("mem");
2825 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2826 std::fs::write(
2827 mem_dir.join(".memstead").join("config.json"),
2828 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2829 )
2830 .unwrap();
2831 std::fs::write(
2832 mem_dir.join("pinned.md"),
2833 "---\ntype: decision\n---\n\n# Pinned\n\n## Decision\n\nOriginal body.\n",
2834 )
2835 .unwrap();
2836 std::fs::write(
2837 mem_dir.join("steady.md"),
2838 "---\ntype: decision\n---\n\n# Steady\n\n## Decision\n\nUnchanged body.\n",
2839 )
2840 .unwrap();
2841
2842 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2843 std::fs::write(
2844 root.join(".memstead").join("workspace.toml"),
2845 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2846 )
2847 .unwrap();
2848 crate::FileWorkspaceStore::new()
2849 .save_state(
2850 root,
2851 &Workspace {
2852 mounts: vec![Mount {
2853 mem: "mem".to_string(),
2854 schema: Some("default@1.0.0".parse().unwrap()),
2855 storage: MountStorage::Folder {
2856 path: mem_dir.clone(),
2857 },
2858 capability: MountCapability::Write,
2859 lifecycle: MountLifecycle::Eager,
2860 cross_linkable: false,
2861 migration_target: None,
2862 }],
2863 settings: WorkspaceSettings::default(),
2864 },
2865 )
2866 .unwrap();
2867
2868 let engine = crate::Engine::from_workspace_root(root).unwrap();
2870 let hash_of = |engine: &crate::Engine, id: &str| {
2871 let e = engine.store().get(&EntityId::canonical(id)).unwrap();
2872 crate::anchor::prepared_content_hash(
2873 crate::render::render_entity_markdown(e, None).as_bytes(),
2874 )
2875 };
2876 let pinned_hash = hash_of(&engine, "mem--pinned");
2877 let steady_hash = hash_of(&engine, "mem--steady");
2878
2879 let entity_anchor = |artifact: &str, hash: &str| Anchor {
2880 artifact: artifact.to_string(),
2881 grain: AnchorGrain::Entity,
2882 class: AnchorProvenanceClass::Anchored,
2883 hash: Some(hash.to_string()),
2884 source: None,
2885 binding: None,
2886 at_version: None,
2887 derived_from: Vec::new(),
2888 hash_stability: crate::anchor::AnchorHashStability::Stable,
2889 span_unvalidated: false,
2890 hash_source: None,
2891 last_observed: None,
2892 };
2893
2894 let mut sidecar = AnchorSidecar::default();
2895 sidecar.set(
2896 "mem--holder",
2897 vec![
2898 entity_anchor("mem--pinned", &pinned_hash),
2899 entity_anchor("mem--steady", &steady_hash),
2900 entity_anchor("mem--vanished", "deadbeefdeadbeef"),
2902 ],
2903 );
2904 std::fs::write(
2905 mem_dir.join(".memstead").join("anchors.json"),
2906 sidecar.to_bytes(),
2907 )
2908 .unwrap();
2909 std::fs::write(
2910 mem_dir.join("holder.md"),
2911 "---\ntype: decision\n---\n\n# Holder\n\n## Decision\n\nHolds anchors.\n",
2912 )
2913 .unwrap();
2914
2915 std::fs::write(
2917 mem_dir.join("pinned.md"),
2918 "---\ntype: decision\n---\n\n# Pinned\n\n## Decision\n\nBody rewritten.\n",
2919 )
2920 .unwrap();
2921
2922 let engine = crate::Engine::from_workspace_root(root).unwrap();
2923 let resolved = engine.entity_anchors_resolved(&EntityId::canonical("mem--holder"));
2924 let state_of = |artifact: &str| {
2925 resolved
2926 .iter()
2927 .find(|r| r.anchor.artifact == artifact)
2928 .unwrap_or_else(|| panic!("no resolved anchor for {artifact}"))
2929 .state
2930 };
2931
2932 assert_eq!(
2933 state_of("mem--pinned"),
2934 Some(AnchorState::Drifted),
2935 "a stale-pinned anchor over a CHANGED entity must be drifted — \
2936 this is the pilot failure that went unflagged"
2937 );
2938 assert_eq!(
2939 state_of("mem--steady"),
2940 Some(AnchorState::Resolves),
2941 "an anchor over an unchanged entity still resolves"
2942 );
2943 assert_eq!(
2944 state_of("mem--vanished"),
2945 Some(AnchorState::Orphaned),
2946 "an anchor over an entity that is not there is orphaned, not unobserved"
2947 );
2948
2949 let mut sc2 = AnchorSidecar::default();
2952 sc2.set(
2953 "mem--holder",
2954 vec![Anchor {
2955 artifact: "https://example.invalid/doc".to_string(),
2956 grain: AnchorGrain::Url,
2957 class: AnchorProvenanceClass::InformedBy,
2958 hash: None,
2959 source: None,
2960 binding: None,
2961 at_version: None,
2962 derived_from: Vec::new(),
2963 hash_stability: crate::anchor::AnchorHashStability::Stable,
2964 span_unvalidated: false,
2965 hash_source: None,
2966 last_observed: None,
2967 }],
2968 );
2969 std::fs::write(
2970 mem_dir.join(".memstead").join("anchors.json"),
2971 sc2.to_bytes(),
2972 )
2973 .unwrap();
2974 let engine = crate::Engine::from_workspace_root(root).unwrap();
2975 let url_state =
2976 engine.entity_anchors_resolved(&EntityId::canonical("mem--holder"))[0].state;
2977 assert_eq!(
2978 url_state, None,
2979 "url anchors stay unobserved — the fix widens observation, never the \
2980 scoring of non-observation"
2981 );
2982 }
2983
2984 #[test]
2994 fn entity_load_bearing_preparation_ignores_notes_edits_and_catches_claim_edits() {
2995 use crate::anchor::{
2996 Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
2997 };
2998 use crate::binding::{
2999 BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, VerifyOperation,
3000 };
3001 use crate::entity::EntityId;
3002 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode, Source};
3003 use crate::workspace::{
3004 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
3005 };
3006 use crate::workspace_store::WorkspaceStoreAdapter;
3007
3008 let tmp = tempfile::tempdir().unwrap();
3009 let root = tmp.path();
3010 let mem_dir = root.join("home");
3011 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3012 std::fs::write(
3013 mem_dir.join(".memstead").join("config.json"),
3014 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3015 )
3016 .unwrap();
3017 let write_pinned = |claim: &str, conditions: &str| {
3020 std::fs::write(
3021 mem_dir.join("pinned.md"),
3022 format!(
3023 "---\ntype: assertion\n---\n\n# Pinned\n\n## Claim\n\n{claim}\n\n\
3024 ## Evidence\n\nMeasured.\n\n## Conditions\n\n{conditions}\n"
3025 ),
3026 )
3027 .unwrap();
3028 };
3029 write_pinned("The sky is blue.", "daylight");
3030 std::fs::write(
3031 mem_dir.join("holder.md"),
3032 "---\ntype: assertion\n---\n\n# Holder\n\n## Claim\n\nDepends on pinned.\n\n\
3033 ## Evidence\n\nSee pinned.\n",
3034 )
3035 .unwrap();
3036
3037 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3038 std::fs::write(
3039 root.join(".memstead").join("workspace.toml"),
3040 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3041 )
3042 .unwrap();
3043 crate::FileWorkspaceStore::new()
3044 .save_state(
3045 root,
3046 &Workspace {
3047 mounts: vec![Mount {
3048 mem: "home".to_string(),
3049 schema: Some("default@1.0.0".parse().unwrap()),
3050 storage: MountStorage::Folder {
3051 path: mem_dir.clone(),
3052 },
3053 capability: MountCapability::Write,
3054 lifecycle: MountLifecycle::Eager,
3055 cross_linkable: false,
3056 migration_target: None,
3057 }],
3058 settings: WorkspaceSettings::default(),
3059 },
3060 )
3061 .unwrap();
3062
3063 let binding_with = |preparation: Option<&str>| Binding {
3067 version: BINDING_VERSION,
3068 intent: None,
3069 sources: vec![Source {
3070 name: "claims".to_string(),
3071 medium_type: MediumType::Graph,
3072 pointer: "home".to_string(),
3073 change_detection: None,
3074 scope: vec![PatternEntry {
3075 path: "*".to_string(),
3076 mode: PatternMode::Allow,
3077 }],
3078 engagement: None,
3079 preparation: preparation.map(str::to_string),
3080 }],
3081 reference_mems: vec![],
3082 destination_mem: "home".to_string(),
3083 deny_paths: vec![],
3084 coverage_semantics: None,
3085 rules: None,
3086 prune: None,
3087 operations: Operations {
3088 build: Some(BuildOperation {
3089 mode: BuildMode::Discovery,
3090 trigger: IngestTrigger::Manual,
3091 batch_size: 5,
3092 post_actions: None,
3093 }),
3094 sync: None,
3095 verify: Some(VerifyOperation {
3096 trigger: IngestTrigger::Manual,
3097 batch_size: 5,
3098 adjudication_cap: 0,
3099 full_resync_every: 0,
3100 }),
3101 },
3102 };
3103 let prepared_binding = binding_with(Some(crate::preparation::ENTITY_LOAD_BEARING));
3104 assert!(crate::binding::validate_binding(&prepared_binding).is_ok());
3105 crate::pipeline_store::write_binding(root, "home", "claims", &prepared_binding).unwrap();
3106
3107 let engine = crate::Engine::from_workspace_root(root).unwrap();
3111 let pinned = engine
3112 .store()
3113 .get(&EntityId::canonical("home--pinned"))
3114 .unwrap();
3115 let type_def = engine
3116 .schema_for("home")
3117 .and_then(|s| s.get_type("assertion"))
3118 .expect("default@1.0.0 declares assertion");
3119 assert!(
3120 crate::preparation::load_bearing_sections(&type_def)
3121 .iter()
3122 .map(|s| s.key.as_str())
3123 .eq(["claim", "evidence"]),
3124 "the required sections are the load-bearing set"
3125 );
3126 let prepared_hash = crate::preparation::entity_prepared_hash(
3127 pinned,
3128 Some(&type_def),
3129 Some(crate::preparation::ENTITY_LOAD_BEARING),
3130 )
3131 .unwrap();
3132 let default_hash =
3133 crate::preparation::entity_prepared_hash(pinned, Some(&type_def), None).unwrap();
3134 assert_ne!(prepared_hash, default_hash);
3135
3136 let anchor = |source: Option<&str>, hash: &str| Anchor {
3137 artifact: "home--pinned".to_string(),
3138 grain: AnchorGrain::Entity,
3139 class: AnchorProvenanceClass::Anchored,
3140 hash: Some(hash.to_string()),
3141 source: source.map(str::to_string),
3142 binding: None,
3143 at_version: None,
3144 derived_from: Vec::new(),
3145 hash_stability: crate::anchor::AnchorHashStability::Stable,
3146 span_unvalidated: false,
3147 hash_source: None,
3148 last_observed: None,
3149 };
3150 std::fs::write(
3152 mem_dir.join("holder2.md"),
3153 "---\ntype: assertion\n---\n\n# Holder2\n\n## Claim\n\nAlso depends.\n\n\
3154 ## Evidence\n\nSee pinned.\n",
3155 )
3156 .unwrap();
3157 let mut sidecar = AnchorSidecar::default();
3158 sidecar.set("home--holder", vec![anchor(Some("claims"), &prepared_hash)]);
3159 sidecar.set("home--holder2", vec![anchor(None, &default_hash)]);
3160 std::fs::write(
3161 mem_dir.join(".memstead").join("anchors.json"),
3162 sidecar.to_bytes(),
3163 )
3164 .unwrap();
3165
3166 let states = |root: &std::path::Path| {
3167 let engine = crate::Engine::from_workspace_root(root).unwrap();
3168 let state_of = |holder: &str| {
3169 engine.entity_anchors_resolved(&EntityId::canonical(holder))[0].state
3170 };
3171 let standalone = engine.verify_mem_anchors("home").unwrap();
3172 (
3173 state_of("home--holder"),
3174 state_of("home--holder2"),
3175 standalone,
3176 )
3177 };
3178
3179 let (prepared, plain, report) = states(root);
3181 assert_eq!(prepared, Some(AnchorState::Resolves));
3182 assert_eq!(plain, Some(AnchorState::Resolves));
3183 assert_eq!((report.resolved, report.drifted), (2, 0));
3184
3185 write_pinned("The sky is blue.", "daylight, clear weather");
3189 let (prepared, plain, report) = states(root);
3190 assert_eq!(
3191 prepared,
3192 Some(AnchorState::Resolves),
3193 "a comma in the notes must not break a load-bearing anchor"
3194 );
3195 assert_eq!(plain, Some(AnchorState::Drifted));
3196 assert_eq!((report.resolved, report.drifted), (1, 1));
3197
3198 write_pinned("The sky is green.", "daylight, clear weather");
3200 let (prepared, plain, report) = states(root);
3201 assert_eq!(prepared, Some(AnchorState::Drifted));
3202 assert_eq!(plain, Some(AnchorState::Drifted));
3203 assert_eq!((report.resolved, report.drifted), (0, 2));
3204
3205 crate::pipeline_store::write_binding(
3209 root,
3210 "home",
3211 "claims",
3212 &binding_with(Some("pdf-to-markdown")),
3213 )
3214 .unwrap();
3215 let (prepared, plain, report) = states(root);
3216 assert_eq!(
3217 prepared, None,
3218 "an unknown preparation yields no observation"
3219 );
3220 assert_eq!(plain, Some(AnchorState::Drifted));
3221 assert_eq!(
3227 (report.unresolvable, report.unobserved, report.drifted),
3228 (0, 1, 1)
3229 );
3230 }
3231
3232 #[test]
3235 fn shuffled_discovery_sequences_identically() {
3236 use crate::preparation::UnitChange;
3237 let unit = |id: &str, order: &str| DeliveredUnit {
3238 id: id.to_string(),
3239 order_key: order.to_string(),
3240 change: UnitChange::Added,
3241 disposed: false,
3242 };
3243 let ordered = vec![
3244 unit("corpus/notes.md#whole", ""),
3245 unit("corpus/b.md#2026-08-20T00:00:00", "2026-08-20T00:00:00"),
3246 unit("corpus/a.md#2026-08-21T00:00:00", "2026-08-21T00:00:00"),
3247 unit("corpus/a.md#2026-08-21T00:00:00.2", "2026-08-21T00:00:00"),
3248 unit("corpus/c.md#2026-08-21T00:00:00", "2026-08-21T00:00:00"),
3249 unit("corpus/b.md#2026-08-22T00:00:00", "2026-08-22T00:00:00"),
3250 ];
3251 for shuffle in [
3252 vec![5, 3, 0, 4, 1, 2],
3253 vec![2, 1, 0, 5, 4, 3],
3254 vec![4, 0, 5, 2, 3, 1],
3255 ] {
3256 let mut units: Vec<DeliveredUnit> =
3257 shuffle.iter().map(|i| ordered[*i].clone()).collect();
3258 sequence_units(&mut units);
3259 assert_eq!(units, ordered, "discovery order {shuffle:?} must not leak");
3260 }
3261
3262 let day = "2026-08-24T00:00:00";
3265 let expected: Vec<String> = (1..=12)
3266 .map(|n| {
3267 if n == 1 {
3268 format!("journal.md#{day}")
3269 } else {
3270 format!("journal.md#{day}.{n}")
3271 }
3272 })
3273 .collect();
3274 let mut units: Vec<DeliveredUnit> = expected.iter().rev().map(|id| unit(id, day)).collect();
3275 sequence_units(&mut units);
3276 assert_eq!(
3277 units.iter().map(|u| u.id.as_str()).collect::<Vec<_>>(),
3278 expected.iter().map(String::as_str).collect::<Vec<_>>()
3279 );
3280 }
3281
3282 #[test]
3292 fn dated_entries_deliver_in_a_total_order_across_first_and_change_runs() {
3293 use crate::anchor::{
3294 Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
3295 };
3296 use crate::binding::{
3297 BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, VerifyOperation,
3298 };
3299 use crate::entity::EntityId;
3300 use crate::ingest::advance::{DispositionInput, advance_baseline};
3301 use crate::ingest::brief::render_changed_slice;
3302 use crate::ingest::resolve::resolve_binding_run;
3303 use crate::pipeline::{IngestTrigger, PatternMode};
3304 use crate::preparation::{DATED_ENTRIES, UnitChange, unitize};
3305 use crate::workspace::{
3306 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
3307 };
3308 use crate::workspace_store::WorkspaceStoreAdapter;
3309
3310 let tmp = tempfile::tempdir().unwrap();
3311 let root = tmp.path();
3312
3313 let mem_dir = root.join("home");
3315 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3316 std::fs::write(
3317 mem_dir.join(".memstead").join("config.json"),
3318 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3319 )
3320 .unwrap();
3321 std::fs::write(
3322 mem_dir.join("holder.md"),
3323 "---\ntype: assertion\n---\n\n# Holder\n\n## Claim\n\nHolds anchors.\n\n\
3324 ## Evidence\n\nSee corpus.\n",
3325 )
3326 .unwrap();
3327 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3328 std::fs::write(
3329 root.join(".memstead").join("workspace.toml"),
3330 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3331 )
3332 .unwrap();
3333 crate::FileWorkspaceStore::new()
3334 .save_state(
3335 root,
3336 &Workspace {
3337 mounts: vec![Mount {
3338 mem: "home".to_string(),
3339 schema: Some("default@1.0.0".parse().unwrap()),
3340 storage: MountStorage::Folder {
3341 path: mem_dir.clone(),
3342 },
3343 capability: MountCapability::Write,
3344 lifecycle: MountLifecycle::Eager,
3345 cross_linkable: false,
3346 migration_target: None,
3347 }],
3348 settings: WorkspaceSettings::default(),
3349 },
3350 )
3351 .unwrap();
3352
3353 let corpus = root.join("corpus");
3356 std::fs::create_dir_all(corpus.join("plain")).unwrap();
3357 git(&corpus, &["init", "-q"]);
3358 let write = |name: &str, text: &str| std::fs::write(corpus.join(name), text).unwrap();
3359 write(
3360 "a.md",
3361 "2026-08-21 alpha one\nbody a1\n2026-08-23 alpha two\nbody a2\n",
3362 );
3363 write(
3364 "b.md",
3365 "2026-08-20 beta one\nbody b1\n2026-08-22 beta two\nbody b2\n",
3366 );
3367 write("notes.md", "undated notes\n");
3368 write("plain/readme.txt", "plain source, file granularity\n");
3369 git(&corpus, &["add", "."]);
3370 git(&corpus, &["commit", "-q", "-m", "corpus"]);
3371
3372 let source = |name: &str, scope: &str, preparation: Option<&str>| Source {
3373 name: name.to_string(),
3374 medium_type: MediumType::Filesystem,
3375 pointer: "corpus".to_string(),
3376 change_detection: Some("git".to_string()),
3377 scope: vec![PatternEntry {
3378 path: scope.to_string(),
3379 mode: PatternMode::Allow,
3380 }],
3381 engagement: None,
3382 preparation: preparation.map(str::to_string),
3383 };
3384 let binding = Binding {
3385 version: BINDING_VERSION,
3386 intent: None,
3387 sources: vec![
3388 source("logs", "*.md", Some(DATED_ENTRIES)),
3390 source("plain", "plain/**", None),
3391 ],
3392 reference_mems: vec![],
3393 destination_mem: "home".to_string(),
3394 deny_paths: vec![],
3395 coverage_semantics: None,
3396 rules: None,
3397 prune: None,
3398 operations: Operations {
3399 build: Some(BuildOperation {
3400 mode: BuildMode::Discovery,
3401 trigger: IngestTrigger::Manual,
3402 batch_size: 3,
3403 post_actions: None,
3404 }),
3405 sync: None,
3406 verify: Some(VerifyOperation {
3407 trigger: IngestTrigger::Manual,
3408 batch_size: 5,
3409 adjudication_cap: 0,
3410 full_resync_every: 0,
3411 }),
3412 },
3413 };
3414 assert!(
3415 crate::binding::validate_binding(&binding).is_ok(),
3416 "{:?}",
3417 crate::binding::validate_binding(&binding)
3418 );
3419 crate::pipeline_store::write_binding(root, "home", "corpus", &binding).unwrap();
3420 let resolved = resolve_binding_run("home/corpus", &binding).unwrap();
3421
3422 let mut engine = crate::Engine::from_workspace_root(root).unwrap();
3424 let cursor = compute_source_cursor(&engine, &resolved, root);
3425 let expected: Vec<&str> = vec![
3426 "corpus/notes.md#whole",
3427 "corpus/b.md#2026-08-20T00:00:00",
3428 "corpus/a.md#2026-08-21T00:00:00",
3429 "corpus/b.md#2026-08-22T00:00:00",
3430 "corpus/a.md#2026-08-23T00:00:00",
3431 ];
3432 assert_eq!(
3433 cursor.delivery.len(),
3434 1,
3435 "one sequence, for the prepared source only"
3436 );
3437 let seq = &cursor.delivery[0];
3438 assert_eq!(
3439 (seq.source.as_str(), seq.preparation.as_str()),
3440 ("logs", DATED_ENTRIES)
3441 );
3442 assert!(seq.first_run && !seq.degraded && seq.batch == 3);
3443 assert_eq!(
3444 seq.units.iter().map(|u| u.id.as_str()).collect::<Vec<_>>(),
3445 expected
3446 );
3447 assert!(
3448 seq.units
3449 .iter()
3450 .all(|u| u.change == UnitChange::Added && !u.disposed)
3451 );
3452 let mut expected_sorted: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
3453 expected_sorted.sort();
3454 assert_eq!(
3455 cursor.union.added, expected_sorted,
3456 "the advance gate accepts the unit ids"
3457 );
3458 assert!(
3461 cursor
3462 .reseed
3463 .iter()
3464 .any(|c| c.key == "home/corpus/plain#synced")
3465 );
3466 assert!(
3467 cursor
3468 .write_commands
3469 .iter()
3470 .any(|c| c.key == "home/corpus/logs#synced")
3471 );
3472 assert!(
3473 !cursor
3474 .union
3475 .added
3476 .iter()
3477 .any(|a| a.starts_with("corpus/plain"))
3478 );
3479 assert_eq!(compute_source_cursor(&engine, &resolved, root), cursor);
3481
3482 let brief = render_changed_slice(&cursor);
3483 assert!(
3484 brief.contains("### Delivery sequence: `logs` (`dated-entries`)"),
3485 "{brief}"
3486 );
3487 assert!(brief.contains("First delivery of this source"));
3488 let listed: Vec<&str> = brief
3489 .lines()
3490 .filter(|l| l.starts_with(|c: char| c.is_ascii_digit()) && l.contains("`corpus/"))
3491 .collect();
3492 assert_eq!(
3493 listed,
3494 vec![
3495 "1. `corpus/notes.md#whole` (new)",
3496 "2. `corpus/b.md#2026-08-20T00:00:00` (new)",
3497 "3. `corpus/a.md#2026-08-21T00:00:00` (new)",
3498 ],
3499 "the batch presents the first three in order"
3500 );
3501 assert!(brief.contains("…and 2 more, presented in order once these are disposed"));
3502 assert!(
3503 !brief.contains("**Added:**"),
3504 "unit ids never repeat in a class list: {brief}"
3505 );
3506
3507 let dispositions: BTreeMap<String, DispositionInput> =
3509 [(expected[0], "skipped"), (expected[1], "worked")]
3510 .into_iter()
3511 .map(|(a, d)| (a.to_string(), DispositionInput::Verdict(d.to_string())))
3512 .collect();
3513 let outcome = advance_baseline(&mut engine, root, &resolved, &dispositions).unwrap();
3514 assert_eq!((outcome.pending, outcome.completed), (3, false));
3515 let cursor = compute_source_cursor(&engine, &resolved, root);
3516 assert!(cursor.delivery[0].units[0].disposed && cursor.delivery[0].units[1].disposed);
3517 let brief = render_changed_slice(&cursor);
3518 assert!(
3519 brief.contains("3. `corpus/a.md#2026-08-21T00:00:00` (new)"),
3520 "{brief}"
3521 );
3522 assert!(
3523 !brief.contains("1. `corpus/notes.md#whole`"),
3524 "disposed units are not re-presented"
3525 );
3526 assert!(brief.contains("2 units of this sequence already disposed"));
3527
3528 let a21_text = std::fs::read_to_string(corpus.join("a.md")).unwrap();
3531 let a21_unit = unitize(DATED_ENTRIES, &a21_text)
3532 .unwrap()
3533 .into_iter()
3534 .find(|u| u.key == "2026-08-21T00:00:00")
3535 .unwrap();
3536 let anchor = |artifact: &str, grain: AnchorGrain, hash: &str| Anchor {
3537 artifact: artifact.to_string(),
3538 grain,
3539 class: AnchorProvenanceClass::Anchored,
3540 hash: Some(hash.to_string()),
3541 source: Some("logs".to_string()),
3542 binding: None,
3543 at_version: None,
3544 derived_from: Vec::new(),
3545 hash_stability: crate::anchor::AnchorHashStability::Stable,
3546 span_unvalidated: false,
3547 hash_source: None,
3548 last_observed: None,
3549 };
3550 let b_file_hash =
3551 crate::anchor::prepared_content_hash(&std::fs::read(corpus.join("b.md")).unwrap());
3552 let mut sidecar = AnchorSidecar::default();
3553 sidecar.set(
3554 "home--holder",
3555 vec![
3556 anchor(expected[2], AnchorGrain::Span, &a21_unit.hash),
3557 anchor("corpus/b.md", AnchorGrain::File, &b_file_hash),
3558 ],
3559 );
3560 std::fs::write(
3561 mem_dir.join(".memstead").join("anchors.json"),
3562 sidecar.to_bytes(),
3563 )
3564 .unwrap();
3565 let mut engine = crate::Engine::from_workspace_root(root).unwrap();
3566 let outcome = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
3567 assert_eq!(
3568 outcome.pending, 2,
3569 "the unit anchor auto-disposed its unit, the file anchor nothing"
3570 );
3571 assert!(outcome.remainder.added.contains(&expected[3].to_string()));
3572 assert!(outcome.remainder.added.contains(&expected[4].to_string()));
3573 let rest: BTreeMap<String, DispositionInput> = [expected[3], expected[4]]
3574 .into_iter()
3575 .map(|a| {
3576 (
3577 a.to_string(),
3578 DispositionInput::Verdict("worked".to_string()),
3579 )
3580 })
3581 .collect();
3582 let outcome = advance_baseline(&mut engine, root, &resolved, &rest).unwrap();
3583 assert!(outcome.completed, "{outcome:?}");
3584 assert!(
3585 outcome
3586 .tokens_written
3587 .contains(&"home/corpus/logs#synced".to_string())
3588 );
3589
3590 write(
3594 "a.md",
3595 "2026-08-21 alpha one\nbody a1\n2026-08-23 alpha two\nbody a2\n2026-08-19 alpha zero\nbody a0\n",
3596 );
3597 write("b.md", "2026-08-22 beta two\nbody b2, revised\n");
3598 git(&corpus, &["add", "."]);
3599 git(&corpus, &["commit", "-q", "-m", "grow, edit, remove"]);
3600 let engine = crate::Engine::from_workspace_root(root).unwrap();
3601 let cursor = compute_source_cursor(&engine, &resolved, root);
3602 let seq = &cursor.delivery[0];
3603 assert!(!seq.first_run && !seq.degraded);
3604 assert_eq!(
3605 seq.units
3606 .iter()
3607 .map(|u| (u.id.as_str(), u.change))
3608 .collect::<Vec<_>>(),
3609 vec![
3610 ("corpus/a.md#2026-08-19T00:00:00", UnitChange::Added),
3611 ("corpus/b.md#2026-08-20T00:00:00", UnitChange::Deleted),
3612 ("corpus/b.md#2026-08-22T00:00:00", UnitChange::Modified),
3613 ]
3614 );
3615 let brief = render_changed_slice(&cursor);
3616 assert!(brief.contains("The units that changed since the last pass"));
3617 assert!(
3618 brief.contains("1. `corpus/a.md#2026-08-19T00:00:00` (new)"),
3619 "{brief}"
3620 );
3621 assert!(brief.contains("3. `corpus/b.md#2026-08-22T00:00:00` (changed)"));
3622
3623 let b22_old_text = "2026-08-22 beta two\nbody b2\n";
3627 let b22_old = unitize(DATED_ENTRIES, b22_old_text).unwrap()[0]
3628 .hash
3629 .clone();
3630 let mut sidecar = AnchorSidecar::default();
3631 sidecar.set(
3632 "home--holder",
3633 vec![
3634 anchor(expected[2], AnchorGrain::Span, &a21_unit.hash),
3635 anchor(expected[1], AnchorGrain::Span, "deadbeefdeadbeef"),
3636 anchor(expected[3], AnchorGrain::Span, &b22_old),
3637 ],
3638 );
3639 std::fs::write(
3640 mem_dir.join(".memstead").join("anchors.json"),
3641 sidecar.to_bytes(),
3642 )
3643 .unwrap();
3644 let engine = crate::Engine::from_workspace_root(root).unwrap();
3645 let resolved_anchors = engine.entity_anchors_resolved(&EntityId::canonical("home--holder"));
3646 let state_of = |artifact: &str| {
3647 resolved_anchors
3648 .iter()
3649 .find(|r| r.anchor.artifact == artifact)
3650 .unwrap()
3651 .state
3652 };
3653 assert_eq!(
3654 state_of(expected[2]),
3655 Some(AnchorState::Resolves),
3656 "unit unchanged, file changed"
3657 );
3658 assert_eq!(
3659 state_of(expected[1]),
3660 Some(AnchorState::Orphaned),
3661 "unit removed"
3662 );
3663 assert_eq!(
3664 state_of(expected[3]),
3665 Some(AnchorState::Drifted),
3666 "unit edited"
3667 );
3668 }
3669
3670 #[test]
3680 fn code_map_anchors_drift_on_interface_changes_only() {
3681 use crate::anchor::{
3682 Anchor, AnchorGrain, AnchorInput, AnchorProvenanceClass, AnchorSidecar, AnchorState,
3683 };
3684 use crate::binding::{
3685 BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, VerifyOperation,
3686 };
3687 use crate::entity::EntityId;
3688 use crate::ingest::resolve::Source;
3689 use crate::pipeline::{IngestTrigger, PatternMode};
3690 use crate::preparation::{CODE_MAP, code_map_digest, code_map_tree_digest};
3691 use crate::workspace::{
3692 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
3693 };
3694 use crate::workspace_store::WorkspaceStoreAdapter;
3695
3696 let tmp = tempfile::tempdir().unwrap();
3697 let root = tmp.path();
3698 let mem_dir = root.join("home");
3699 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3700 std::fs::write(
3701 mem_dir.join(".memstead").join("config.json"),
3702 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3703 )
3704 .unwrap();
3705 std::fs::write(
3706 mem_dir.join("holder.md"),
3707 "---\ntype: assertion\n---\n\n# Holder\n\n## Claim\n\nHolds anchors.\n\n\
3708 ## Evidence\n\nSee code.\n",
3709 )
3710 .unwrap();
3711 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3712 std::fs::write(
3713 root.join(".memstead").join("workspace.toml"),
3714 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3715 )
3716 .unwrap();
3717 crate::FileWorkspaceStore::new()
3718 .save_state(
3719 root,
3720 &Workspace {
3721 mounts: vec![Mount {
3722 mem: "home".to_string(),
3723 schema: Some("default@1.0.0".parse().unwrap()),
3724 storage: MountStorage::Folder {
3725 path: mem_dir.clone(),
3726 },
3727 capability: MountCapability::Write,
3728 lifecycle: MountLifecycle::Eager,
3729 cross_linkable: false,
3730 migration_target: None,
3731 }],
3732 settings: WorkspaceSettings::default(),
3733 },
3734 )
3735 .unwrap();
3736
3737 let corpus = root.join("corpus");
3739 std::fs::create_dir_all(corpus.join("src")).unwrap();
3740 std::fs::create_dir_all(corpus.join("plain")).unwrap();
3741 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";
3742 const B: &str = "// the limit\nexport const LIMIT = 10\n";
3743 let write = |rel: &str, text: &str| std::fs::write(corpus.join(rel), text).unwrap();
3744 write("src/a.js", A);
3745 write("src/b.js", B);
3746 write("plain/notes.js", "export const N = 1\n");
3747
3748 let source = |name: &str, scope: &str, preparation: Option<&str>| Source {
3749 name: name.to_string(),
3750 medium_type: MediumType::Codebase,
3751 pointer: "corpus".to_string(),
3752 change_detection: Some("none".to_string()),
3753 scope: vec![PatternEntry {
3754 path: scope.to_string(),
3755 mode: PatternMode::Allow,
3756 }],
3757 engagement: None,
3758 preparation: preparation.map(str::to_string),
3759 };
3760 let binding = Binding {
3761 version: BINDING_VERSION,
3762 intent: None,
3763 sources: vec![
3764 source("code", "src/**/*.js", Some(CODE_MAP)),
3768 source("plain", "plain/**", None),
3769 ],
3770 reference_mems: vec![],
3771 destination_mem: "home".to_string(),
3772 deny_paths: vec![],
3773 coverage_semantics: None,
3774 rules: None,
3775 prune: None,
3776 operations: Operations {
3777 build: Some(BuildOperation {
3778 mode: BuildMode::Discovery,
3779 trigger: IngestTrigger::Manual,
3780 batch_size: 5,
3781 post_actions: None,
3782 }),
3783 sync: None,
3784 verify: Some(VerifyOperation {
3785 trigger: IngestTrigger::Manual,
3786 batch_size: 5,
3787 adjudication_cap: 0,
3788 full_resync_every: 0,
3789 }),
3790 },
3791 };
3792 assert!(crate::binding::validate_binding(&binding).is_ok());
3793 crate::pipeline_store::write_binding(root, "home", "code", &binding).unwrap();
3794
3795 let digest_hash = |rel: &str, text: &str| {
3796 crate::anchor::prepared_content_hash(code_map_digest(rel, text).as_bytes())
3797 };
3798 let tree_hash = |files: &[(&str, &str)]| {
3799 let owned: Vec<(String, String)> = files
3800 .iter()
3801 .map(|(p, t)| (p.to_string(), t.to_string()))
3802 .collect();
3803 crate::anchor::prepared_content_hash(code_map_tree_digest(&owned).as_bytes())
3804 };
3805 let anchor = |artifact: &str, grain: AnchorGrain, source: &str, hash: &str| Anchor {
3806 artifact: artifact.to_string(),
3807 grain,
3808 class: AnchorProvenanceClass::Anchored,
3809 hash: Some(hash.to_string()),
3810 source: Some(source.to_string()),
3811 binding: None,
3812 at_version: None,
3813 derived_from: Vec::new(),
3814 hash_stability: crate::anchor::AnchorHashStability::Stable,
3815 span_unvalidated: false,
3816 hash_source: None,
3817 last_observed: None,
3818 };
3819 let plain_raw = crate::anchor::prepared_content_hash(b"export const N = 1\n");
3820 let plain_tree = crate::anchor::prepared_content_hash(
3821 crate::preparation::plain_tree_digest(&[(
3822 "corpus/plain/notes.js".to_string(),
3823 b"export const N = 1\n".to_vec(),
3824 )])
3825 .as_bytes(),
3826 );
3827 let mut sidecar = AnchorSidecar::default();
3828 sidecar.set(
3829 "home--holder",
3830 vec![
3831 anchor(
3832 "corpus/src/a.js",
3833 AnchorGrain::File,
3834 "code",
3835 &digest_hash("corpus/src/a.js", A),
3836 ),
3837 anchor(
3838 "corpus/src/a.js#L4-L7",
3839 AnchorGrain::Span,
3840 "code",
3841 &digest_hash("corpus/src/a.js", A),
3842 ),
3843 anchor(
3844 "corpus/src",
3845 AnchorGrain::Tree,
3846 "code",
3847 &tree_hash(&[("corpus/src/a.js", A), ("corpus/src/b.js", B)]),
3848 ),
3849 anchor(
3850 "corpus/plain/notes.js",
3851 AnchorGrain::File,
3852 "plain",
3853 &plain_raw,
3854 ),
3855 anchor("corpus/plain", AnchorGrain::Tree, "plain", &plain_tree),
3856 ],
3857 );
3858 std::fs::write(
3859 mem_dir.join(".memstead").join("anchors.json"),
3860 sidecar.to_bytes(),
3861 )
3862 .unwrap();
3863
3864 let states = |root: &std::path::Path| {
3865 let engine = crate::Engine::from_workspace_root(root).unwrap();
3866 let resolved = engine.entity_anchors_resolved(&EntityId::canonical("home--holder"));
3867 let of = |artifact: &str| {
3868 let r = resolved
3869 .iter()
3870 .find(|r| r.anchor.artifact == artifact)
3871 .unwrap_or_else(|| panic!("no anchor {artifact}"));
3872 (r.state, r.observed_hash.clone())
3873 };
3874 (
3875 of("corpus/src/a.js").0,
3876 of("corpus/src/a.js#L4-L7").0,
3877 of("corpus/src").0,
3878 of("corpus/plain/notes.js").0,
3879 of("corpus/plain"),
3880 engine.verify_mem_anchors("home").unwrap(),
3881 )
3882 };
3883
3884 let (file, span, tree, plain_file, plain_tree_state, report) = states(root);
3888 assert_eq!(
3889 (file, span, tree, plain_file),
3890 (
3891 Some(AnchorState::Resolves),
3892 Some(AnchorState::Resolves),
3893 Some(AnchorState::Resolves),
3894 Some(AnchorState::Resolves)
3895 )
3896 );
3897 assert_eq!(
3898 plain_tree_state,
3899 (Some(AnchorState::Resolves), Some(plain_tree.clone()))
3900 );
3901 assert_eq!((report.resolved, report.drifted, report.recheck), (5, 0, 0));
3902
3903 write(
3905 "src/a.js",
3906 "// auth (rewritten comment)\nimport axios from 'axios'\n\nexport async function login(user, password) {\n return await axios.post('/session', { user, password })\n}\n",
3907 );
3908 let (file, span, tree, _, _, report) = states(root);
3909 assert_eq!(
3910 (file, span, tree),
3911 (
3912 Some(AnchorState::Resolves),
3913 Some(AnchorState::Resolves),
3914 Some(AnchorState::Resolves)
3915 ),
3916 "a body edit must not drift a code-map anchor"
3917 );
3918 assert_eq!(report.drifted, 0);
3919
3920 write(
3922 "src/a.js",
3923 "// auth\nimport axios from 'axios'\n\nexport async function login(user, password, remember) {\n return axios.post('/login', { user, password, remember })\n}\n",
3924 );
3925 let (file, span, tree, plain_file, _, report) = states(root);
3926 assert_eq!(
3927 (file, span, tree),
3928 (
3929 Some(AnchorState::Drifted),
3930 Some(AnchorState::Drifted),
3931 Some(AnchorState::Drifted)
3932 )
3933 );
3934 assert_eq!(plain_file, Some(AnchorState::Resolves));
3935 assert_eq!(report.drifted, 3);
3936
3937 write("src/a.js", A);
3939 write("src/c.js", "export const C = 1\n");
3940 let (file, span, tree, _, _, _) = states(root);
3941 assert_eq!(
3942 (file, span),
3943 (Some(AnchorState::Resolves), Some(AnchorState::Resolves))
3944 );
3945 assert_eq!(
3946 tree,
3947 Some(AnchorState::Drifted),
3948 "a file joining the tree changes its code map"
3949 );
3950
3951 write("plain/notes.js", "export const N = 1 // note\n");
3956 let (_, _, _, plain_file, plain_tree_state, _) = states(root);
3957 assert_eq!(plain_file, Some(AnchorState::Drifted));
3958 assert_eq!(
3959 plain_tree_state.0,
3960 Some(AnchorState::Drifted),
3961 "a body edit in a scoped file drifts the plain tree anchor"
3962 );
3963
3964 let engine = crate::Engine::from_workspace_root(root).unwrap();
3966 let input = AnchorInput {
3967 artifact: Some("corpus/src/b.js".to_string()),
3968 grain: Some("file".to_string()),
3969 class: Some("anchored".to_string()),
3970 source: Some("code".to_string()),
3971 content: Some(B.to_string()),
3972 ..Default::default()
3973 };
3974 let validated = engine.validate_anchor_inputs("home", &[input]).unwrap();
3975 assert_eq!(
3976 validated[0].hash.as_deref(),
3977 Some(digest_hash("corpus/src/b.js", B).as_str())
3978 );
3979 assert_ne!(
3980 validated[0].hash.as_deref(),
3981 Some(crate::anchor::prepared_content_hash(B.as_bytes()).as_str())
3982 );
3983 let plain_input = AnchorInput {
3984 artifact: Some("corpus/plain/notes.js".to_string()),
3985 grain: Some("file".to_string()),
3986 class: Some("anchored".to_string()),
3987 source: Some("plain".to_string()),
3988 content: Some("export const N = 1\n".to_string()),
3989 ..Default::default()
3990 };
3991 let validated = engine
3992 .validate_anchor_inputs("home", &[plain_input])
3993 .unwrap();
3994 assert_eq!(
3995 validated[0].hash.as_deref(),
3996 Some(plain_raw.as_str()),
3997 "no preparation: the raw canonicalization, as before"
3998 );
3999 }
4000
4001 #[test]
4010 fn graph_scoping_changes_only_the_unscoped_arm() {
4011 use crate::binding::BuildMode;
4012
4013 let tmp = tempfile::tempdir().unwrap();
4014 let root = tmp.path();
4015 let mem_dir = root.join("srcmem");
4016 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
4017 std::fs::write(
4018 mem_dir.join(".memstead").join("config.json"),
4019 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
4020 )
4021 .unwrap();
4022 std::fs::write(
4023 mem_dir.join("one.md"),
4024 "---\ntype: decision\n---\n\n# One\n\n## Decision\n\nBody.\n",
4025 )
4026 .unwrap();
4027 std::fs::create_dir_all(root.join(".memstead")).unwrap();
4028 std::fs::write(
4029 root.join(".memstead").join("workspace.toml"),
4030 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4031 )
4032 .unwrap();
4033 {
4034 use crate::workspace::{
4035 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
4036 };
4037 use crate::workspace_store::WorkspaceStoreAdapter;
4038 crate::FileWorkspaceStore::new()
4039 .save_state(
4040 root,
4041 &Workspace {
4042 mounts: vec![Mount {
4043 mem: "srcmem".to_string(),
4044 schema: Some("default@1.0.0".parse().unwrap()),
4045 storage: MountStorage::Folder {
4046 path: mem_dir.clone(),
4047 },
4048 capability: MountCapability::Write,
4049 lifecycle: MountLifecycle::Eager,
4050 cross_linkable: false,
4051 migration_target: None,
4052 }],
4053 settings: WorkspaceSettings::default(),
4054 },
4055 )
4056 .unwrap();
4057 }
4058 let engine = crate::Engine::from_workspace_root(root).unwrap();
4059
4060 let resolved_with = |scope: Vec<crate::pipeline::PatternEntry>| ResolvedIngest {
4061 name: "srcmem/p".to_string(),
4062 mode: BuildMode::Discovery,
4063 trigger: crate::pipeline::IngestTrigger::Manual,
4064 batch_size: 20,
4065 deny_paths: Vec::new(),
4066 projection_ref: "srcmem/p".to_string(),
4067 projection_mem: "srcmem".to_string(),
4068 projection_name: "p".to_string(),
4069 intent: None,
4070 sources: vec![ResolvedSource::Primary(Source {
4071 name: "g".to_string(),
4072 medium_type: MediumType::Graph,
4073 pointer: "srcmem".to_string(),
4074 change_detection: None,
4075 scope,
4076 engagement: None,
4077 preparation: None,
4078 })],
4079 destination_mem: "srcmem".to_string(),
4080 rules: None,
4081 post_actions: None,
4082 };
4083
4084 let scoped = compute_source_cursor(
4085 &engine,
4086 &resolved_with(vec![crate::pipeline::PatternEntry {
4087 path: "*".to_string(),
4088 mode: PatternMode::Allow,
4089 }]),
4090 root,
4091 );
4092 let unscoped = compute_source_cursor(&engine, &resolved_with(Vec::new()), root);
4093
4094 let reason_of = |c: &SourceCursor| c.no_signal.first().map(|n| n.reason);
4095 assert_eq!(
4096 reason_of(&scoped),
4097 Some(NoSignalReason::GraphSnapshotMissing),
4098 "a scoped graph facet still routes to the graph strategy and reports \
4099 its own no-signal reason — the change-detection half is untouched"
4100 );
4101 assert_eq!(
4102 reason_of(&unscoped),
4103 Some(NoSignalReason::Unscoped),
4104 "an unscoped graph facet refuses like every other medium's, instead of \
4105 silently proceeding"
4106 );
4107 }
4108
4109 #[test]
4116 fn git_medium_enumerates_through_the_path_walk() {
4117 let ws = tempfile::tempdir().unwrap();
4118 let root = ws.path();
4119 std::fs::write(root.join("a.rs"), "").unwrap();
4120 std::fs::write(root.join("b.rs"), "").unwrap();
4121
4122 let source = |medium: MediumType| Source {
4123 name: "s".to_string(),
4124 medium_type: medium,
4125 pointer: ".".to_string(),
4126 change_detection: None,
4127 scope: vec![crate::pipeline::PatternEntry {
4128 path: "**/*.rs".to_string(),
4129 mode: PatternMode::Allow,
4130 }],
4131 engagement: None,
4132 preparation: None,
4133 };
4134
4135 let want = vec!["a.rs".to_string(), "b.rs".to_string()];
4136 for medium in [
4137 MediumType::Codebase,
4138 MediumType::Filesystem,
4139 MediumType::Git,
4140 ] {
4141 assert_eq!(
4142 enumerate_facet_files(&source(medium), &[], root),
4143 want,
4144 "{medium:?} walks the file tree — every medium the matrix marks \
4145 enumerable with a path namespace must actually enumerate"
4146 );
4147 assert!(
4148 crate::binding::medium_capabilities(medium).enumerable,
4149 "{medium:?} claims enumerability, and now delivers it"
4150 );
4151 }
4152 }
4153
4154 #[test]
4162 fn a_narrowing_selector_bounds_the_changed_slice_too() {
4163 use crate::workspace::{
4164 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
4165 };
4166 use crate::workspace_store::WorkspaceStoreAdapter;
4167
4168 let tmp = tempfile::tempdir().unwrap();
4169 let root = tmp.path();
4170 let mem_dir = root.join("srcmem");
4171 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
4172 std::fs::write(
4173 mem_dir.join(".memstead").join("config.json"),
4174 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
4175 )
4176 .unwrap();
4177 let write = |slug: &str, ty: &str| {
4178 std::fs::write(
4179 mem_dir.join(format!("{slug}.md")),
4180 format!("---\ntype: {ty}\n---\n\n# {slug}\n\n## Decision\n\nBody.\n"),
4181 )
4182 .unwrap();
4183 };
4184 write("kept", "decision");
4185 write("other", "memo");
4186 std::fs::create_dir_all(root.join(".memstead")).unwrap();
4187 std::fs::write(
4188 root.join(".memstead").join("workspace.toml"),
4189 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4190 )
4191 .unwrap();
4192 crate::FileWorkspaceStore::new()
4193 .save_state(
4194 root,
4195 &Workspace {
4196 mounts: vec![Mount {
4197 mem: "srcmem".to_string(),
4198 schema: Some("default@1.0.0".parse().unwrap()),
4199 storage: MountStorage::Folder {
4200 path: mem_dir.clone(),
4201 },
4202 capability: MountCapability::Write,
4203 lifecycle: MountLifecycle::Eager,
4204 cross_linkable: false,
4205 migration_target: None,
4206 }],
4207 settings: WorkspaceSettings::default(),
4208 },
4209 )
4210 .unwrap();
4211 let engine = crate::Engine::from_workspace_root(root).unwrap();
4212
4213 let source = Source {
4214 name: "g".to_string(),
4215 medium_type: MediumType::Graph,
4216 pointer: "srcmem".to_string(),
4217 change_detection: None,
4218 scope: vec![crate::pipeline::PatternEntry {
4219 path: "type:decision".to_string(),
4220 mode: PatternMode::Allow,
4221 }],
4222 engagement: None,
4223 preparation: None,
4224 };
4225
4226 let mut slice = Slice {
4227 added: vec!["srcmem--other".to_string()],
4228 modified: vec!["srcmem--kept".to_string(), "srcmem--other".to_string()],
4229 deleted: vec!["srcmem--vanished".to_string()],
4230 };
4231 filter_graph_slice_to_scope(&engine, &source, &mut slice);
4232
4233 assert_eq!(
4234 slice.modified,
4235 vec!["srcmem--kept".to_string()],
4236 "the out-of-scope memo is dropped from the slice the brief presents"
4237 );
4238 assert!(
4239 slice.added.is_empty(),
4240 "an added out-of-scope entity is out of scope too"
4241 );
4242 assert_eq!(
4243 slice.deleted,
4244 vec!["srcmem--vanished".to_string()],
4245 "a DELETED entity is kept even though its type can no longer be \
4246 read — a deletion that cannot be classified must be reported, \
4247 never silently dropped"
4248 );
4249
4250 let mut wide = Slice {
4252 added: Vec::new(),
4253 modified: vec!["srcmem--kept".to_string(), "srcmem--other".to_string()],
4254 deleted: Vec::new(),
4255 };
4256 let mut all = source.clone();
4257 all.scope = vec![crate::pipeline::PatternEntry {
4258 path: "*".to_string(),
4259 mode: PatternMode::Allow,
4260 }];
4261 filter_graph_slice_to_scope(&engine, &all, &mut wide);
4262 assert_eq!(wide.modified.len(), 2, "`*` selects the whole mem");
4263 }
4264
4265 #[test]
4269 fn entity_selector_grammar_is_closed() {
4270 use super::EntitySelector;
4271 assert_eq!(parse_entity_selector("*"), Some(EntitySelector::All));
4272 assert_eq!(
4273 parse_entity_selector("type:decision"),
4274 Some(EntitySelector::Type("decision".to_string()))
4275 );
4276 assert_eq!(
4277 parse_entity_selector("id:engine--*"),
4278 Some(EntitySelector::Id("engine--*".to_string()))
4279 );
4280 assert_eq!(parse_entity_selector("**/*"), None);
4283 assert_eq!(parse_entity_selector("src/**"), None);
4284 assert_eq!(parse_entity_selector("type:"), None);
4285 assert_eq!(parse_entity_selector("id:"), None);
4286 assert_eq!(parse_entity_selector(""), None);
4287 }
4288
4289 #[test]
4292 fn mtime_driver_reseeds_then_diffs_precisely() {
4293 let ws = tempfile::tempdir().unwrap();
4294 let root = ws.path();
4295 let cache = root.join(".memstead.cache").join("ingest");
4296 std::fs::write(root.join("a.rs"), "one").unwrap();
4297 std::fs::write(root.join("gone.rs"), "bye").unwrap();
4298 let source = primary(vec![PatternEntry {
4299 path: "**/*.rs".to_string(),
4300 mode: PatternMode::Allow,
4301 }]);
4302
4303 let token = match compute_mtime_slice(&source, "ing", &[], root, &cache, None) {
4305 SliceOutcome::Reseed { token } => token,
4306 other => panic!("expected Reseed, got {other:?}"),
4307 };
4308
4309 std::fs::write(root.join("a.rs"), "one-longer").unwrap();
4311 std::fs::remove_file(root.join("gone.rs")).unwrap();
4312 std::fs::write(root.join("new.rs"), "x").unwrap();
4313
4314 match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&token)) {
4316 SliceOutcome::Changed {
4317 slice, degraded, ..
4318 } => {
4319 assert!(
4320 !degraded,
4321 "memo present → precise, not a degraded full scan"
4322 );
4323 assert_eq!(slice.added, vec!["new.rs"]);
4324 assert_eq!(slice.modified, vec!["a.rs"]);
4325 assert_eq!(
4326 slice.deleted,
4327 vec!["gone.rs"],
4328 "deletions come from the memo"
4329 );
4330 }
4331 other => panic!("expected Changed, got {other:?}"),
4332 }
4333
4334 let stale = super::super::change_detection::serialize_digest_token(
4337 &super::super::change_detection::digest_stat_map(&stat_map_for(&["absent.rs"])),
4338 );
4339 match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&stale)) {
4340 SliceOutcome::Changed { degraded, .. } => assert!(degraded, "memo miss → degraded"),
4341 other => panic!("expected degraded Changed, got {other:?}"),
4342 }
4343 }
4344
4345 fn head_sha(repo: &Path) -> String {
4346 String::from_utf8(
4347 std::process::Command::new("git")
4348 .args(["rev-parse", "HEAD"])
4349 .current_dir(repo)
4350 .output()
4351 .unwrap()
4352 .stdout,
4353 )
4354 .unwrap()
4355 .trim()
4356 .to_string()
4357 }
4358
4359 fn slice_contains(slice: &Slice, path: &str) -> bool {
4360 let p = path.to_string();
4361 slice.added.contains(&p) || slice.modified.contains(&p) || slice.deleted.contains(&p)
4362 }
4363
4364 fn mtime_token(source: &Source, deny: &[String], root: &Path) -> String {
4368 let files = enumerate_facet_files(source, deny, root);
4369 serialize_digest_token(&digest_stat_map(&compute_stat_map(&files, root)))
4370 }
4371
4372 #[test]
4378 fn deny_paths_excluded_from_every_strategy_and_token() {
4379 use crate::binding::BuildMode;
4380 use crate::ingest::refinement::next_batch;
4381 use crate::pipeline::IngestTrigger;
4382
4383 let repo = tempfile::tempdir().unwrap();
4384 let root = repo.path();
4385 let cache = root.join(".memstead.cache").join("ingest");
4386
4387 git(root, &["init", "-q"]);
4390 std::fs::write(root.join("keep.rs"), "one").unwrap();
4391 std::fs::write(root.join("denied.rs"), "secret-one").unwrap();
4392 git(root, &["add", "-A"]);
4393 git(root, &["commit", "-qm", "base"]);
4394 let baseline = head_sha(root);
4395
4396 std::fs::write(root.join("keep.rs"), "two").unwrap();
4398 std::fs::write(root.join("denied.rs"), "secret-two").unwrap();
4399 git(root, &["add", "-A"]);
4400 git(root, &["commit", "-qm", "move"]);
4401
4402 let source = primary(vec![PatternEntry {
4405 path: "**/*.rs".to_string(),
4406 mode: PatternMode::Allow,
4407 }]);
4408 let deny = vec!["denied.rs".to_string()];
4409
4410 match compute_git_slice(&source, &deny, root, Some(&baseline)) {
4412 SliceOutcome::Changed { slice, .. } => {
4413 assert_eq!(slice.modified, vec!["keep.rs"]);
4414 assert!(!slice_contains(&slice, "denied.rs"), "git deny leak");
4415 }
4416 other => panic!("git: expected Changed, got {other:?}"),
4417 }
4418 match compute_git_slice(&source, &[], root, Some(&baseline)) {
4421 SliceOutcome::Changed { slice, .. } => {
4422 assert!(
4423 slice_contains(&slice, "denied.rs"),
4424 "un-denied, denied.rs is a genuine git change"
4425 );
4426 }
4427 other => panic!("git(no-deny): expected Changed, got {other:?}"),
4428 }
4429
4430 assert_eq!(enumerate_facet_files(&source, &deny, root), vec!["keep.rs"]);
4432 assert!(
4433 enumerate_facet_files(&source, &[], root).contains(&"denied.rs".to_string()),
4434 "un-denied, denied.rs is enumerated"
4435 );
4436
4437 let token = match compute_mtime_slice(&source, "ing", &deny, root, &cache, None) {
4439 SliceOutcome::Reseed { token } => token,
4440 other => panic!("mtime reseed expected, got {other:?}"),
4441 };
4442 std::fs::write(root.join("keep.rs"), "three-longer").unwrap();
4443 std::fs::write(root.join("denied.rs"), "secret-three-longer").unwrap();
4444 match compute_mtime_slice(&source, "ing", &deny, root, &cache, Some(&token)) {
4445 SliceOutcome::Changed { slice, .. } => {
4446 assert_eq!(slice.modified, vec!["keep.rs"]);
4447 assert!(!slice_contains(&slice, "denied.rs"), "mtime deny leak");
4448 }
4449 other => panic!("mtime: expected Changed, got {other:?}"),
4450 }
4451
4452 let token_present = mtime_token(&source, &deny, root);
4457 std::fs::remove_file(root.join("denied.rs")).unwrap();
4458 let token_absent = mtime_token(&source, &deny, root);
4459 assert_eq!(
4460 token_present, token_absent,
4461 "denied.rs must not influence the mtime digest / source_moved token"
4462 );
4463 std::fs::write(root.join("denied.rs"), "secret-restored").unwrap();
4464
4465 let resolved = ResolvedIngest {
4467 name: "ing".to_string(),
4468 mode: BuildMode::Discovery,
4469 trigger: IngestTrigger::Loop,
4470 batch_size: 50,
4471 deny_paths: deny.clone(),
4472 projection_ref: "m/p".to_string(),
4473 projection_mem: "m".to_string(),
4474 projection_name: "p".to_string(),
4475 intent: None,
4476 sources: vec![ResolvedSource::Primary(source.clone())],
4477 destination_mem: "m".to_string(),
4478 rules: None,
4479 post_actions: None,
4480 };
4481 let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
4482 let batch = next_batch(&engine, &resolved, root, &cache, 20).unwrap();
4483 assert!(
4484 batch.files.contains(&"keep.rs".to_string()),
4485 "keep.rs batched"
4486 );
4487 assert!(
4488 !batch.files.contains(&"denied.rs".to_string()),
4489 "denied.rs must never enter a refinement batch"
4490 );
4491 }
4492
4493 #[test]
4500 fn unscoped_facet_refuses_uniformly_and_empty_deny_is_distinct() {
4501 let repo = tempfile::tempdir().unwrap();
4502 let root = repo.path();
4503 let cache = root.join(".memstead.cache").join("ingest");
4504 git(root, &["init", "-q"]);
4505 std::fs::write(root.join("a.rs"), "one").unwrap();
4506 git(root, &["add", "-A"]);
4507 git(root, &["commit", "-qm", "base"]);
4508 let baseline = head_sha(root);
4509 std::fs::write(root.join("a.rs"), "two").unwrap();
4510 git(root, &["add", "-A"]);
4511 git(root, &["commit", "-qm", "move"]);
4512
4513 let unscoped = primary(vec![PatternEntry {
4516 path: "target/**".to_string(),
4517 mode: PatternMode::Deny,
4518 }]);
4519 assert_eq!(
4520 compute_git_slice(&unscoped, &[], root, Some(&baseline)),
4521 SliceOutcome::NoSignal {
4522 reason: NoSignalReason::Unscoped
4523 },
4524 "git refuses an unscoped facet"
4525 );
4526 assert_eq!(
4527 compute_mtime_slice(&unscoped, "ing", &[], root, &cache, None),
4528 SliceOutcome::NoSignal {
4529 reason: NoSignalReason::Unscoped
4530 },
4531 "mtime refuses an unscoped facet identically"
4532 );
4533 let empty_scope = primary(vec![]);
4535 assert_eq!(
4536 compute_git_slice(&empty_scope, &[], root, Some(&baseline)),
4537 SliceOutcome::NoSignal {
4538 reason: NoSignalReason::Unscoped
4539 }
4540 );
4541
4542 let scoped = primary(vec![PatternEntry {
4545 path: "**/*.rs".to_string(),
4546 mode: PatternMode::Allow,
4547 }]);
4548 assert!(
4549 matches!(
4550 compute_git_slice(&scoped, &[], root, Some(&baseline)),
4551 SliceOutcome::Changed { .. }
4552 ),
4553 "scoped facet + empty deny_paths → normal git slice, not a refusal"
4554 );
4555 assert!(
4556 matches!(
4557 compute_mtime_slice(&scoped, "ing", &[], root, &cache, None),
4558 SliceOutcome::Reseed { .. }
4559 ),
4560 "scoped facet + empty deny_paths → normal mtime reseed, not a refusal"
4561 );
4562 }
4563
4564 #[test]
4567 fn unscoped_facet_emits_no_refinement_batch() {
4568 use crate::binding::BuildMode;
4569 use crate::ingest::refinement::next_batch;
4570 use crate::pipeline::IngestTrigger;
4571
4572 let ws = tempfile::tempdir().unwrap();
4573 let root = ws.path();
4574 let cache = root.join(".memstead.cache").join("ingest");
4575 std::fs::write(root.join("a.rs"), "x").unwrap();
4576
4577 let resolved = ResolvedIngest {
4578 name: "ing".to_string(),
4579 mode: BuildMode::Discovery,
4580 trigger: IngestTrigger::Loop,
4581 batch_size: 50,
4582 deny_paths: vec![],
4583 projection_ref: "m/p".to_string(),
4584 projection_mem: "m".to_string(),
4585 projection_name: "p".to_string(),
4586 intent: None,
4587 sources: vec![ResolvedSource::Primary(primary(vec![]))],
4589 destination_mem: "m".to_string(),
4590 rules: None,
4591 post_actions: None,
4592 };
4593 assert!(
4594 next_batch(
4595 &crate::Engine::from_mounts(Vec::new()).unwrap(),
4596 &resolved,
4597 root,
4598 &cache,
4599 20
4600 )
4601 .is_none(),
4602 "an all-unscoped ingest emits no refinement batch"
4603 );
4604 }
4605
4606 #[test]
4612 fn compute_source_cursor_notes_no_signal_reasons() {
4613 use crate::binding::BuildMode;
4614 use crate::pipeline::IngestTrigger;
4615
4616 let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
4617 let ws = tempfile::tempdir().unwrap();
4619 let root = ws.path();
4620 std::fs::write(root.join("a.rs"), "x").unwrap();
4621
4622 let allow_rs = || {
4623 vec![PatternEntry {
4624 path: "**/*.rs".to_string(),
4625 mode: PatternMode::Allow,
4626 }]
4627 };
4628 let src = |facet: &str, declared: &str, scope: Vec<PatternEntry>| {
4629 ResolvedSource::Primary(Source {
4630 name: facet.to_string(),
4631 medium_type: MediumType::Filesystem,
4632 pointer: String::new(),
4633 change_detection: Some(declared.to_string()),
4634 scope,
4635 engagement: None,
4636 preparation: None,
4637 })
4638 };
4639
4640 let resolved = ResolvedIngest {
4641 name: "ing".to_string(),
4642 mode: BuildMode::Discovery,
4643 trigger: IngestTrigger::Loop,
4644 batch_size: 20,
4645 deny_paths: vec![],
4646 projection_ref: "m/p".to_string(),
4647 projection_mem: "m".to_string(),
4648 projection_name: "p".to_string(),
4649 intent: None,
4650 sources: vec![
4651 src("plan", "none", allow_rs()),
4653 src("blind", "mtime", vec![]),
4655 src("watched", "mtime", allow_rs()),
4657 ],
4658 destination_mem: "m".to_string(),
4659 rules: None,
4660 post_actions: None,
4661 };
4662
4663 let cursor = compute_source_cursor(&engine, &resolved, root);
4664 let reasons: BTreeMap<&str, NoSignalReason> = cursor
4665 .no_signal
4666 .iter()
4667 .map(|n| (n.source.as_str(), n.reason))
4668 .collect();
4669 assert_eq!(reasons.get("plan"), Some(&NoSignalReason::DetectionNone));
4670 assert_eq!(reasons.get("blind"), Some(&NoSignalReason::Unscoped));
4671 assert!(
4672 !reasons.contains_key("watched"),
4673 "a first-seen (reseed) source is not a no-signal note"
4674 );
4675 assert_eq!(cursor.no_signal.len(), 2);
4676 assert!(cursor.reseed.iter().any(|c| c.key == "ing/watched#synced"));
4678
4679 let out = crate::ingest::brief::render_changed_slice(&cursor);
4681 assert!(out.contains("- `plan`: `signal:none`"));
4682 assert!(out.contains("- `blind`: unscoped facet"));
4683 }
4684
4685 fn stat_map_for(paths: &[&str]) -> super::super::change_detection::StatMap {
4686 paths
4687 .iter()
4688 .map(|p| {
4689 (
4690 (*p).to_string(),
4691 super::super::change_detection::StatEntry { mtime: 1, size: 1 },
4692 )
4693 })
4694 .collect()
4695 }
4696
4697 #[test]
4703 fn engine_state_never_enumerates_even_when_allowed() {
4704 let ws = tempfile::tempdir().unwrap();
4705 let root = ws.path();
4706 for rel in [
4707 ".memstead/state/findings/muehle/f.json",
4708 ".memstead/projections/muehle/f.json",
4709 ".memstead.cache/ingest/source-cursor/muehle/f/f.json",
4710 "custom-repo/README.md",
4711 "Allgemein/Protokoll.md",
4712 "Allgemein/Vertrag.md",
4713 ] {
4714 let path = root.join(rel);
4715 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4716 std::fs::write(&path, "x").unwrap();
4717 }
4718 std::fs::write(
4722 root.join(".memstead/workspace.toml"),
4723 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4724 )
4725 .unwrap();
4726 std::fs::write(
4727 root.join(".memstead/state/mounts.json"),
4728 serde_json::json!({
4729 "format": "memstead-mounts-3",
4730 "mounts": [{
4731 "mem": "muehle",
4732 "schema": "default@1.0.0",
4733 "storage": {
4734 "type": "git-branch",
4735 "gitdir": "custom-repo/.git",
4736 "branch": "refs/heads/muehle"
4737 },
4738 "capability": "write",
4739 "lifecycle": "eager",
4740 "cross_linkable": true
4741 }]
4742 })
4743 .to_string(),
4744 )
4745 .unwrap();
4746
4747 let source = primary(vec![
4749 PatternEntry {
4750 path: "**/*".to_string(),
4751 mode: PatternMode::Allow,
4752 },
4753 PatternEntry {
4754 path: ".memstead/**".to_string(),
4755 mode: PatternMode::Allow,
4756 },
4757 PatternEntry {
4758 path: "custom-repo/**".to_string(),
4759 mode: PatternMode::Allow,
4760 },
4761 ]);
4762 let got = enumerate_facet_files(&source, &[], root);
4763 assert_eq!(
4764 got,
4765 vec!["Allgemein/Protokoll.md", "Allgemein/Vertrag.md"],
4766 "only source artifacts may enter the denominator"
4767 );
4768 }
4769
4770 #[test]
4775 fn git_slice_excludes_engine_state() {
4776 let repo = tempfile::tempdir().unwrap();
4777 let root = repo.path();
4778 git(root, &["init", "-q"]);
4779 std::fs::write(
4780 root.join("workspace.rs"), "x",
4782 )
4783 .unwrap();
4784 std::fs::create_dir_all(root.join(".memstead/state")).unwrap();
4785 std::fs::write(
4786 root.join(".memstead/workspace.toml"),
4787 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4788 )
4789 .unwrap();
4790 std::fs::write(
4791 root.join(".memstead/state/mounts.json"),
4792 serde_json::json!({
4793 "format": "memstead-mounts-3",
4794 "mounts": [{
4795 "mem": "muehle",
4796 "schema": "default@1.0.0",
4797 "storage": {
4798 "type": "git-branch",
4799 "gitdir": "custom-repo/.git",
4800 "branch": "refs/heads/muehle"
4801 },
4802 "capability": "write",
4803 "lifecycle": "eager",
4804 "cross_linkable": true
4805 }]
4806 })
4807 .to_string(),
4808 )
4809 .unwrap();
4810 git(root, &["add", "-A"]);
4811 git(root, &["commit", "-qm", "base"]);
4812 let baseline = String::from_utf8(
4813 std::process::Command::new("git")
4814 .args(["rev-parse", "HEAD"])
4815 .current_dir(root)
4816 .output()
4817 .unwrap()
4818 .stdout,
4819 )
4820 .unwrap()
4821 .trim()
4822 .to_string();
4823
4824 std::fs::write(root.join("real.md"), "signal").unwrap();
4826 std::fs::write(root.join(".memstead/state/findings.json"), "self").unwrap();
4827 std::fs::create_dir_all(root.join("custom-repo")).unwrap();
4828 std::fs::write(root.join("custom-repo/README.md"), "repo").unwrap();
4829 git(root, &["add", "-A"]);
4830 git(root, &["commit", "-qm", "move"]);
4831
4832 let source = primary(vec![PatternEntry {
4833 path: "**/*".to_string(),
4834 mode: PatternMode::Allow,
4835 }]);
4836 match compute_git_slice(&source, &[], root, Some(&baseline)) {
4837 SliceOutcome::Changed { slice, .. } => {
4838 assert_eq!(
4839 slice.added,
4840 vec!["real.md"],
4841 "engine state leaked: {slice:?}"
4842 );
4843 assert!(slice.modified.is_empty(), "{slice:?}");
4844 }
4845 other => panic!("expected Changed, got {other:?}"),
4846 }
4847 }
4848
4849 #[test]
4855 fn dead_deny_lint_exempts_scaffold_defaults_but_not_user_typos() {
4856 use crate::binding::{BuildMode, DEFAULT_SCAFFOLD_DENY_PATHS};
4857 use crate::pipeline::IngestTrigger;
4858
4859 let repo = tempfile::tempdir().unwrap();
4860 let root = repo.path();
4861 git(root, &["init", "-q"]);
4862 std::fs::create_dir_all(root.join("src")).unwrap();
4863 std::fs::write(root.join("src/lib.rs"), "code").unwrap();
4864
4865 let mut deny_paths: Vec<String> = DEFAULT_SCAFFOLD_DENY_PATHS
4866 .iter()
4867 .map(|s| s.to_string())
4868 .collect();
4869 deny_paths.push("typo/**".to_string()); deny_paths.push("src/**".to_string()); let resolved = ResolvedIngest {
4873 name: "ing".to_string(),
4874 mode: BuildMode::Discovery,
4875 trigger: IngestTrigger::Loop,
4876 batch_size: 20,
4877 deny_paths,
4878 projection_ref: "m/p".to_string(),
4879 projection_mem: "m".to_string(),
4880 projection_name: "p".to_string(),
4881 intent: None,
4882 sources: vec![ResolvedSource::Primary(primary(vec![PatternEntry {
4883 path: "**/*.rs".to_string(),
4884 mode: PatternMode::Allow,
4885 }]))],
4886 destination_mem: "m".to_string(),
4887 rules: None,
4888 post_actions: None,
4889 };
4890
4891 let dead = dead_deny_entries(&resolved, root);
4892 assert_eq!(
4893 dead,
4894 vec!["typo/**".to_string()],
4895 "only the user typo is flagged — scaffold defaults and matching \
4896 entries stay silent"
4897 );
4898 }
4899}