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 {
296 let magic = if exclude {
297 ":(glob,exclude)"
298 } else {
299 ":(glob)"
300 };
301 if pattern.starts_with("**") {
302 return format!("{magic}{pattern}");
303 }
304 let resolved = normalize_lexical(&base.join(pattern));
305 let git_rel = relative_path(git_root, &resolved);
306 format!("{magic}{}", git_rel.to_string_lossy())
307}
308
309fn in_repo_pathspec(pattern: &str, git_root: &Path, base: &Path, exclude: bool) -> Option<String> {
314 if pattern.starts_with("**") {
316 return Some(to_git_pathspec(pattern, git_root, base, exclude));
317 }
318 let resolved = normalize_lexical(&base.join(pattern));
319 let git_rel = relative_path(git_root, &resolved);
320 if git_rel
321 .components()
322 .next()
323 .is_some_and(|c| c == Component::ParentDir)
324 {
325 return None;
326 }
327 let magic = if exclude {
328 ":(glob,exclude)"
329 } else {
330 ":(glob)"
331 };
332 Some(format!("{magic}{}", git_rel.to_string_lossy()))
333}
334
335pub(crate) fn build_glob_set(patterns: &[&str]) -> Option<GlobSet> {
340 build_glob_set_reporting(patterns).0
341}
342
343pub(crate) fn build_glob_set_reporting(patterns: &[&str]) -> (Option<GlobSet>, Vec<String>) {
353 let mut builder = GlobSetBuilder::new();
354 let mut malformed = Vec::new();
355 let mut any = false;
356 for pattern in patterns {
357 match Glob::new(pattern) {
358 Ok(g) => {
359 builder.add(g);
360 any = true;
361 }
362 Err(_) => malformed.push((*pattern).to_string()),
363 }
364 }
365 let set = if any { builder.build().ok() } else { None };
366 (set, malformed)
367}
368
369fn facet_unscoped(source: &Source) -> bool {
376 !source.scope.iter().any(|r| r.mode == PatternMode::Allow)
377}
378
379pub fn enumerate_facet_files(
418 source: &Source,
419 deny_paths: &[String],
420 workspace_root: &Path,
421) -> Vec<String> {
422 enumerate_facet_files_reported(source, deny_paths, workspace_root).files
423}
424
425pub fn enumerate_facet_files_reported(
430 source: &Source,
431 deny_paths: &[String],
432 workspace_root: &Path,
433) -> ScopeEnumeration {
434 if !matches!(
435 source.medium_type,
436 MediumType::Codebase | MediumType::Filesystem | MediumType::Git
437 ) {
438 return ScopeEnumeration::default();
439 }
440 let legacy_dialect = scope_migration_notes(source);
441 let mut allows: Vec<&str> = Vec::new();
442 let mut scope_denies: Vec<&str> = Vec::new();
443 for rule in &source.scope {
444 match rule.mode {
445 PatternMode::Allow => allows.push(&rule.path),
446 PatternMode::Deny => scope_denies.push(&rule.path),
447 }
448 }
449 let mut ws_denies: Vec<&str> = deny_paths.iter().map(String::as_str).collect();
455 let forced = engine_state_denies(workspace_root);
459 for f in &forced {
460 ws_denies.push(f);
461 }
462 if allows.is_empty() {
463 return ScopeEnumeration {
464 legacy_dialect,
465 ..Default::default()
466 };
467 }
468 let (allow_set, mut malformed) = build_glob_set_reporting(&allows);
473 let (scope_deny_set, deny_malformed) = build_glob_set_reporting(&scope_denies);
474 malformed.extend(deny_malformed);
475 let ws_oracle = super::check_path::DenyOracle::new(
481 &ws_denies
482 .iter()
483 .map(|d| (*d).to_string())
484 .collect::<Vec<_>>(),
485 );
486 let Some(allow_set) = allow_set else {
487 return ScopeEnumeration {
488 malformed,
489 legacy_dialect,
490 ..Default::default()
491 };
492 };
493
494 let base = medium_base(&source.pointer, workspace_root);
503 let mut out: Vec<String> = Vec::new();
504 let mut stack = vec![base.clone()];
505 while let Some(dir) = stack.pop() {
506 let Ok(entries) = std::fs::read_dir(&dir) else {
507 continue;
508 };
509 for entry in entries.flatten() {
510 let Ok(file_type) = entry.file_type() else {
511 continue;
512 };
513 let path = entry.path();
514 if file_type.is_dir() {
515 let skip = path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
516 VCS_INTERNAL_DIRS.contains(&n) || n == ".memstead" || n == ".memstead.cache"
521 });
522 if !skip {
523 stack.push(path);
524 }
525 } else if file_type.is_file() {
526 let normalized = normalize_lexical(&path);
527 let rel = relative_path(workspace_root, &normalized)
528 .to_string_lossy()
529 .to_string();
530 let src_rel = relative_path(&base, &normalized)
531 .to_string_lossy()
532 .to_string();
533 let denied = scope_deny_set
534 .as_ref()
535 .is_some_and(|d| d.is_match(&src_rel))
536 || ws_oracle.is_denied(&rel);
537 if allow_set.is_match(&src_rel) && !denied {
538 out.push(rel);
539 }
540 }
541 }
542 }
543 out.sort();
544 out.dedup();
545 ScopeEnumeration {
546 files: out,
547 malformed,
548 legacy_dialect,
549 }
550}
551
552#[derive(Debug, Clone, Default, PartialEq, Eq)]
560pub struct ScopeEnumeration {
561 pub files: Vec<String>,
563 pub malformed: Vec<String>,
566 pub legacy_dialect: Vec<ScopePatternNote>,
570}
571
572impl ScopeEnumeration {
573 pub fn is_partial(&self) -> bool {
584 !self.malformed.is_empty() || !self.legacy_dialect.is_empty()
585 }
586
587 pub fn partiality_reason(&self) -> Option<String> {
590 let mut causes = Vec::new();
591 if !self.malformed.is_empty() {
592 causes.push(format!(
593 "scope pattern(s) that would not compile were skipped: {}",
594 self.malformed.join(", ")
595 ));
596 }
597 if !self.legacy_dialect.is_empty() {
598 causes.push(format!(
599 "scope pattern(s) are still written against the workspace root rather than the \
600 source pointer, so they select nothing under the pointer join: {}",
601 self.legacy_dialect
602 .iter()
603 .map(|n| n.pattern.as_str())
604 .collect::<Vec<_>>()
605 .join(", ")
606 ));
607 }
608 (!causes.is_empty()).then(|| causes.join("; "))
609 }
610}
611
612#[derive(Debug, Clone, PartialEq, Eq)]
618pub struct ScopePatternNote {
619 pub pattern: String,
621 pub deny: bool,
623 pub suggested: Option<String>,
627}
628
629pub fn scope_migration_notes(source: &Source) -> Vec<ScopePatternNote> {
639 let pointer = source.pointer.trim_end_matches('/');
640 if pointer.is_empty() {
641 return Vec::new();
642 }
643 let prefix = format!("{pointer}/");
644 source
645 .scope
646 .iter()
647 .filter(|r| !r.path.starts_with("**"))
648 .filter_map(|rule| {
649 let stripped = rule.path.strip_prefix(&prefix)?;
650 Some(ScopePatternNote {
651 pattern: rule.path.clone(),
652 deny: rule.mode == PatternMode::Deny,
653 suggested: (!stripped.is_empty()).then(|| stripped.to_string()),
654 })
655 })
656 .collect()
657}
658
659fn compute_git_slice(
662 source: &Source,
663 deny_paths: &[String],
664 workspace_root: &Path,
665 baseline: Option<&str>,
666) -> SliceOutcome {
667 let base = medium_base(&source.pointer, workspace_root);
668 let Some(git_root) = find_git_root(&base) else {
669 return SliceOutcome::NoSignal {
670 reason: NoSignalReason::GitUnavailable,
671 };
672 };
673 let Some(head) = git_head(&git_root) else {
674 return SliceOutcome::NoSignal {
675 reason: NoSignalReason::GitUnavailable,
676 };
677 };
678
679 let baseline = match baseline {
680 Some(b) if is_git_token(b) => b,
681 _ => return SliceOutcome::Reseed { token: head },
683 };
684 if baseline == head {
685 return SliceOutcome::Unchanged { token: head };
686 }
687 if !commit_exists(&git_root, baseline) {
696 return SliceOutcome::Reseed { token: head };
697 }
698
699 let mut allows: Vec<&str> = Vec::new();
701 let mut scope_denies: Vec<&str> = Vec::new();
702 for rule in &source.scope {
703 match rule.mode {
704 PatternMode::Allow => allows.push(&rule.path),
705 PatternMode::Deny => scope_denies.push(&rule.path),
706 }
707 }
708 if allows.is_empty() {
709 return SliceOutcome::NoSignal {
712 reason: NoSignalReason::Unscoped,
713 };
714 }
715 let mut ws_denies: Vec<&str> = deny_paths.iter().map(String::as_str).collect();
716 let forced = engine_state_denies(workspace_root);
720 for f in &forced {
721 ws_denies.push(f);
722 }
723 let mut specs: Vec<String> =
724 Vec::with_capacity(allows.len() + scope_denies.len() + ws_denies.len());
725 for a in &allows {
730 specs.push(to_git_pathspec(a, &git_root, &base, false));
731 }
732 for d in &scope_denies {
741 if let Some(spec) = in_repo_pathspec(d, &git_root, &base, true) {
742 specs.push(spec);
743 }
744 }
745 for d in &ws_denies {
746 if let Some(spec) = in_repo_pathspec(d, &git_root, workspace_root, true) {
747 specs.push(spec);
748 }
749 }
750
751 let mut cmd = Command::new("git");
752 cmd.args([
753 "diff",
754 "--no-renames",
755 "--name-status",
756 baseline,
757 &head,
758 "--",
759 ]);
760 cmd.args(&specs);
761 cmd.current_dir(&git_root);
762 let out = match cmd.output() {
763 Ok(o) if o.status.success() => o,
764 _ => {
767 return SliceOutcome::NoSignal {
768 reason: NoSignalReason::GitUnavailable,
769 };
770 }
771 };
772 let text = String::from_utf8_lossy(&out.stdout);
773
774 let ws_oracle = super::check_path::DenyOracle::new(
784 &ws_denies
785 .iter()
786 .map(|d| (*d).to_string())
787 .collect::<Vec<_>>(),
788 );
789
790 let mut slice = Slice::default();
791 for line in text.lines() {
792 if line.trim().is_empty() {
793 continue;
794 }
795 let Some(tab) = line.find('\t') else { continue };
796 let status = line[..tab].trim();
797 let git_path = line[tab + 1..].trim();
798 let ws_path = relative_path(workspace_root, &normalize_lexical(&git_root.join(git_path)))
799 .to_string_lossy()
800 .to_string();
801 if ws_oracle.is_denied(&ws_path) {
802 continue;
803 }
804 match status.chars().next() {
805 Some('A') => slice.added.push(ws_path),
806 Some('D') => slice.deleted.push(ws_path),
807 _ => slice.modified.push(ws_path),
809 }
810 }
811 slice.added.sort();
812 slice.modified.sort();
813 slice.deleted.sort();
814 SliceOutcome::Changed {
815 token: head,
816 slice,
817 degraded: false,
818 }
819}
820
821#[derive(Debug, Clone, PartialEq, Eq)]
837pub enum EntitySelector {
838 All,
840 Type(String),
842 Id(String),
844}
845
846pub fn parse_entity_selector(pattern: &str) -> Option<EntitySelector> {
850 let pattern = pattern.trim();
851 if pattern == "*" {
852 return Some(EntitySelector::All);
853 }
854 if let Some(rest) = pattern.strip_prefix("type:") {
855 let rest = rest.trim();
856 if rest.is_empty() {
857 return None;
858 }
859 return Some(EntitySelector::Type(rest.to_string()));
860 }
861 if let Some(rest) = pattern.strip_prefix("id:") {
862 let rest = rest.trim();
863 if rest.is_empty() {
864 return None;
865 }
866 Glob::new(rest).ok()?;
868 return Some(EntitySelector::Id(rest.to_string()));
869 }
870 None
871}
872
873fn selector_matches(selector: &EntitySelector, id: &str, entity_type: &str) -> bool {
875 match selector {
876 EntitySelector::All => true,
877 EntitySelector::Type(t) => entity_type == t,
878 EntitySelector::Id(g) => Glob::new(g)
879 .ok()
880 .map(|glob| glob.compile_matcher().is_match(id))
881 .unwrap_or(false),
882 }
883}
884
885pub fn enumerate_graph_entities(engine: &Engine, source: &Source) -> Vec<String> {
900 if source.medium_type != MediumType::Graph {
901 return Vec::new();
902 }
903 let mut allows: Vec<EntitySelector> = Vec::new();
904 let mut denies: Vec<EntitySelector> = Vec::new();
905 for rule in &source.scope {
906 let Some(sel) = parse_entity_selector(&rule.path) else {
910 continue;
911 };
912 match rule.mode {
913 PatternMode::Allow => allows.push(sel),
914 PatternMode::Deny => denies.push(sel),
915 }
916 }
917 if allows.is_empty() {
918 return Vec::new();
919 }
920 let mem = source.pointer.as_str();
921 let mut out: Vec<String> = Vec::new();
922 for entity in engine.store().all_entities() {
923 if entity.mem != mem || entity.stub {
924 continue;
925 }
926 let id = entity.id.0.as_str();
927 let ty = entity.entity_type.as_str();
928 if !allows.iter().any(|s| selector_matches(s, id, ty)) {
929 continue;
930 }
931 if denies.iter().any(|s| selector_matches(s, id, ty)) {
932 continue;
933 }
934 out.push(id.to_string());
935 }
936 out.sort();
937 out.dedup();
938 out
939}
940
941pub fn enumerate_source_artifacts(
953 engine: &Engine,
954 source: &Source,
955 deny_paths: &[String],
956 workspace_root: &Path,
957) -> Vec<String> {
958 enumerate_source_artifacts_reported(engine, source, deny_paths, workspace_root).files
959}
960
961pub fn enumerate_source_artifacts_reported(
965 engine: &Engine,
966 source: &Source,
967 deny_paths: &[String],
968 workspace_root: &Path,
969) -> ScopeEnumeration {
970 match source.medium_type {
971 MediumType::Codebase | MediumType::Filesystem | MediumType::Git => {
972 enumerate_facet_files_reported(source, deny_paths, workspace_root)
973 }
974 MediumType::Graph => ScopeEnumeration {
977 files: enumerate_graph_entities(engine, source),
978 ..Default::default()
979 },
980 MediumType::Web => ScopeEnumeration::default(),
981 }
982}
983
984fn filter_graph_slice_to_scope(engine: &Engine, source: &Source, slice: &mut Slice) {
1001 let mut allows: Vec<EntitySelector> = Vec::new();
1002 let mut denies: Vec<EntitySelector> = Vec::new();
1003 for rule in &source.scope {
1004 let Some(sel) = parse_entity_selector(&rule.path) else {
1005 continue;
1006 };
1007 match rule.mode {
1008 PatternMode::Allow => allows.push(sel),
1009 PatternMode::Deny => denies.push(sel),
1010 }
1011 }
1012 if allows.is_empty() {
1013 return;
1014 }
1015 let in_scope = |id: &str, known_type: Option<&str>| {
1016 let matches = |s: &EntitySelector| match (s, known_type) {
1020 (EntitySelector::Type(_), None) => true,
1021 _ => selector_matches(s, id, known_type.unwrap_or_default()),
1022 };
1023 allows.iter().any(&matches) && !denies.iter().any(&matches)
1024 };
1025 let type_of = |id: &str| {
1026 engine
1027 .store()
1028 .get(&crate::entity::EntityId::canonical(id))
1029 .map(|e| e.entity_type.clone())
1030 };
1031 slice
1032 .added
1033 .retain(|id| in_scope(id, type_of(id).as_deref()));
1034 slice
1035 .modified
1036 .retain(|id| in_scope(id, type_of(id).as_deref()));
1037 slice.deleted.retain(|id| in_scope(id, None));
1038}
1039
1040fn compute_graph_slice(
1041 engine: &Engine,
1042 source: Option<&Source>,
1043 source_mem: &str,
1044 baseline: Option<&str>,
1045) -> SliceOutcome {
1046 let current = match engine.mem_head_sha(source_mem) {
1047 Ok(Some(sha)) => sha,
1048 _ => {
1050 return SliceOutcome::NoSignal {
1051 reason: NoSignalReason::GraphSnapshotMissing,
1052 };
1053 }
1054 };
1055 let changed = matches!(baseline, Some(b) if is_git_token(b) && b != current);
1057 let mut outcome = if changed {
1058 let baseline = baseline.expect("changed implies a baseline");
1059 match engine.changes_since(source_mem, baseline, None) {
1060 Ok(report) => graph_slice_outcome(Some(baseline), ¤t, &report.changes),
1061 Err(_) => SliceOutcome::NoSignal {
1063 reason: NoSignalReason::GraphSnapshotMissing,
1064 },
1065 }
1066 } else {
1067 graph_slice_outcome(baseline, ¤t, &[])
1068 };
1069 if let (Some(source), SliceOutcome::Changed { slice, .. }) = (source, &mut outcome) {
1075 filter_graph_slice_to_scope(engine, source, slice);
1076 }
1077 outcome
1078}
1079
1080fn cursor_memo_path(cache_root: &Path, ingest_name: &str, facet_ref: &str) -> PathBuf {
1096 let safe: String = facet_ref
1097 .chars()
1098 .map(|c| {
1099 if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
1100 c
1101 } else {
1102 '_'
1103 }
1104 })
1105 .collect();
1106 cache_root
1107 .join("source-cursor")
1108 .join(ingest_name)
1109 .join(format!("{safe}.json"))
1110}
1111
1112fn read_cursor_memo(
1114 cache_root: &Path,
1115 ingest: &str,
1116 facet: &str,
1117 aggregate: &str,
1118) -> Option<StatMap> {
1119 let bytes = std::fs::read(cursor_memo_path(cache_root, ingest, facet)).ok()?;
1120 let memo: BTreeMap<String, StatMap> = serde_json::from_slice(&bytes).ok()?;
1121 memo.get(aggregate).cloned()
1122}
1123
1124fn write_cursor_memo(cache_root: &Path, ingest: &str, facet: &str, aggregate: &str, map: &StatMap) {
1127 let path = cursor_memo_path(cache_root, ingest, facet);
1128 let mut memo: BTreeMap<String, StatMap> = std::fs::read(&path)
1129 .ok()
1130 .and_then(|b| serde_json::from_slice(&b).ok())
1131 .unwrap_or_default();
1132 memo.insert(aggregate.to_string(), map.clone());
1133 if memo.len() > 3 {
1134 let drop: Vec<String> = memo
1136 .keys()
1137 .filter(|k| k.as_str() != aggregate)
1138 .skip(2)
1139 .cloned()
1140 .collect();
1141 for key in drop {
1142 memo.remove(&key);
1143 }
1144 }
1145 if let Some(parent) = path.parent() {
1146 let _ = std::fs::create_dir_all(parent);
1147 }
1148 if let Ok(bytes) = serde_json::to_vec(&memo) {
1149 let _ = std::fs::write(&path, bytes);
1150 }
1151}
1152
1153const VCS_INTERNAL_DIRS: &[&str] = &[".git", ".svn", ".hg"];
1156
1157const DEAD_DENY_SKIP_DIRS: &[&str] = &[
1161 ".git",
1162 "node_modules",
1163 "target",
1164 "dist",
1165 ".memstead.cache",
1166 ".sqlx",
1167 ".svn",
1168 ".hg",
1169];
1170
1171fn walk_tree_bounded(base: &Path, workspace_root: &Path, cap: usize) -> Option<Vec<String>> {
1177 let mut out: Vec<String> = Vec::new();
1178 let mut stack = vec![base.to_path_buf()];
1179 while let Some(dir) = stack.pop() {
1180 let Ok(entries) = std::fs::read_dir(&dir) else {
1181 continue;
1182 };
1183 for entry in entries.flatten() {
1184 let Ok(file_type) = entry.file_type() else {
1185 continue;
1186 };
1187 let path = entry.path();
1188 if file_type.is_dir() {
1189 let skip = path
1190 .file_name()
1191 .and_then(|n| n.to_str())
1192 .is_some_and(|n| DEAD_DENY_SKIP_DIRS.contains(&n));
1193 if !skip {
1194 stack.push(path);
1195 }
1196 } else if file_type.is_file() {
1197 if out.len() >= cap {
1198 return None;
1199 }
1200 out.push(
1201 relative_path(workspace_root, &normalize_lexical(&path))
1202 .to_string_lossy()
1203 .to_string(),
1204 );
1205 }
1206 }
1207 }
1208 Some(out)
1209}
1210
1211fn dead_deny_entries(resolved: &ResolvedIngest, workspace_root: &Path) -> Vec<String> {
1238 if resolved.deny_paths.is_empty() {
1239 return Vec::new();
1240 }
1241 let base = find_git_root(workspace_root).unwrap_or_else(|| workspace_root.to_path_buf());
1242 let Some(files) = walk_tree_bounded(&base, workspace_root, 100_000) else {
1243 return Vec::new();
1244 };
1245 let mut dead: Vec<String> = Vec::new();
1246 for entry in &resolved.deny_paths {
1247 if crate::binding::DEFAULT_SCAFFOLD_DENY_PATHS.contains(&entry.as_str()) {
1248 continue;
1249 }
1250 let oracle = super::check_path::DenyOracle::new(std::slice::from_ref(entry));
1251 if oracle.is_empty() {
1252 continue;
1255 }
1256 if !files.iter().any(|f| oracle.is_denied(f)) {
1257 dead.push(entry.clone());
1258 }
1259 }
1260 dead
1261}
1262
1263fn compute_mtime_slice(
1268 source: &Source,
1269 ingest_name: &str,
1270 deny_paths: &[String],
1271 workspace_root: &Path,
1272 cache_root: &Path,
1273 baseline: Option<&str>,
1274) -> SliceOutcome {
1275 if facet_unscoped(source) {
1276 return SliceOutcome::NoSignal {
1279 reason: NoSignalReason::Unscoped,
1280 };
1281 }
1282 let files = enumerate_facet_files(source, deny_paths, workspace_root);
1283 let now_map = compute_stat_map(&files, workspace_root);
1284 let now_digest = digest_stat_map(&now_map);
1285 write_cursor_memo(
1286 cache_root,
1287 ingest_name,
1288 &source.name,
1289 &now_digest.aggregate,
1290 &now_map,
1291 );
1292 let prev_map = baseline
1293 .and_then(parse_digest_token)
1294 .and_then(|base| read_cursor_memo(cache_root, ingest_name, &source.name, &base.aggregate));
1295 mtime_slice_outcome(baseline, prev_map.as_ref(), &now_map)
1296}
1297
1298fn current_primary_token(
1302 engine: &Engine,
1303 source: &Source,
1304 deny_paths: &[String],
1305 workspace_root: &Path,
1306) -> Option<String> {
1307 match resolve_change_strategy(source, workspace_root) {
1308 ChangeStrategy::Git => git_head(&find_git_root(&medium_base(
1309 &source.pointer,
1310 workspace_root,
1311 ))?),
1312 ChangeStrategy::Graph => {
1313 if facet_unscoped(source) {
1314 None
1317 } else {
1318 engine.mem_head_sha(&source.pointer).ok().flatten()
1319 }
1320 }
1321 ChangeStrategy::Mtime => {
1322 if facet_unscoped(source) {
1323 None
1326 } else {
1327 let files = enumerate_facet_files(source, deny_paths, workspace_root);
1328 Some(serialize_digest_token(&digest_stat_map(&compute_stat_map(
1329 &files,
1330 workspace_root,
1331 ))))
1332 }
1333 }
1334 ChangeStrategy::None => None,
1335 }
1336}
1337
1338pub fn source_moved(engine: &Engine, resolved: &ResolvedIngest, workspace_root: &Path) -> bool {
1345 source_moved_since(engine, resolved, workspace_root, "synced", false)
1346}
1347
1348pub fn source_moved_since(
1360 engine: &Engine,
1361 resolved: &ResolvedIngest,
1362 workspace_root: &Path,
1363 state: &str,
1364 missing_baseline_is_moved: bool,
1365) -> bool {
1366 let dest = &resolved.destination_mem;
1367 let baseline_map = engine
1368 .mem_config_for(dest)
1369 .map(|c| c.sync_state.clone())
1370 .unwrap_or_default();
1371
1372 for source in &resolved.sources {
1373 let (facet_ref, current) = match source {
1374 ResolvedSource::Primary(p) => (
1375 p.name.clone(),
1376 current_primary_token(engine, p, &resolved.deny_paths, workspace_root),
1377 ),
1378 ResolvedSource::Reference { mem } => {
1379 (mem.clone(), engine.mem_head_sha(mem).ok().flatten())
1380 }
1381 };
1382 let key = format!("{}/{}#{state}", resolved.name, facet_ref);
1383 let Some(baseline) = baseline_map.get(&key) else {
1384 if missing_baseline_is_moved && current.as_deref().is_some_and(|c| !c.is_empty()) {
1386 return true;
1387 }
1388 continue;
1389 };
1390 if let Some(current) = current
1391 && !current.is_empty()
1392 && current != *baseline
1393 {
1394 return true;
1395 }
1396 }
1397 false
1398}
1399
1400pub fn compute_source_cursor(
1403 engine: &Engine,
1404 resolved: &ResolvedIngest,
1405 workspace_root: &Path,
1406) -> SourceCursor {
1407 let dest = &resolved.destination_mem;
1408 let baseline_map = engine
1409 .mem_config_for(dest)
1410 .map(|c| c.sync_state.clone())
1411 .unwrap_or_default();
1412
1413 let cache_root = workspace_root.join(".memstead.cache").join("ingest");
1414 let mut union = Slice::default();
1415 let mut write_commands: Vec<SyncCommand> = Vec::new();
1416 let mut reseed: Vec<SyncCommand> = Vec::new();
1417 let mut no_signal: Vec<NoSignalNote> = Vec::new();
1418 let mut delivery: Vec<DeliverySequence> = Vec::new();
1419 let mut degraded = false;
1420 let disposed_units: std::cell::OnceCell<BTreeSet<String>> = std::cell::OnceCell::new();
1424 let disposed_units = || {
1425 disposed_units.get_or_init(|| {
1426 resolved
1427 .name
1428 .split_once('/')
1429 .and_then(|(mem, name)| {
1430 super::advance::read_advance_store(workspace_root, mem, name)
1431 .ok()
1432 .flatten()
1433 })
1434 .map(|state| state.dispositions.keys().cloned().collect())
1435 .unwrap_or_default()
1436 })
1437 };
1438
1439 for source in &resolved.sources {
1440 let primary_medium = match source {
1444 ResolvedSource::Primary(p) => Some(p.medium_type),
1445 ResolvedSource::Reference { .. } => None,
1446 };
1447 let (facet_ref, outcome) = match source {
1448 ResolvedSource::Primary(p) => {
1449 let key = format!("{}/{}#synced", resolved.name, p.name);
1450 let baseline = baseline_map.get(&key).map(String::as_str);
1451 let outcome = match resolve_change_strategy(p, workspace_root) {
1452 ChangeStrategy::Git => {
1453 compute_git_slice(p, &resolved.deny_paths, workspace_root, baseline)
1454 }
1455 ChangeStrategy::Graph if facet_unscoped(p) => SliceOutcome::NoSignal {
1461 reason: NoSignalReason::Unscoped,
1462 },
1463 ChangeStrategy::Graph => {
1464 compute_graph_slice(engine, Some(p), &p.pointer, baseline)
1465 }
1466 ChangeStrategy::Mtime => compute_mtime_slice(
1467 p,
1468 &resolved.name,
1469 &resolved.deny_paths,
1470 workspace_root,
1471 &cache_root,
1472 baseline,
1473 ),
1474 ChangeStrategy::None => SliceOutcome::NoSignal {
1476 reason: NoSignalReason::DetectionNone,
1477 },
1478 };
1479 let outcome =
1484 match crate::preparation::delivery_preparation(p.preparation.as_deref()) {
1485 Some(prep)
1486 if matches!(
1487 p.medium_type,
1488 MediumType::Codebase | MediumType::Filesystem | MediumType::Git
1489 ) =>
1490 {
1491 let (outcome, sequence) = deliver_units(
1492 p,
1493 prep.id,
1494 &resolved.deny_paths,
1495 workspace_root,
1496 baseline,
1497 resolved.batch_size as usize,
1498 disposed_units(),
1499 outcome,
1500 );
1501 delivery.extend(sequence);
1502 outcome
1503 }
1504 _ => outcome,
1505 };
1506 (p.name.clone(), outcome)
1507 }
1508 ResolvedSource::Reference { mem } => {
1509 let key = format!("{}/{}#synced", resolved.name, mem);
1510 let baseline = baseline_map.get(&key).map(String::as_str);
1511 (
1512 mem.clone(),
1513 compute_graph_slice(engine, None, mem, baseline),
1514 )
1515 }
1516 };
1517
1518 let key = format!("{}/{}#synced", resolved.name, facet_ref);
1519 match outcome {
1520 SliceOutcome::Unchanged { .. } => {}
1524 SliceOutcome::NoSignal { reason } => no_signal.push(NoSignalNote {
1526 source: facet_ref.clone(),
1527 reason,
1528 medium_type: primary_medium,
1529 }),
1530 SliceOutcome::Reseed { token } => reseed.push(SyncCommand { key, token }),
1531 SliceOutcome::Changed {
1532 token,
1533 slice,
1534 degraded: d,
1535 } => {
1536 union.added.extend(slice.added);
1537 union.modified.extend(slice.modified);
1538 union.deleted.extend(slice.deleted);
1539 degraded |= d;
1540 write_commands.push(SyncCommand { key, token });
1541 }
1542 }
1543 }
1544
1545 dedupe_sort(&mut union.added);
1546 dedupe_sort(&mut union.modified);
1547 dedupe_sort(&mut union.deleted);
1548 let any_changes =
1549 !union.added.is_empty() || !union.modified.is_empty() || !union.deleted.is_empty();
1550
1551 SourceCursor {
1552 union,
1553 write_commands,
1554 reseed,
1555 no_signal,
1556 any_changes,
1557 degraded,
1558 dead_denies: dead_deny_entries(resolved, workspace_root),
1559 delivery,
1560 dest_mem: dest.clone(),
1561 binding_id: resolved.name.clone(),
1565 }
1566}
1567
1568fn dedupe_sort(v: &mut Vec<String>) {
1569 v.sort();
1570 v.dedup();
1571}
1572
1573fn read_workspace_file(workspace_root: &Path, ws_rel: &str) -> Option<String> {
1575 std::fs::read(workspace_root.join(ws_rel))
1576 .ok()
1577 .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
1578}
1579
1580fn git_baseline_content(
1586 source: &Source,
1587 workspace_root: &Path,
1588 baseline: Option<&str>,
1589 ws_rel: &str,
1590) -> Option<String> {
1591 let baseline = baseline.filter(|b| is_git_token(b))?;
1592 if !matches!(
1593 resolve_change_strategy(source, workspace_root),
1594 ChangeStrategy::Git
1595 ) {
1596 return None;
1597 }
1598 let git_root = find_git_root(&medium_base(&source.pointer, workspace_root))?;
1599 let abs = normalize_lexical(&workspace_root.join(ws_rel));
1600 let rel = relative_path(&git_root, &abs);
1601 let spec = format!("{baseline}:{}", rel.to_string_lossy().replace('\\', "/"));
1602 let out = Command::new("git")
1603 .args(["show", &spec])
1604 .current_dir(&git_root)
1605 .output()
1606 .ok()?;
1607 out.status
1608 .success()
1609 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
1610}
1611
1612pub(crate) fn sequence_units(units: &mut Vec<DeliveredUnit>) {
1619 fn rank(id: &str) -> (&str, u64, &str) {
1620 let (path, key) = crate::preparation::split_unit_id(id);
1621 let key = key.unwrap_or("");
1622 let ordinal = key
1623 .rsplit_once('.')
1624 .and_then(|(_, n)| n.parse::<u64>().ok())
1625 .unwrap_or(1);
1626 (path, ordinal, key)
1627 }
1628 units.sort_by(|a, b| (&a.order_key, rank(&a.id)).cmp(&(&b.order_key, rank(&b.id))));
1629 units.dedup_by(|a, b| a.id == b.id);
1630}
1631
1632#[allow(clippy::too_many_arguments)]
1645fn deliver_units(
1646 source: &Source,
1647 preparation: &str,
1648 deny_paths: &[String],
1649 workspace_root: &Path,
1650 baseline: Option<&str>,
1651 batch: usize,
1652 disposed: &BTreeSet<String>,
1653 outcome: SliceOutcome,
1654) -> (SliceOutcome, Option<DeliverySequence>) {
1655 use crate::preparation::{DeliveryUnit, UnitChange, diff_units, unit_id, unitize};
1656
1657 let units_of =
1658 |text: &str| -> Vec<DeliveryUnit> { unitize(preparation, text).unwrap_or_default() };
1659 let delivered = |path: &str, u: &DeliveryUnit, change: UnitChange| DeliveredUnit {
1660 id: unit_id(path, &u.key),
1661 order_key: u.order_key.clone(),
1662 change,
1663 disposed: false,
1664 };
1665
1666 let mut units: Vec<DeliveredUnit> = Vec::new();
1667 let mut file_level_deleted: Vec<String> = Vec::new();
1668 let mut degraded_units = false;
1669 let (token, first_run, degraded) = match outcome {
1670 SliceOutcome::Reseed { token } => {
1671 for f in enumerate_facet_files(source, deny_paths, workspace_root) {
1672 if let Some(text) = read_workspace_file(workspace_root, &f) {
1673 for u in units_of(&text) {
1674 units.push(delivered(&f, &u, UnitChange::Added));
1675 }
1676 }
1677 }
1678 if units.is_empty() {
1679 return (SliceOutcome::Reseed { token }, None);
1681 }
1682 (token, true, false)
1683 }
1684 SliceOutcome::Changed {
1685 token,
1686 slice,
1687 degraded,
1688 } => {
1689 for f in &slice.added {
1690 if let Some(text) = read_workspace_file(workspace_root, f) {
1691 for u in units_of(&text) {
1692 units.push(delivered(f, &u, UnitChange::Added));
1693 }
1694 }
1695 }
1696 for f in &slice.modified {
1697 let Some(now) = read_workspace_file(workspace_root, f) else {
1698 continue;
1699 };
1700 let new_units = units_of(&now);
1701 match git_baseline_content(source, workspace_root, baseline, f) {
1702 Some(old) => {
1703 for (u, change) in diff_units(&units_of(&old), &new_units) {
1704 units.push(delivered(f, &u, change));
1705 }
1706 }
1707 None => {
1708 degraded_units = true;
1709 for u in new_units {
1710 units.push(delivered(f, &u, UnitChange::Modified));
1711 }
1712 }
1713 }
1714 }
1715 for f in &slice.deleted {
1716 match git_baseline_content(source, workspace_root, baseline, f) {
1717 Some(old) => {
1718 for u in units_of(&old) {
1719 units.push(delivered(f, &u, UnitChange::Deleted));
1720 }
1721 }
1722 None => file_level_deleted.push(f.clone()),
1723 }
1724 }
1725 (token, false, degraded)
1726 }
1727 other => return (other, None),
1728 };
1729
1730 sequence_units(&mut units);
1731 for u in &mut units {
1732 u.disposed = disposed.contains(&u.id);
1733 }
1734
1735 let mut slice = Slice::default();
1736 for u in &units {
1737 match u.change {
1738 UnitChange::Added => slice.added.push(u.id.clone()),
1739 UnitChange::Modified => slice.modified.push(u.id.clone()),
1740 UnitChange::Deleted => slice.deleted.push(u.id.clone()),
1741 }
1742 }
1743 slice.deleted.extend(file_level_deleted);
1744 dedupe_sort(&mut slice.added);
1745 dedupe_sort(&mut slice.modified);
1746 dedupe_sort(&mut slice.deleted);
1747
1748 let sequence = DeliverySequence {
1749 source: source.name.clone(),
1750 preparation: preparation.to_string(),
1751 first_run,
1752 degraded: degraded_units,
1753 batch,
1754 units,
1755 };
1756 (
1757 SliceOutcome::Changed {
1758 token,
1759 slice,
1760 degraded,
1761 },
1762 Some(sequence),
1763 )
1764}
1765
1766#[cfg(test)]
1767mod tests {
1768 use super::*;
1769
1770 #[test]
1771 fn normalize_resolves_dot_and_dotdot() {
1772 assert_eq!(
1773 normalize_lexical(Path::new("/a/b/../c/./d")),
1774 PathBuf::from("/a/c/d")
1775 );
1776 assert_eq!(
1777 normalize_lexical(Path::new("/a/../../b")),
1778 PathBuf::from("/b"),
1779 "dotdot past root is clamped"
1780 );
1781 }
1782
1783 #[test]
1784 fn relative_computes_updowns() {
1785 assert_eq!(
1786 relative_path(Path::new("/a/b"), Path::new("/a/b/c/d")),
1787 PathBuf::from("c/d")
1788 );
1789 assert_eq!(
1790 relative_path(Path::new("/a/b/c"), Path::new("/a/x")),
1791 PathBuf::from("../../x")
1792 );
1793 assert_eq!(
1795 relative_path(Path::new("/m/public"), Path::new("/m/public/crates/x.rs")),
1796 PathBuf::from("crates/x.rs")
1797 );
1798 assert_eq!(
1799 relative_path(Path::new("/m/graph"), Path::new("/m/public/crates/x.rs")),
1800 PathBuf::from("../public/crates/x.rs")
1801 );
1802 }
1803
1804 #[test]
1805 fn pathspec_builds_glob_magic_relative_to_git_root() {
1806 let ws = Path::new("/m/graph");
1807 let git_root = Path::new("/m/public");
1808 assert_eq!(
1809 to_git_pathspec("../public/**/*.rs", git_root, ws, false),
1810 ":(glob)**/*.rs"
1811 );
1812 assert_eq!(
1813 to_git_pathspec("../public/target/**", git_root, ws, true),
1814 ":(glob,exclude)target/**"
1815 );
1816 }
1817
1818 #[test]
1824 fn wildcard_prefixed_pathspec_reanchors_verbatim() {
1825 let ws = Path::new("/m/ws");
1826 let git_root = Path::new("/m/ws/src");
1827 assert_eq!(to_git_pathspec("**/*", git_root, ws, false), ":(glob)**/*");
1828 assert_eq!(
1829 in_repo_pathspec("**/__pycache__/**", git_root, ws, true).as_deref(),
1830 Some(":(glob,exclude)**/__pycache__/**")
1831 );
1832 }
1833
1834 use crate::ingest::resolve::Source;
1835 use crate::pipeline::{MediumType, PatternEntry};
1836
1837 fn git(repo: &Path, args: &[&str]) {
1838 let status = std::process::Command::new("git")
1839 .args(args)
1840 .current_dir(repo)
1841 .env("GIT_AUTHOR_NAME", "t")
1842 .env("GIT_AUTHOR_EMAIL", "t@t")
1843 .env("GIT_COMMITTER_NAME", "t")
1844 .env("GIT_COMMITTER_EMAIL", "t@t")
1845 .output()
1846 .unwrap();
1847 assert!(
1848 status.status.success(),
1849 "git {args:?}: {}",
1850 String::from_utf8_lossy(&status.stderr)
1851 );
1852 }
1853
1854 fn primary(scope: Vec<PatternEntry>) -> Source {
1855 Source {
1856 name: "src".to_string(),
1857 medium_type: MediumType::Codebase,
1858 pointer: String::new(),
1859 change_detection: Some("git".to_string()),
1860 scope,
1861 engagement: None,
1862 preparation: None,
1863 }
1864 }
1865
1866 #[test]
1876 fn deny_dialect_agrees_between_slice_and_check() {
1877 let strs =
1878 |items: &[&str]| -> Vec<String> { items.iter().map(|s| s.to_string()).collect() };
1879 let entries = strs(&["dev/**", "**/VISION.md", "docs/meta/CLAUDE.md"]);
1880 let blocked = strs(&[
1881 "dev/notes/a.md",
1882 "dev/x.rs",
1883 "dev/deep/nested/y.txt",
1884 "VISION.md",
1885 "crates/foo/VISION.md",
1886 "docs/meta/CLAUDE.md",
1887 ]);
1888 let allowed = strs(&[
1889 "src/lib.rs",
1890 "dev-tools/x.rs",
1891 "VISION-draft.md",
1892 "docs/meta/README.md",
1893 "other/CLAUDE.md",
1894 "crates/foo/mod.rs",
1895 ]);
1896
1897 let ws = tempfile::tempdir().unwrap();
1898 for rel in blocked.iter().chain(allowed.iter()) {
1899 let path = ws.path().join(rel);
1900 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1901 std::fs::write(&path, "x").unwrap();
1902 }
1903
1904 let source = primary(vec![PatternEntry {
1906 path: "**".to_string(),
1907 mode: PatternMode::Allow,
1908 }]);
1909 let mut got = enumerate_facet_files(&source, &entries, ws.path());
1910 got.sort();
1911 let mut want = allowed.clone();
1912 want.sort();
1913 assert_eq!(
1914 got, want,
1915 "engine slice must equal the fixture `allowed` set"
1916 );
1917
1918 for b in &blocked {
1919 assert!(
1920 !got.contains(b),
1921 "denied `{b}` leaked into the engine slice"
1922 );
1923 }
1924 let all: Vec<String> = blocked.iter().chain(allowed.iter()).cloned().collect();
1927 let checks =
1928 super::super::check_path::check_deny_paths(&entries, &all, ws.path(), ws.path());
1929 for c in &checks {
1930 let expect = blocked.contains(&c.path);
1931 assert_eq!(
1932 c.denied, expect,
1933 "check_deny_paths disagrees with the slice on `{}`",
1934 c.path
1935 );
1936 }
1937 }
1938
1939 #[test]
1943 fn out_of_repo_deny_pathspec_is_dropped() {
1944 let ws = Path::new("/m/graph");
1945 let git_root = Path::new("/m/public");
1946 assert_eq!(in_repo_pathspec("../dev/**", git_root, ws, true), None);
1948 assert_eq!(in_repo_pathspec("../CLAUDE.md", git_root, ws, true), None);
1949 assert_eq!(
1951 in_repo_pathspec("../public/target/**", git_root, ws, true),
1952 Some(":(glob,exclude)target/**".to_string())
1953 );
1954 }
1955
1956 #[test]
1963 fn foreign_baseline_reseeds_instead_of_degrading() {
1964 let repo = tempfile::tempdir().unwrap();
1965 let root = repo.path();
1966 std::fs::write(root.join("keep.rs"), "one").unwrap();
1967 git(root, &["init", "-q"]);
1968 git(root, &["add", "-A"]);
1969 git(root, &["commit", "-qm", "seed"]);
1970
1971 let source = primary(vec![PatternEntry {
1972 path: "**/*.rs".to_string(),
1973 mode: PatternMode::Allow,
1974 }]);
1975 let foreign = "46ce8add0fe87250527b6fa21fcfdc2d943d51f0";
1977 match compute_git_slice(&source, &[], root, Some(foreign)) {
1978 SliceOutcome::Reseed { token } => {
1979 let head = String::from_utf8(
1981 std::process::Command::new("git")
1982 .args(["rev-parse", "HEAD"])
1983 .current_dir(root)
1984 .output()
1985 .unwrap()
1986 .stdout,
1987 )
1988 .unwrap()
1989 .trim()
1990 .to_string();
1991 assert_eq!(token, head);
1992 }
1993 other => panic!("foreign baseline must reseed, got {other:?}"),
1994 }
1995 }
1996
1997 #[test]
2002 fn git_slice_survives_cross_repo_deny() {
2003 let repo = tempfile::tempdir().unwrap();
2004 let root = repo.path();
2005 std::fs::write(root.join("keep.rs"), "one").unwrap();
2006 git(root, &["init", "-q"]);
2007 git(root, &["add", "-A"]);
2008 git(root, &["commit", "-qm", "seed"]);
2009 let baseline = String::from_utf8(
2010 std::process::Command::new("git")
2011 .args(["rev-parse", "HEAD"])
2012 .current_dir(root)
2013 .output()
2014 .unwrap()
2015 .stdout,
2016 )
2017 .unwrap()
2018 .trim()
2019 .to_string();
2020 std::fs::write(root.join("keep.rs"), "two").unwrap();
2021 git(root, &["add", "-A"]);
2022 git(root, &["commit", "-qm", "move"]);
2023
2024 let source = primary(vec![PatternEntry {
2025 path: "**/*.rs".to_string(),
2026 mode: PatternMode::Allow,
2027 }]);
2028 let outcome = compute_git_slice(&source, &["../dev/**".to_string()], root, Some(&baseline));
2030 match outcome {
2031 SliceOutcome::Changed { slice, .. } => {
2032 assert_eq!(slice.modified, vec!["keep.rs"]);
2033 }
2034 other => panic!("expected Changed (deny dropped), got {other:?}"),
2035 }
2036 }
2037
2038 #[test]
2041 fn git_slice_diffs_baseline_to_head() {
2042 let repo = tempfile::tempdir().unwrap();
2043 let root = repo.path();
2044 git(root, &["init", "-q"]);
2045 std::fs::write(root.join("keep.rs"), "one").unwrap();
2046 std::fs::write(root.join("gone.rs"), "bye").unwrap();
2047 std::fs::write(root.join("note.md"), "ignored-by-scope").unwrap();
2048 git(root, &["add", "-A"]);
2049 git(root, &["commit", "-qm", "base"]);
2050 let baseline = String::from_utf8(
2051 std::process::Command::new("git")
2052 .args(["rev-parse", "HEAD"])
2053 .current_dir(root)
2054 .output()
2055 .unwrap()
2056 .stdout,
2057 )
2058 .unwrap()
2059 .trim()
2060 .to_string();
2061
2062 std::fs::write(root.join("keep.rs"), "two").unwrap();
2064 std::fs::remove_file(root.join("gone.rs")).unwrap();
2065 std::fs::write(root.join("new.rs"), "hi").unwrap();
2066 std::fs::write(root.join("note.md"), "still ignored").unwrap();
2067 git(root, &["add", "-A"]);
2068 git(root, &["commit", "-qm", "move"]);
2069
2070 let source = primary(vec![PatternEntry {
2072 path: "**/*.rs".to_string(),
2073 mode: PatternMode::Allow,
2074 }]);
2075 let outcome = compute_git_slice(&source, &[], root, Some(&baseline));
2076 match outcome {
2077 SliceOutcome::Changed {
2078 slice, degraded, ..
2079 } => {
2080 assert!(!degraded);
2081 assert_eq!(slice.added, vec!["new.rs"]);
2082 assert_eq!(slice.modified, vec!["keep.rs"]);
2083 assert_eq!(slice.deleted, vec!["gone.rs"]);
2084 }
2085 other => panic!("expected Changed, got {other:?}"),
2086 }
2087
2088 let head = String::from_utf8(
2090 std::process::Command::new("git")
2091 .args(["rev-parse", "HEAD"])
2092 .current_dir(root)
2093 .output()
2094 .unwrap()
2095 .stdout,
2096 )
2097 .unwrap()
2098 .trim()
2099 .to_string();
2100 assert!(matches!(
2101 compute_git_slice(&source, &[], root, Some(&head)),
2102 SliceOutcome::Unchanged { .. }
2103 ));
2104
2105 assert!(matches!(
2107 compute_git_slice(&source, &[], root, None),
2108 SliceOutcome::Reseed { .. }
2109 ));
2110 }
2111
2112 #[test]
2120 fn scope_patterns_resolve_against_the_source_pointer() {
2121 let tmp = tempfile::tempdir().unwrap();
2122 let root = tmp.path();
2123 std::fs::create_dir_all(root.join("src/lib/deep")).unwrap();
2124 std::fs::write(root.join("src/lib/a.rs"), "").unwrap();
2125 std::fs::write(root.join("src/lib/deep/b.rs"), "").unwrap();
2126 std::fs::write(root.join("src/lib/skip.md"), "").unwrap();
2127 std::fs::write(root.join("outside.rs"), "").unwrap();
2128
2129 let mut source = primary(vec![
2130 PatternEntry {
2131 path: "**/*.rs".to_string(),
2132 mode: PatternMode::Allow,
2133 },
2134 PatternEntry {
2135 path: "deep/**".to_string(),
2136 mode: PatternMode::Deny,
2137 },
2138 ]);
2139 source.pointer = "src/lib".to_string();
2140 let got = enumerate_facet_files(&source, &[], root);
2141 assert_eq!(
2142 got,
2143 vec!["src/lib/a.rs".to_string()],
2144 "the deny is source-relative too, and nothing outside the pointer enters"
2145 );
2146 }
2147
2148 #[test]
2152 fn a_bare_literal_under_the_pointer_selects_its_file() {
2153 let tmp = tempfile::tempdir().unwrap();
2154 let root = tmp.path();
2155 std::fs::create_dir_all(root.join("pkg")).unwrap();
2156 std::fs::write(root.join("pkg/Cargo.toml"), "").unwrap();
2157 std::fs::write(root.join("pkg/other.toml"), "").unwrap();
2158
2159 let mut source = primary(vec![PatternEntry {
2160 path: "Cargo.toml".to_string(),
2161 mode: PatternMode::Allow,
2162 }]);
2163 source.pointer = "pkg".to_string();
2164 assert_eq!(
2165 enumerate_facet_files(&source, &[], root),
2166 vec!["pkg/Cargo.toml".to_string()]
2167 );
2168 }
2169
2170 #[test]
2174 fn ingest_denies_stay_workspace_relative() {
2175 let tmp = tempfile::tempdir().unwrap();
2176 let root = tmp.path();
2177 std::fs::create_dir_all(root.join("src/secret")).unwrap();
2178 std::fs::write(root.join("src/keep.rs"), "").unwrap();
2179 std::fs::write(root.join("src/secret/hide.rs"), "").unwrap();
2180
2181 let mut source = primary(vec![PatternEntry {
2182 path: "**/*.rs".to_string(),
2183 mode: PatternMode::Allow,
2184 }]);
2185 source.pointer = "src".to_string();
2186 let got = enumerate_facet_files(&source, &["src/secret/**".to_string()], root);
2187 assert_eq!(got, vec!["src/keep.rs".to_string()]);
2188 }
2189
2190 #[test]
2193 fn an_empty_pointer_is_unaffected_by_the_join() {
2194 let tmp = tempfile::tempdir().unwrap();
2195 let root = tmp.path();
2196 std::fs::create_dir_all(root.join("docs")).unwrap();
2197 std::fs::write(root.join("docs/a.md"), "").unwrap();
2198 std::fs::write(root.join("docs/b.txt"), "").unwrap();
2199
2200 let source = primary(vec![PatternEntry {
2201 path: "docs/**/*.md".to_string(),
2202 mode: PatternMode::Allow,
2203 }]);
2204 assert_eq!(
2205 enumerate_facet_files(&source, &[], root),
2206 vec!["docs/a.md".to_string()]
2207 );
2208 }
2209
2210 #[test]
2217 fn the_enumerator_applies_the_oracle_extra_rules_too() {
2218 let tmp = tempfile::tempdir().unwrap();
2219 let root = tmp.path();
2220 for dir in ["bare", "broken"] {
2221 std::fs::create_dir_all(root.join(dir)).unwrap();
2222 std::fs::write(root.join(dir).join("f.rs"), "").unwrap();
2223 }
2224 std::fs::write(root.join("keep.rs"), "").unwrap();
2225
2226 let denies = vec!["bare".to_string(), "broken[".to_string()];
2229 let source = primary(vec![PatternEntry {
2230 path: "**/*.rs".to_string(),
2231 mode: PatternMode::Allow,
2232 }]);
2233 let enumerated = enumerate_facet_files(&source, &denies, root);
2234 assert_eq!(
2235 enumerated,
2236 vec!["keep.rs".to_string()],
2237 "both extra rules must exclude from S(D)"
2238 );
2239
2240 let verdicts = super::super::check_path::check_deny_paths(
2242 &denies,
2243 &[
2244 "bare/f.rs".to_string(),
2245 "broken/f.rs".to_string(),
2246 "keep.rs".to_string(),
2247 ],
2248 root,
2249 root,
2250 );
2251 let denied: Vec<bool> = verdicts.iter().map(|v| v.denied).collect();
2252 assert_eq!(
2253 denied,
2254 vec![true, true, false],
2255 "the hook and the enumerator must agree path for path"
2256 );
2257 }
2258
2259 #[test]
2266 fn the_dead_deny_lint_agrees_with_the_resolution_that_enforces() {
2267 use crate::binding::BuildMode;
2268 use crate::pipeline::IngestTrigger;
2269 let tmp = tempfile::tempdir().unwrap();
2270 let root = tmp.path();
2271 std::fs::create_dir_all(root.join("bare")).unwrap();
2272 std::fs::write(root.join("bare/f.md"), "").unwrap();
2273 std::fs::write(root.join("keep.md"), "").unwrap();
2274
2275 let live = super::super::check_path::DenyOracle::new(&["bare".to_string()]);
2276 assert!(
2277 live.is_denied("bare/f.md"),
2278 "the enforcing resolution blocks a bare name by its literal base"
2279 );
2280
2281 let resolved = ResolvedIngest {
2282 name: "ing".to_string(),
2283 mode: BuildMode::Discovery,
2284 trigger: IngestTrigger::Loop,
2285 batch_size: 50,
2286 deny_paths: vec!["bare".to_string(), "nothing-here/**".to_string()],
2287 projection_ref: "m/p".to_string(),
2288 projection_mem: "m".to_string(),
2289 projection_name: "p".to_string(),
2290 intent: None,
2291 sources: vec![ResolvedSource::Primary(primary(vec![PatternEntry {
2292 path: "**/*.md".to_string(),
2293 mode: PatternMode::Allow,
2294 }]))],
2295 destination_mem: "m".to_string(),
2296 rules: None,
2297 post_actions: None,
2298 };
2299 let dead = dead_deny_entries(&resolved, root);
2300 assert_eq!(
2301 dead,
2302 vec!["nothing-here/**".to_string()],
2303 "only the genuinely dead entry is reported; got {dead:?}"
2304 );
2305 }
2306
2307 #[test]
2313 fn a_legacy_dialect_pattern_makes_the_enumeration_partial() {
2314 let tmp = tempfile::tempdir().unwrap();
2315 let root = tmp.path();
2316 std::fs::create_dir_all(root.join("src")).unwrap();
2317 std::fs::write(root.join("src/a.rs"), "").unwrap();
2318 std::fs::write(root.join("src/b.md"), "").unwrap();
2319
2320 let mut source = primary(vec![
2321 PatternEntry {
2322 path: "**/*.rs".to_string(),
2323 mode: PatternMode::Allow,
2324 },
2325 PatternEntry {
2328 path: "src/b.md".to_string(),
2329 mode: PatternMode::Allow,
2330 },
2331 ]);
2332 source.pointer = "src".to_string();
2333
2334 let got = enumerate_facet_files_reported(&source, &[], root);
2335 assert_eq!(got.files, vec!["src/a.rs".to_string()]);
2336 assert!(got.malformed.is_empty(), "nothing here fails to compile");
2337 assert!(
2338 got.is_partial(),
2339 "a legacy-dialect pattern truncates the set just as a malformed one does"
2340 );
2341 let why = got.partiality_reason().expect("a reason is stated");
2342 assert!(
2343 why.contains("src/b.md") && why.contains("pointer"),
2344 "the reason must name the pattern and the cause: {why}"
2345 );
2346 }
2347
2348 #[test]
2352 fn a_malformed_allow_does_not_empty_the_enumeration() {
2353 let tmp = tempfile::tempdir().unwrap();
2354 let root = tmp.path();
2355 std::fs::write(root.join("a.rs"), "").unwrap();
2356
2357 let source = primary(vec![
2358 PatternEntry {
2359 path: "**/*.rs".to_string(),
2360 mode: PatternMode::Allow,
2361 },
2362 PatternEntry {
2363 path: "[unclosed".to_string(),
2364 mode: PatternMode::Allow,
2365 },
2366 ]);
2367 let got = enumerate_facet_files_reported(&source, &[], root);
2368 assert_eq!(got.files, vec!["a.rs".to_string()]);
2369 assert_eq!(got.malformed, vec!["[unclosed".to_string()]);
2370 assert!(got.is_partial(), "a skipped pattern makes the set partial");
2371 }
2372
2373 #[test]
2376 fn a_malformed_deny_does_not_disable_the_other_denies() {
2377 let tmp = tempfile::tempdir().unwrap();
2378 let root = tmp.path();
2379 std::fs::create_dir_all(root.join("skip")).unwrap();
2380 std::fs::write(root.join("keep.rs"), "").unwrap();
2381 std::fs::write(root.join("skip/gone.rs"), "").unwrap();
2382
2383 let source = primary(vec![
2384 PatternEntry {
2385 path: "**/*.rs".to_string(),
2386 mode: PatternMode::Allow,
2387 },
2388 PatternEntry {
2389 path: "skip/**".to_string(),
2390 mode: PatternMode::Deny,
2391 },
2392 PatternEntry {
2393 path: "[unclosed".to_string(),
2394 mode: PatternMode::Deny,
2395 },
2396 ]);
2397 let got = enumerate_facet_files_reported(&source, &[], root);
2398 assert_eq!(
2399 got.files,
2400 vec!["keep.rs".to_string()],
2401 "the good deny still applies"
2402 );
2403 assert_eq!(got.malformed, vec!["[unclosed".to_string()]);
2404 }
2405
2406 #[test]
2410 fn validation_refuses_a_malformed_scope_pattern_by_name() {
2411 use crate::binding::CapabilityError;
2412 let mut binding = crate::binding::scaffold_binding(crate::binding::ScaffoldParams {
2413 destination_mem: "home",
2414 source_name: "src",
2415 medium_type: MediumType::Codebase,
2416 pointer: "src",
2417 intent: None,
2418 additional_deny_paths: Vec::new(),
2419 })
2420 .binding;
2421 binding.sources[0].scope.push(PatternEntry {
2422 path: "[unclosed".to_string(),
2423 mode: PatternMode::Allow,
2424 });
2425 let errs = crate::binding::validate_binding(&binding).unwrap_err();
2426 assert!(
2427 errs.iter().any(|e| matches!(
2428 e,
2429 CapabilityError::MalformedScopePattern { pattern, .. } if pattern == "[unclosed"
2430 )),
2431 "expected MalformedScopePattern naming the pattern, got {errs:?}"
2432 );
2433 }
2434
2435 #[test]
2436 fn migration_notes_name_old_dialect_patterns_and_suggest_the_rewrite() {
2437 let mut source = primary(vec![
2438 PatternEntry {
2439 path: "../public/plugins/claude-code/**/*.md".to_string(),
2440 mode: PatternMode::Allow,
2441 },
2442 PatternEntry {
2443 path: "../public/plugins/claude-code/dist/**".to_string(),
2444 mode: PatternMode::Deny,
2445 },
2446 PatternEntry {
2447 path: "**/*.mjs".to_string(),
2448 mode: PatternMode::Allow,
2449 },
2450 ]);
2451 source.pointer = "../public/plugins/claude-code".to_string();
2452 let notes = scope_migration_notes(&source);
2453 assert_eq!(notes.len(), 2, "the prefix-free pattern is not reported");
2454 assert_eq!(notes[0].suggested.as_deref(), Some("**/*.md"));
2455 assert!(!notes[0].deny);
2456 assert_eq!(notes[1].suggested.as_deref(), Some("dist/**"));
2457 assert!(notes[1].deny);
2458 }
2459
2460 #[test]
2462 fn migration_notes_are_empty_without_a_pointer() {
2463 let source = primary(vec![PatternEntry {
2464 path: "dev/**/*.md".to_string(),
2465 mode: PatternMode::Allow,
2466 }]);
2467 assert!(scope_migration_notes(&source).is_empty());
2468 }
2469
2470 #[test]
2471 fn enumerate_honours_allow_and_deny() {
2472 let ws = tempfile::tempdir().unwrap();
2473 let root = ws.path();
2474 std::fs::create_dir_all(root.join("sub")).unwrap();
2475 std::fs::write(root.join("a.rs"), "").unwrap();
2476 std::fs::write(root.join("sub/b.rs"), "").unwrap();
2477 std::fs::write(root.join("c.md"), "").unwrap();
2478
2479 let source = primary(vec![
2482 PatternEntry {
2483 path: "**/*.rs".to_string(),
2484 mode: PatternMode::Allow,
2485 },
2486 PatternEntry {
2487 path: "sub/**".to_string(),
2488 mode: PatternMode::Deny,
2489 },
2490 ]);
2491 assert_eq!(enumerate_facet_files(&source, &[], root), vec!["a.rs"]);
2492
2493 let mut graph_source = source.clone();
2498 graph_source.medium_type = MediumType::Graph;
2499 assert!(enumerate_facet_files(&graph_source, &[], root).is_empty());
2500 }
2501
2502 #[test]
2508 fn graph_enumeration_selects_the_source_mems_entities() {
2509 use crate::workspace::{
2510 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2511 };
2512 use crate::workspace_store::WorkspaceStoreAdapter;
2513
2514 let tmp = tempfile::tempdir().unwrap();
2515 let root = tmp.path();
2516 let mem_dir = root.join("srcmem");
2517 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2518 std::fs::write(
2519 mem_dir.join(".memstead").join("config.json"),
2520 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2521 )
2522 .unwrap();
2523
2524 let entity = |slug: &str, ty: &str, title: &str| {
2525 std::fs::write(
2526 mem_dir.join(format!("{slug}.md")),
2527 format!("---\ntype: {ty}\n---\n\n# {title}\n\n## Decision\n\nBody.\n"),
2528 )
2529 .unwrap();
2530 };
2531 entity("alpha-choice", "decision", "Alpha choice");
2532 entity("beta-choice", "decision", "Beta choice");
2533 entity("gamma-note", "memo", "Gamma note");
2534
2535 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2536 std::fs::write(
2537 root.join(".memstead").join("workspace.toml"),
2538 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2539 )
2540 .unwrap();
2541 crate::FileWorkspaceStore::new()
2542 .save_state(
2543 root,
2544 &Workspace {
2545 mounts: vec![Mount {
2546 mem: "srcmem".to_string(),
2547 schema: Some("default@1.0.0".parse().unwrap()),
2548 storage: MountStorage::Folder {
2549 path: mem_dir.clone(),
2550 },
2551 capability: MountCapability::Write,
2552 lifecycle: MountLifecycle::Eager,
2553 cross_linkable: false,
2554 migration_target: None,
2555 }],
2556 settings: WorkspaceSettings::default(),
2557 },
2558 )
2559 .unwrap();
2560
2561 let engine = crate::Engine::from_workspace_root(root).unwrap();
2562
2563 let graph_source = |patterns: Vec<(&str, PatternMode)>| Source {
2564 name: "g".to_string(),
2565 medium_type: MediumType::Graph,
2566 pointer: "srcmem".to_string(),
2567 change_detection: None,
2568 scope: patterns
2569 .into_iter()
2570 .map(|(p, mode)| crate::pipeline::PatternEntry {
2571 path: p.to_string(),
2572 mode,
2573 })
2574 .collect(),
2575 engagement: None,
2576 preparation: None,
2577 };
2578
2579 let all = enumerate_graph_entities(&engine, &graph_source(vec![("*", PatternMode::Allow)]));
2581 assert_eq!(
2582 all,
2583 vec![
2584 "srcmem--alpha-choice".to_string(),
2585 "srcmem--beta-choice".to_string(),
2586 "srcmem--gamma-note".to_string(),
2587 ],
2588 "the whole-mem selector enumerates every real entity"
2589 );
2590
2591 let decisions = enumerate_graph_entities(
2593 &engine,
2594 &graph_source(vec![("type:decision", PatternMode::Allow)]),
2595 );
2596 assert_eq!(
2597 decisions,
2598 vec![
2599 "srcmem--alpha-choice".to_string(),
2600 "srcmem--beta-choice".to_string()
2601 ],
2602 "type selector excludes the memo"
2603 );
2604
2605 let globbed = enumerate_graph_entities(
2607 &engine,
2608 &graph_source(vec![
2609 ("id:srcmem--*-choice", PatternMode::Allow),
2610 ("id:srcmem--beta-*", PatternMode::Deny),
2611 ]),
2612 );
2613 assert_eq!(
2614 globbed,
2615 vec!["srcmem--alpha-choice".to_string()],
2616 "deny subtracts from allow in the entity namespace too"
2617 );
2618
2619 assert!(
2623 enumerate_graph_entities(&engine, &graph_source(vec![])).is_empty(),
2624 "an unscoped graph facet is never silently 'everything'"
2625 );
2626
2627 assert_eq!(
2629 enumerate_source_artifacts(
2630 &engine,
2631 &graph_source(vec![("*", PatternMode::Allow)]),
2632 &[],
2633 root
2634 ),
2635 all,
2636 "enumerate_source_artifacts routes a graph source to the graph arm"
2637 );
2638 }
2639
2640 #[test]
2646 fn a_stale_entity_anchor_over_a_changed_entity_is_drifted() {
2647 use crate::anchor::{
2648 Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
2649 };
2650 use crate::entity::EntityId;
2651 use crate::workspace::{
2652 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2653 };
2654 use crate::workspace_store::WorkspaceStoreAdapter;
2655
2656 let tmp = tempfile::tempdir().unwrap();
2657 let root = tmp.path();
2658 let mem_dir = root.join("mem");
2659 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2660 std::fs::write(
2661 mem_dir.join(".memstead").join("config.json"),
2662 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2663 )
2664 .unwrap();
2665 std::fs::write(
2666 mem_dir.join("pinned.md"),
2667 "---\ntype: decision\n---\n\n# Pinned\n\n## Decision\n\nOriginal body.\n",
2668 )
2669 .unwrap();
2670 std::fs::write(
2671 mem_dir.join("steady.md"),
2672 "---\ntype: decision\n---\n\n# Steady\n\n## Decision\n\nUnchanged body.\n",
2673 )
2674 .unwrap();
2675
2676 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2677 std::fs::write(
2678 root.join(".memstead").join("workspace.toml"),
2679 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2680 )
2681 .unwrap();
2682 crate::FileWorkspaceStore::new()
2683 .save_state(
2684 root,
2685 &Workspace {
2686 mounts: vec![Mount {
2687 mem: "mem".to_string(),
2688 schema: Some("default@1.0.0".parse().unwrap()),
2689 storage: MountStorage::Folder {
2690 path: mem_dir.clone(),
2691 },
2692 capability: MountCapability::Write,
2693 lifecycle: MountLifecycle::Eager,
2694 cross_linkable: false,
2695 migration_target: None,
2696 }],
2697 settings: WorkspaceSettings::default(),
2698 },
2699 )
2700 .unwrap();
2701
2702 let engine = crate::Engine::from_workspace_root(root).unwrap();
2704 let hash_of = |engine: &crate::Engine, id: &str| {
2705 let e = engine.store().get(&EntityId::canonical(id)).unwrap();
2706 crate::anchor::prepared_content_hash(
2707 crate::render::render_entity_markdown(e, None).as_bytes(),
2708 )
2709 };
2710 let pinned_hash = hash_of(&engine, "mem--pinned");
2711 let steady_hash = hash_of(&engine, "mem--steady");
2712
2713 let entity_anchor = |artifact: &str, hash: &str| Anchor {
2714 artifact: artifact.to_string(),
2715 grain: AnchorGrain::Entity,
2716 class: AnchorProvenanceClass::Anchored,
2717 hash: Some(hash.to_string()),
2718 source: None,
2719 binding: None,
2720 at_version: None,
2721 derived_from: Vec::new(),
2722 hash_stability: crate::anchor::AnchorHashStability::Stable,
2723 span_unvalidated: false,
2724 hash_source: None,
2725 };
2726
2727 let mut sidecar = AnchorSidecar::default();
2728 sidecar.set(
2729 "mem--holder",
2730 vec![
2731 entity_anchor("mem--pinned", &pinned_hash),
2732 entity_anchor("mem--steady", &steady_hash),
2733 entity_anchor("mem--vanished", "deadbeefdeadbeef"),
2735 ],
2736 );
2737 std::fs::write(
2738 mem_dir.join(".memstead").join("anchors.json"),
2739 sidecar.to_bytes(),
2740 )
2741 .unwrap();
2742 std::fs::write(
2743 mem_dir.join("holder.md"),
2744 "---\ntype: decision\n---\n\n# Holder\n\n## Decision\n\nHolds anchors.\n",
2745 )
2746 .unwrap();
2747
2748 std::fs::write(
2750 mem_dir.join("pinned.md"),
2751 "---\ntype: decision\n---\n\n# Pinned\n\n## Decision\n\nBody rewritten.\n",
2752 )
2753 .unwrap();
2754
2755 let engine = crate::Engine::from_workspace_root(root).unwrap();
2756 let resolved = engine.entity_anchors_resolved(&EntityId::canonical("mem--holder"));
2757 let state_of = |artifact: &str| {
2758 resolved
2759 .iter()
2760 .find(|r| r.anchor.artifact == artifact)
2761 .unwrap_or_else(|| panic!("no resolved anchor for {artifact}"))
2762 .state
2763 };
2764
2765 assert_eq!(
2766 state_of("mem--pinned"),
2767 Some(AnchorState::Drifted),
2768 "a stale-pinned anchor over a CHANGED entity must be drifted — \
2769 this is the pilot failure that went unflagged"
2770 );
2771 assert_eq!(
2772 state_of("mem--steady"),
2773 Some(AnchorState::Resolves),
2774 "an anchor over an unchanged entity still resolves"
2775 );
2776 assert_eq!(
2777 state_of("mem--vanished"),
2778 Some(AnchorState::Orphaned),
2779 "an anchor over an entity that is not there is orphaned, not unobserved"
2780 );
2781
2782 let mut sc2 = AnchorSidecar::default();
2785 sc2.set(
2786 "mem--holder",
2787 vec![Anchor {
2788 artifact: "https://example.invalid/doc".to_string(),
2789 grain: AnchorGrain::Url,
2790 class: AnchorProvenanceClass::InformedBy,
2791 hash: None,
2792 source: None,
2793 binding: None,
2794 at_version: None,
2795 derived_from: Vec::new(),
2796 hash_stability: crate::anchor::AnchorHashStability::Stable,
2797 span_unvalidated: false,
2798 hash_source: None,
2799 }],
2800 );
2801 std::fs::write(
2802 mem_dir.join(".memstead").join("anchors.json"),
2803 sc2.to_bytes(),
2804 )
2805 .unwrap();
2806 let engine = crate::Engine::from_workspace_root(root).unwrap();
2807 let url_state =
2808 engine.entity_anchors_resolved(&EntityId::canonical("mem--holder"))[0].state;
2809 assert_eq!(
2810 url_state, None,
2811 "url anchors stay unobserved — the fix widens observation, never the \
2812 scoring of non-observation"
2813 );
2814 }
2815
2816 #[test]
2826 fn entity_load_bearing_preparation_ignores_notes_edits_and_catches_claim_edits() {
2827 use crate::anchor::{
2828 Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
2829 };
2830 use crate::binding::{
2831 BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, VerifyOperation,
2832 };
2833 use crate::entity::EntityId;
2834 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode, Source};
2835 use crate::workspace::{
2836 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2837 };
2838 use crate::workspace_store::WorkspaceStoreAdapter;
2839
2840 let tmp = tempfile::tempdir().unwrap();
2841 let root = tmp.path();
2842 let mem_dir = root.join("home");
2843 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2844 std::fs::write(
2845 mem_dir.join(".memstead").join("config.json"),
2846 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2847 )
2848 .unwrap();
2849 let write_pinned = |claim: &str, conditions: &str| {
2852 std::fs::write(
2853 mem_dir.join("pinned.md"),
2854 format!(
2855 "---\ntype: assertion\n---\n\n# Pinned\n\n## Claim\n\n{claim}\n\n\
2856 ## Evidence\n\nMeasured.\n\n## Conditions\n\n{conditions}\n"
2857 ),
2858 )
2859 .unwrap();
2860 };
2861 write_pinned("The sky is blue.", "daylight");
2862 std::fs::write(
2863 mem_dir.join("holder.md"),
2864 "---\ntype: assertion\n---\n\n# Holder\n\n## Claim\n\nDepends on pinned.\n\n\
2865 ## Evidence\n\nSee pinned.\n",
2866 )
2867 .unwrap();
2868
2869 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2870 std::fs::write(
2871 root.join(".memstead").join("workspace.toml"),
2872 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2873 )
2874 .unwrap();
2875 crate::FileWorkspaceStore::new()
2876 .save_state(
2877 root,
2878 &Workspace {
2879 mounts: vec![Mount {
2880 mem: "home".to_string(),
2881 schema: Some("default@1.0.0".parse().unwrap()),
2882 storage: MountStorage::Folder {
2883 path: mem_dir.clone(),
2884 },
2885 capability: MountCapability::Write,
2886 lifecycle: MountLifecycle::Eager,
2887 cross_linkable: false,
2888 migration_target: None,
2889 }],
2890 settings: WorkspaceSettings::default(),
2891 },
2892 )
2893 .unwrap();
2894
2895 let binding_with = |preparation: Option<&str>| Binding {
2899 version: BINDING_VERSION,
2900 intent: None,
2901 sources: vec![Source {
2902 name: "claims".to_string(),
2903 medium_type: MediumType::Graph,
2904 pointer: "home".to_string(),
2905 change_detection: None,
2906 scope: vec![PatternEntry {
2907 path: "*".to_string(),
2908 mode: PatternMode::Allow,
2909 }],
2910 engagement: None,
2911 preparation: preparation.map(str::to_string),
2912 }],
2913 reference_mems: vec![],
2914 destination_mem: "home".to_string(),
2915 deny_paths: vec![],
2916 coverage_semantics: None,
2917 rules: None,
2918 prune: None,
2919 operations: Operations {
2920 build: Some(BuildOperation {
2921 mode: BuildMode::Discovery,
2922 trigger: IngestTrigger::Manual,
2923 batch_size: 5,
2924 post_actions: None,
2925 }),
2926 sync: None,
2927 verify: Some(VerifyOperation {
2928 trigger: IngestTrigger::Manual,
2929 batch_size: 5,
2930 adjudication_cap: 0,
2931 full_resync_every: 0,
2932 }),
2933 },
2934 };
2935 let prepared_binding = binding_with(Some(crate::preparation::ENTITY_LOAD_BEARING));
2936 assert!(crate::binding::validate_binding(&prepared_binding).is_ok());
2937 crate::pipeline_store::write_binding(root, "home", "claims", &prepared_binding).unwrap();
2938
2939 let engine = crate::Engine::from_workspace_root(root).unwrap();
2943 let pinned = engine
2944 .store()
2945 .get(&EntityId::canonical("home--pinned"))
2946 .unwrap();
2947 let type_def = engine
2948 .schema_for("home")
2949 .and_then(|s| s.get_type("assertion"))
2950 .expect("default@1.0.0 declares assertion");
2951 assert!(
2952 crate::preparation::load_bearing_sections(&type_def)
2953 .iter()
2954 .map(|s| s.key.as_str())
2955 .eq(["claim", "evidence"]),
2956 "the required sections are the load-bearing set"
2957 );
2958 let prepared_hash = crate::preparation::entity_prepared_hash(
2959 pinned,
2960 Some(&type_def),
2961 Some(crate::preparation::ENTITY_LOAD_BEARING),
2962 )
2963 .unwrap();
2964 let default_hash =
2965 crate::preparation::entity_prepared_hash(pinned, Some(&type_def), None).unwrap();
2966 assert_ne!(prepared_hash, default_hash);
2967
2968 let anchor = |source: Option<&str>, hash: &str| Anchor {
2969 artifact: "home--pinned".to_string(),
2970 grain: AnchorGrain::Entity,
2971 class: AnchorProvenanceClass::Anchored,
2972 hash: Some(hash.to_string()),
2973 source: source.map(str::to_string),
2974 binding: None,
2975 at_version: None,
2976 derived_from: Vec::new(),
2977 hash_stability: crate::anchor::AnchorHashStability::Stable,
2978 span_unvalidated: false,
2979 hash_source: None,
2980 };
2981 std::fs::write(
2983 mem_dir.join("holder2.md"),
2984 "---\ntype: assertion\n---\n\n# Holder2\n\n## Claim\n\nAlso depends.\n\n\
2985 ## Evidence\n\nSee pinned.\n",
2986 )
2987 .unwrap();
2988 let mut sidecar = AnchorSidecar::default();
2989 sidecar.set("home--holder", vec![anchor(Some("claims"), &prepared_hash)]);
2990 sidecar.set("home--holder2", vec![anchor(None, &default_hash)]);
2991 std::fs::write(
2992 mem_dir.join(".memstead").join("anchors.json"),
2993 sidecar.to_bytes(),
2994 )
2995 .unwrap();
2996
2997 let states = |root: &std::path::Path| {
2998 let engine = crate::Engine::from_workspace_root(root).unwrap();
2999 let state_of = |holder: &str| {
3000 engine.entity_anchors_resolved(&EntityId::canonical(holder))[0].state
3001 };
3002 let standalone = engine.verify_mem_anchors("home").unwrap();
3003 (
3004 state_of("home--holder"),
3005 state_of("home--holder2"),
3006 standalone,
3007 )
3008 };
3009
3010 let (prepared, plain, report) = states(root);
3012 assert_eq!(prepared, Some(AnchorState::Resolves));
3013 assert_eq!(plain, Some(AnchorState::Resolves));
3014 assert_eq!((report.resolved, report.drifted), (2, 0));
3015
3016 write_pinned("The sky is blue.", "daylight, clear weather");
3020 let (prepared, plain, report) = states(root);
3021 assert_eq!(
3022 prepared,
3023 Some(AnchorState::Resolves),
3024 "a comma in the notes must not break a load-bearing anchor"
3025 );
3026 assert_eq!(plain, Some(AnchorState::Drifted));
3027 assert_eq!((report.resolved, report.drifted), (1, 1));
3028
3029 write_pinned("The sky is green.", "daylight, clear weather");
3031 let (prepared, plain, report) = states(root);
3032 assert_eq!(prepared, Some(AnchorState::Drifted));
3033 assert_eq!(plain, Some(AnchorState::Drifted));
3034 assert_eq!((report.resolved, report.drifted), (0, 2));
3035
3036 crate::pipeline_store::write_binding(
3040 root,
3041 "home",
3042 "claims",
3043 &binding_with(Some("pdf-to-markdown")),
3044 )
3045 .unwrap();
3046 let (prepared, plain, report) = states(root);
3047 assert_eq!(
3048 prepared, None,
3049 "an unknown preparation yields no observation"
3050 );
3051 assert_eq!(plain, Some(AnchorState::Drifted));
3052 assert_eq!(
3058 (report.unresolvable, report.unobserved, report.drifted),
3059 (0, 1, 1)
3060 );
3061 }
3062
3063 #[test]
3066 fn shuffled_discovery_sequences_identically() {
3067 use crate::preparation::UnitChange;
3068 let unit = |id: &str, order: &str| DeliveredUnit {
3069 id: id.to_string(),
3070 order_key: order.to_string(),
3071 change: UnitChange::Added,
3072 disposed: false,
3073 };
3074 let ordered = vec![
3075 unit("corpus/notes.md#whole", ""),
3076 unit("corpus/b.md#2026-08-20T00:00:00", "2026-08-20T00:00:00"),
3077 unit("corpus/a.md#2026-08-21T00:00:00", "2026-08-21T00:00:00"),
3078 unit("corpus/a.md#2026-08-21T00:00:00.2", "2026-08-21T00:00:00"),
3079 unit("corpus/c.md#2026-08-21T00:00:00", "2026-08-21T00:00:00"),
3080 unit("corpus/b.md#2026-08-22T00:00:00", "2026-08-22T00:00:00"),
3081 ];
3082 for shuffle in [
3083 vec![5, 3, 0, 4, 1, 2],
3084 vec![2, 1, 0, 5, 4, 3],
3085 vec![4, 0, 5, 2, 3, 1],
3086 ] {
3087 let mut units: Vec<DeliveredUnit> =
3088 shuffle.iter().map(|i| ordered[*i].clone()).collect();
3089 sequence_units(&mut units);
3090 assert_eq!(units, ordered, "discovery order {shuffle:?} must not leak");
3091 }
3092
3093 let day = "2026-08-24T00:00:00";
3096 let expected: Vec<String> = (1..=12)
3097 .map(|n| {
3098 if n == 1 {
3099 format!("journal.md#{day}")
3100 } else {
3101 format!("journal.md#{day}.{n}")
3102 }
3103 })
3104 .collect();
3105 let mut units: Vec<DeliveredUnit> = expected.iter().rev().map(|id| unit(id, day)).collect();
3106 sequence_units(&mut units);
3107 assert_eq!(
3108 units.iter().map(|u| u.id.as_str()).collect::<Vec<_>>(),
3109 expected.iter().map(String::as_str).collect::<Vec<_>>()
3110 );
3111 }
3112
3113 #[test]
3123 fn dated_entries_deliver_in_a_total_order_across_first_and_change_runs() {
3124 use crate::anchor::{
3125 Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
3126 };
3127 use crate::binding::{
3128 BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, VerifyOperation,
3129 };
3130 use crate::entity::EntityId;
3131 use crate::ingest::advance::{DispositionInput, advance_baseline};
3132 use crate::ingest::brief::render_changed_slice;
3133 use crate::ingest::resolve::resolve_binding_run;
3134 use crate::pipeline::{IngestTrigger, PatternMode};
3135 use crate::preparation::{DATED_ENTRIES, UnitChange, unitize};
3136 use crate::workspace::{
3137 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
3138 };
3139 use crate::workspace_store::WorkspaceStoreAdapter;
3140
3141 let tmp = tempfile::tempdir().unwrap();
3142 let root = tmp.path();
3143
3144 let mem_dir = root.join("home");
3146 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3147 std::fs::write(
3148 mem_dir.join(".memstead").join("config.json"),
3149 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3150 )
3151 .unwrap();
3152 std::fs::write(
3153 mem_dir.join("holder.md"),
3154 "---\ntype: assertion\n---\n\n# Holder\n\n## Claim\n\nHolds anchors.\n\n\
3155 ## Evidence\n\nSee corpus.\n",
3156 )
3157 .unwrap();
3158 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3159 std::fs::write(
3160 root.join(".memstead").join("workspace.toml"),
3161 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3162 )
3163 .unwrap();
3164 crate::FileWorkspaceStore::new()
3165 .save_state(
3166 root,
3167 &Workspace {
3168 mounts: vec![Mount {
3169 mem: "home".to_string(),
3170 schema: Some("default@1.0.0".parse().unwrap()),
3171 storage: MountStorage::Folder {
3172 path: mem_dir.clone(),
3173 },
3174 capability: MountCapability::Write,
3175 lifecycle: MountLifecycle::Eager,
3176 cross_linkable: false,
3177 migration_target: None,
3178 }],
3179 settings: WorkspaceSettings::default(),
3180 },
3181 )
3182 .unwrap();
3183
3184 let corpus = root.join("corpus");
3187 std::fs::create_dir_all(corpus.join("plain")).unwrap();
3188 git(&corpus, &["init", "-q"]);
3189 let write = |name: &str, text: &str| std::fs::write(corpus.join(name), text).unwrap();
3190 write(
3191 "a.md",
3192 "2026-08-21 alpha one\nbody a1\n2026-08-23 alpha two\nbody a2\n",
3193 );
3194 write(
3195 "b.md",
3196 "2026-08-20 beta one\nbody b1\n2026-08-22 beta two\nbody b2\n",
3197 );
3198 write("notes.md", "undated notes\n");
3199 write("plain/readme.txt", "plain source, file granularity\n");
3200 git(&corpus, &["add", "."]);
3201 git(&corpus, &["commit", "-q", "-m", "corpus"]);
3202
3203 let source = |name: &str, scope: &str, preparation: Option<&str>| Source {
3204 name: name.to_string(),
3205 medium_type: MediumType::Filesystem,
3206 pointer: "corpus".to_string(),
3207 change_detection: Some("git".to_string()),
3208 scope: vec![PatternEntry {
3209 path: scope.to_string(),
3210 mode: PatternMode::Allow,
3211 }],
3212 engagement: None,
3213 preparation: preparation.map(str::to_string),
3214 };
3215 let binding = Binding {
3216 version: BINDING_VERSION,
3217 intent: None,
3218 sources: vec![
3219 source("logs", "*.md", Some(DATED_ENTRIES)),
3221 source("plain", "plain/**", None),
3222 ],
3223 reference_mems: vec![],
3224 destination_mem: "home".to_string(),
3225 deny_paths: vec![],
3226 coverage_semantics: None,
3227 rules: None,
3228 prune: None,
3229 operations: Operations {
3230 build: Some(BuildOperation {
3231 mode: BuildMode::Discovery,
3232 trigger: IngestTrigger::Manual,
3233 batch_size: 3,
3234 post_actions: None,
3235 }),
3236 sync: None,
3237 verify: Some(VerifyOperation {
3238 trigger: IngestTrigger::Manual,
3239 batch_size: 5,
3240 adjudication_cap: 0,
3241 full_resync_every: 0,
3242 }),
3243 },
3244 };
3245 assert!(
3246 crate::binding::validate_binding(&binding).is_ok(),
3247 "{:?}",
3248 crate::binding::validate_binding(&binding)
3249 );
3250 crate::pipeline_store::write_binding(root, "home", "corpus", &binding).unwrap();
3251 let resolved = resolve_binding_run("home/corpus", &binding).unwrap();
3252
3253 let mut engine = crate::Engine::from_workspace_root(root).unwrap();
3255 let cursor = compute_source_cursor(&engine, &resolved, root);
3256 let expected: Vec<&str> = vec![
3257 "corpus/notes.md#whole",
3258 "corpus/b.md#2026-08-20T00:00:00",
3259 "corpus/a.md#2026-08-21T00:00:00",
3260 "corpus/b.md#2026-08-22T00:00:00",
3261 "corpus/a.md#2026-08-23T00:00:00",
3262 ];
3263 assert_eq!(
3264 cursor.delivery.len(),
3265 1,
3266 "one sequence, for the prepared source only"
3267 );
3268 let seq = &cursor.delivery[0];
3269 assert_eq!(
3270 (seq.source.as_str(), seq.preparation.as_str()),
3271 ("logs", DATED_ENTRIES)
3272 );
3273 assert!(seq.first_run && !seq.degraded && seq.batch == 3);
3274 assert_eq!(
3275 seq.units.iter().map(|u| u.id.as_str()).collect::<Vec<_>>(),
3276 expected
3277 );
3278 assert!(
3279 seq.units
3280 .iter()
3281 .all(|u| u.change == UnitChange::Added && !u.disposed)
3282 );
3283 let mut expected_sorted: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
3284 expected_sorted.sort();
3285 assert_eq!(
3286 cursor.union.added, expected_sorted,
3287 "the advance gate accepts the unit ids"
3288 );
3289 assert!(
3292 cursor
3293 .reseed
3294 .iter()
3295 .any(|c| c.key == "home/corpus/plain#synced")
3296 );
3297 assert!(
3298 cursor
3299 .write_commands
3300 .iter()
3301 .any(|c| c.key == "home/corpus/logs#synced")
3302 );
3303 assert!(
3304 !cursor
3305 .union
3306 .added
3307 .iter()
3308 .any(|a| a.starts_with("corpus/plain"))
3309 );
3310 assert_eq!(compute_source_cursor(&engine, &resolved, root), cursor);
3312
3313 let brief = render_changed_slice(&cursor);
3314 assert!(
3315 brief.contains("### Delivery sequence: `logs` (`dated-entries`)"),
3316 "{brief}"
3317 );
3318 assert!(brief.contains("First delivery of this source"));
3319 let listed: Vec<&str> = brief
3320 .lines()
3321 .filter(|l| l.starts_with(|c: char| c.is_ascii_digit()) && l.contains("`corpus/"))
3322 .collect();
3323 assert_eq!(
3324 listed,
3325 vec![
3326 "1. `corpus/notes.md#whole` (new)",
3327 "2. `corpus/b.md#2026-08-20T00:00:00` (new)",
3328 "3. `corpus/a.md#2026-08-21T00:00:00` (new)",
3329 ],
3330 "the batch presents the first three in order"
3331 );
3332 assert!(brief.contains("…and 2 more, presented in order once these are disposed"));
3333 assert!(
3334 !brief.contains("**Added:**"),
3335 "unit ids never repeat in a class list: {brief}"
3336 );
3337
3338 let dispositions: BTreeMap<String, DispositionInput> =
3340 [(expected[0], "skipped"), (expected[1], "worked")]
3341 .into_iter()
3342 .map(|(a, d)| (a.to_string(), DispositionInput::Verdict(d.to_string())))
3343 .collect();
3344 let outcome = advance_baseline(&mut engine, root, &resolved, &dispositions).unwrap();
3345 assert_eq!((outcome.pending, outcome.completed), (3, false));
3346 let cursor = compute_source_cursor(&engine, &resolved, root);
3347 assert!(cursor.delivery[0].units[0].disposed && cursor.delivery[0].units[1].disposed);
3348 let brief = render_changed_slice(&cursor);
3349 assert!(
3350 brief.contains("3. `corpus/a.md#2026-08-21T00:00:00` (new)"),
3351 "{brief}"
3352 );
3353 assert!(
3354 !brief.contains("1. `corpus/notes.md#whole`"),
3355 "disposed units are not re-presented"
3356 );
3357 assert!(brief.contains("2 units of this sequence already disposed"));
3358
3359 let a21_text = std::fs::read_to_string(corpus.join("a.md")).unwrap();
3362 let a21_unit = unitize(DATED_ENTRIES, &a21_text)
3363 .unwrap()
3364 .into_iter()
3365 .find(|u| u.key == "2026-08-21T00:00:00")
3366 .unwrap();
3367 let anchor = |artifact: &str, grain: AnchorGrain, hash: &str| Anchor {
3368 artifact: artifact.to_string(),
3369 grain,
3370 class: AnchorProvenanceClass::Anchored,
3371 hash: Some(hash.to_string()),
3372 source: Some("logs".to_string()),
3373 binding: None,
3374 at_version: None,
3375 derived_from: Vec::new(),
3376 hash_stability: crate::anchor::AnchorHashStability::Stable,
3377 span_unvalidated: false,
3378 hash_source: None,
3379 };
3380 let b_file_hash =
3381 crate::anchor::prepared_content_hash(&std::fs::read(corpus.join("b.md")).unwrap());
3382 let mut sidecar = AnchorSidecar::default();
3383 sidecar.set(
3384 "home--holder",
3385 vec![
3386 anchor(expected[2], AnchorGrain::Span, &a21_unit.hash),
3387 anchor("corpus/b.md", AnchorGrain::File, &b_file_hash),
3388 ],
3389 );
3390 std::fs::write(
3391 mem_dir.join(".memstead").join("anchors.json"),
3392 sidecar.to_bytes(),
3393 )
3394 .unwrap();
3395 let mut engine = crate::Engine::from_workspace_root(root).unwrap();
3396 let outcome = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
3397 assert_eq!(
3398 outcome.pending, 2,
3399 "the unit anchor auto-disposed its unit, the file anchor nothing"
3400 );
3401 assert!(outcome.remainder.added.contains(&expected[3].to_string()));
3402 assert!(outcome.remainder.added.contains(&expected[4].to_string()));
3403 let rest: BTreeMap<String, DispositionInput> = [expected[3], expected[4]]
3404 .into_iter()
3405 .map(|a| {
3406 (
3407 a.to_string(),
3408 DispositionInput::Verdict("worked".to_string()),
3409 )
3410 })
3411 .collect();
3412 let outcome = advance_baseline(&mut engine, root, &resolved, &rest).unwrap();
3413 assert!(outcome.completed, "{outcome:?}");
3414 assert!(
3415 outcome
3416 .tokens_written
3417 .contains(&"home/corpus/logs#synced".to_string())
3418 );
3419
3420 write(
3424 "a.md",
3425 "2026-08-21 alpha one\nbody a1\n2026-08-23 alpha two\nbody a2\n2026-08-19 alpha zero\nbody a0\n",
3426 );
3427 write("b.md", "2026-08-22 beta two\nbody b2, revised\n");
3428 git(&corpus, &["add", "."]);
3429 git(&corpus, &["commit", "-q", "-m", "grow, edit, remove"]);
3430 let engine = crate::Engine::from_workspace_root(root).unwrap();
3431 let cursor = compute_source_cursor(&engine, &resolved, root);
3432 let seq = &cursor.delivery[0];
3433 assert!(!seq.first_run && !seq.degraded);
3434 assert_eq!(
3435 seq.units
3436 .iter()
3437 .map(|u| (u.id.as_str(), u.change))
3438 .collect::<Vec<_>>(),
3439 vec![
3440 ("corpus/a.md#2026-08-19T00:00:00", UnitChange::Added),
3441 ("corpus/b.md#2026-08-20T00:00:00", UnitChange::Deleted),
3442 ("corpus/b.md#2026-08-22T00:00:00", UnitChange::Modified),
3443 ]
3444 );
3445 let brief = render_changed_slice(&cursor);
3446 assert!(brief.contains("The units that changed since the last pass"));
3447 assert!(
3448 brief.contains("1. `corpus/a.md#2026-08-19T00:00:00` (new)"),
3449 "{brief}"
3450 );
3451 assert!(brief.contains("3. `corpus/b.md#2026-08-22T00:00:00` (changed)"));
3452
3453 let b22_old_text = "2026-08-22 beta two\nbody b2\n";
3457 let b22_old = unitize(DATED_ENTRIES, b22_old_text).unwrap()[0]
3458 .hash
3459 .clone();
3460 let mut sidecar = AnchorSidecar::default();
3461 sidecar.set(
3462 "home--holder",
3463 vec![
3464 anchor(expected[2], AnchorGrain::Span, &a21_unit.hash),
3465 anchor(expected[1], AnchorGrain::Span, "deadbeefdeadbeef"),
3466 anchor(expected[3], AnchorGrain::Span, &b22_old),
3467 ],
3468 );
3469 std::fs::write(
3470 mem_dir.join(".memstead").join("anchors.json"),
3471 sidecar.to_bytes(),
3472 )
3473 .unwrap();
3474 let engine = crate::Engine::from_workspace_root(root).unwrap();
3475 let resolved_anchors = engine.entity_anchors_resolved(&EntityId::canonical("home--holder"));
3476 let state_of = |artifact: &str| {
3477 resolved_anchors
3478 .iter()
3479 .find(|r| r.anchor.artifact == artifact)
3480 .unwrap()
3481 .state
3482 };
3483 assert_eq!(
3484 state_of(expected[2]),
3485 Some(AnchorState::Resolves),
3486 "unit unchanged, file changed"
3487 );
3488 assert_eq!(
3489 state_of(expected[1]),
3490 Some(AnchorState::Orphaned),
3491 "unit removed"
3492 );
3493 assert_eq!(
3494 state_of(expected[3]),
3495 Some(AnchorState::Drifted),
3496 "unit edited"
3497 );
3498 }
3499
3500 #[test]
3509 fn code_map_anchors_drift_on_interface_changes_only() {
3510 use crate::anchor::{
3511 Anchor, AnchorGrain, AnchorInput, AnchorProvenanceClass, AnchorSidecar, AnchorState,
3512 };
3513 use crate::binding::{
3514 BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, VerifyOperation,
3515 };
3516 use crate::entity::EntityId;
3517 use crate::ingest::resolve::Source;
3518 use crate::pipeline::{IngestTrigger, PatternMode};
3519 use crate::preparation::{CODE_MAP, code_map_digest, code_map_tree_digest};
3520 use crate::workspace::{
3521 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
3522 };
3523 use crate::workspace_store::WorkspaceStoreAdapter;
3524
3525 let tmp = tempfile::tempdir().unwrap();
3526 let root = tmp.path();
3527 let mem_dir = root.join("home");
3528 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3529 std::fs::write(
3530 mem_dir.join(".memstead").join("config.json"),
3531 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3532 )
3533 .unwrap();
3534 std::fs::write(
3535 mem_dir.join("holder.md"),
3536 "---\ntype: assertion\n---\n\n# Holder\n\n## Claim\n\nHolds anchors.\n\n\
3537 ## Evidence\n\nSee code.\n",
3538 )
3539 .unwrap();
3540 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3541 std::fs::write(
3542 root.join(".memstead").join("workspace.toml"),
3543 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3544 )
3545 .unwrap();
3546 crate::FileWorkspaceStore::new()
3547 .save_state(
3548 root,
3549 &Workspace {
3550 mounts: vec![Mount {
3551 mem: "home".to_string(),
3552 schema: Some("default@1.0.0".parse().unwrap()),
3553 storage: MountStorage::Folder {
3554 path: mem_dir.clone(),
3555 },
3556 capability: MountCapability::Write,
3557 lifecycle: MountLifecycle::Eager,
3558 cross_linkable: false,
3559 migration_target: None,
3560 }],
3561 settings: WorkspaceSettings::default(),
3562 },
3563 )
3564 .unwrap();
3565
3566 let corpus = root.join("corpus");
3568 std::fs::create_dir_all(corpus.join("src")).unwrap();
3569 std::fs::create_dir_all(corpus.join("plain")).unwrap();
3570 const A: &str = "// auth\nimport axios from 'axios'\n\nexport async function login(user, password) {\n // call\n return axios.post('/login', { user, password })\n}\n";
3571 const B: &str = "// the limit\nexport const LIMIT = 10\n";
3572 let write = |rel: &str, text: &str| std::fs::write(corpus.join(rel), text).unwrap();
3573 write("src/a.js", A);
3574 write("src/b.js", B);
3575 write("plain/notes.js", "export const N = 1\n");
3576
3577 let source = |name: &str, scope: &str, preparation: Option<&str>| Source {
3578 name: name.to_string(),
3579 medium_type: MediumType::Codebase,
3580 pointer: "corpus".to_string(),
3581 change_detection: Some("none".to_string()),
3582 scope: vec![PatternEntry {
3583 path: scope.to_string(),
3584 mode: PatternMode::Allow,
3585 }],
3586 engagement: None,
3587 preparation: preparation.map(str::to_string),
3588 };
3589 let binding = Binding {
3590 version: BINDING_VERSION,
3591 intent: None,
3592 sources: vec![
3593 source("code", "src/**/*.js", Some(CODE_MAP)),
3597 source("plain", "plain/**", None),
3598 ],
3599 reference_mems: vec![],
3600 destination_mem: "home".to_string(),
3601 deny_paths: vec![],
3602 coverage_semantics: None,
3603 rules: None,
3604 prune: None,
3605 operations: Operations {
3606 build: Some(BuildOperation {
3607 mode: BuildMode::Discovery,
3608 trigger: IngestTrigger::Manual,
3609 batch_size: 5,
3610 post_actions: None,
3611 }),
3612 sync: None,
3613 verify: Some(VerifyOperation {
3614 trigger: IngestTrigger::Manual,
3615 batch_size: 5,
3616 adjudication_cap: 0,
3617 full_resync_every: 0,
3618 }),
3619 },
3620 };
3621 assert!(crate::binding::validate_binding(&binding).is_ok());
3622 crate::pipeline_store::write_binding(root, "home", "code", &binding).unwrap();
3623
3624 let digest_hash = |rel: &str, text: &str| {
3625 crate::anchor::prepared_content_hash(code_map_digest(rel, text).as_bytes())
3626 };
3627 let tree_hash = |files: &[(&str, &str)]| {
3628 let owned: Vec<(String, String)> = files
3629 .iter()
3630 .map(|(p, t)| (p.to_string(), t.to_string()))
3631 .collect();
3632 crate::anchor::prepared_content_hash(code_map_tree_digest(&owned).as_bytes())
3633 };
3634 let anchor = |artifact: &str, grain: AnchorGrain, source: &str, hash: &str| Anchor {
3635 artifact: artifact.to_string(),
3636 grain,
3637 class: AnchorProvenanceClass::Anchored,
3638 hash: Some(hash.to_string()),
3639 source: Some(source.to_string()),
3640 binding: None,
3641 at_version: None,
3642 derived_from: Vec::new(),
3643 hash_stability: crate::anchor::AnchorHashStability::Stable,
3644 span_unvalidated: false,
3645 hash_source: None,
3646 };
3647 let plain_raw = crate::anchor::prepared_content_hash(b"export const N = 1\n");
3648 let mut sidecar = AnchorSidecar::default();
3649 sidecar.set(
3650 "home--holder",
3651 vec![
3652 anchor(
3653 "corpus/src/a.js",
3654 AnchorGrain::File,
3655 "code",
3656 &digest_hash("corpus/src/a.js", A),
3657 ),
3658 anchor(
3659 "corpus/src/a.js#L4-L7",
3660 AnchorGrain::Span,
3661 "code",
3662 &digest_hash("corpus/src/a.js", A),
3663 ),
3664 anchor(
3665 "corpus/src",
3666 AnchorGrain::Tree,
3667 "code",
3668 &tree_hash(&[("corpus/src/a.js", A), ("corpus/src/b.js", B)]),
3669 ),
3670 anchor(
3671 "corpus/plain/notes.js",
3672 AnchorGrain::File,
3673 "plain",
3674 &plain_raw,
3675 ),
3676 anchor(
3677 "corpus/plain",
3678 AnchorGrain::Tree,
3679 "plain",
3680 "0000000000000000",
3681 ),
3682 ],
3683 );
3684 std::fs::write(
3685 mem_dir.join(".memstead").join("anchors.json"),
3686 sidecar.to_bytes(),
3687 )
3688 .unwrap();
3689
3690 let states = |root: &std::path::Path| {
3691 let engine = crate::Engine::from_workspace_root(root).unwrap();
3692 let resolved = engine.entity_anchors_resolved(&EntityId::canonical("home--holder"));
3693 let of = |artifact: &str| {
3694 let r = resolved
3695 .iter()
3696 .find(|r| r.anchor.artifact == artifact)
3697 .unwrap_or_else(|| panic!("no anchor {artifact}"));
3698 (r.state, r.observed_hash.clone())
3699 };
3700 (
3701 of("corpus/src/a.js").0,
3702 of("corpus/src/a.js#L4-L7").0,
3703 of("corpus/src").0,
3704 of("corpus/plain/notes.js").0,
3705 of("corpus/plain"),
3706 engine.verify_mem_anchors("home").unwrap(),
3707 )
3708 };
3709
3710 let (file, span, tree, plain_file, plain_tree, report) = states(root);
3714 assert_eq!(
3715 (file, span, tree, plain_file),
3716 (
3717 Some(AnchorState::Resolves),
3718 Some(AnchorState::Resolves),
3719 Some(AnchorState::Resolves),
3720 Some(AnchorState::Resolves)
3721 )
3722 );
3723 assert_eq!(plain_tree, (Some(AnchorState::Recheck), None));
3724 assert_eq!((report.resolved, report.drifted, report.recheck), (4, 0, 1));
3725
3726 write(
3728 "src/a.js",
3729 "// auth (rewritten comment)\nimport axios from 'axios'\n\nexport async function login(user, password) {\n return await axios.post('/session', { user, password })\n}\n",
3730 );
3731 let (file, span, tree, _, _, report) = states(root);
3732 assert_eq!(
3733 (file, span, tree),
3734 (
3735 Some(AnchorState::Resolves),
3736 Some(AnchorState::Resolves),
3737 Some(AnchorState::Resolves)
3738 ),
3739 "a body edit must not drift a code-map anchor"
3740 );
3741 assert_eq!(report.drifted, 0);
3742
3743 write(
3745 "src/a.js",
3746 "// auth\nimport axios from 'axios'\n\nexport async function login(user, password, remember) {\n return axios.post('/login', { user, password, remember })\n}\n",
3747 );
3748 let (file, span, tree, plain_file, _, report) = states(root);
3749 assert_eq!(
3750 (file, span, tree),
3751 (
3752 Some(AnchorState::Drifted),
3753 Some(AnchorState::Drifted),
3754 Some(AnchorState::Drifted)
3755 )
3756 );
3757 assert_eq!(plain_file, Some(AnchorState::Resolves));
3758 assert_eq!(report.drifted, 3);
3759
3760 write("src/a.js", A);
3762 write("src/c.js", "export const C = 1\n");
3763 let (file, span, tree, _, _, _) = states(root);
3764 assert_eq!(
3765 (file, span),
3766 (Some(AnchorState::Resolves), Some(AnchorState::Resolves))
3767 );
3768 assert_eq!(
3769 tree,
3770 Some(AnchorState::Drifted),
3771 "a file joining the tree changes its code map"
3772 );
3773
3774 write("plain/notes.js", "export const N = 1 // note\n");
3777 let (_, _, _, plain_file, _, _) = states(root);
3778 assert_eq!(plain_file, Some(AnchorState::Drifted));
3779
3780 let engine = crate::Engine::from_workspace_root(root).unwrap();
3782 let input = AnchorInput {
3783 artifact: Some("corpus/src/b.js".to_string()),
3784 grain: Some("file".to_string()),
3785 class: Some("anchored".to_string()),
3786 source: Some("code".to_string()),
3787 content: Some(B.to_string()),
3788 ..Default::default()
3789 };
3790 let validated = engine.validate_anchor_inputs("home", &[input]).unwrap();
3791 assert_eq!(
3792 validated[0].hash.as_deref(),
3793 Some(digest_hash("corpus/src/b.js", B).as_str())
3794 );
3795 assert_ne!(
3796 validated[0].hash.as_deref(),
3797 Some(crate::anchor::prepared_content_hash(B.as_bytes()).as_str())
3798 );
3799 let plain_input = AnchorInput {
3800 artifact: Some("corpus/plain/notes.js".to_string()),
3801 grain: Some("file".to_string()),
3802 class: Some("anchored".to_string()),
3803 source: Some("plain".to_string()),
3804 content: Some("export const N = 1\n".to_string()),
3805 ..Default::default()
3806 };
3807 let validated = engine
3808 .validate_anchor_inputs("home", &[plain_input])
3809 .unwrap();
3810 assert_eq!(
3811 validated[0].hash.as_deref(),
3812 Some(plain_raw.as_str()),
3813 "no preparation: the raw canonicalization, as before"
3814 );
3815 }
3816
3817 #[test]
3826 fn graph_scoping_changes_only_the_unscoped_arm() {
3827 use crate::binding::BuildMode;
3828
3829 let tmp = tempfile::tempdir().unwrap();
3830 let root = tmp.path();
3831 let mem_dir = root.join("srcmem");
3832 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3833 std::fs::write(
3834 mem_dir.join(".memstead").join("config.json"),
3835 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3836 )
3837 .unwrap();
3838 std::fs::write(
3839 mem_dir.join("one.md"),
3840 "---\ntype: decision\n---\n\n# One\n\n## Decision\n\nBody.\n",
3841 )
3842 .unwrap();
3843 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3844 std::fs::write(
3845 root.join(".memstead").join("workspace.toml"),
3846 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3847 )
3848 .unwrap();
3849 {
3850 use crate::workspace::{
3851 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
3852 };
3853 use crate::workspace_store::WorkspaceStoreAdapter;
3854 crate::FileWorkspaceStore::new()
3855 .save_state(
3856 root,
3857 &Workspace {
3858 mounts: vec![Mount {
3859 mem: "srcmem".to_string(),
3860 schema: Some("default@1.0.0".parse().unwrap()),
3861 storage: MountStorage::Folder {
3862 path: mem_dir.clone(),
3863 },
3864 capability: MountCapability::Write,
3865 lifecycle: MountLifecycle::Eager,
3866 cross_linkable: false,
3867 migration_target: None,
3868 }],
3869 settings: WorkspaceSettings::default(),
3870 },
3871 )
3872 .unwrap();
3873 }
3874 let engine = crate::Engine::from_workspace_root(root).unwrap();
3875
3876 let resolved_with = |scope: Vec<crate::pipeline::PatternEntry>| ResolvedIngest {
3877 name: "srcmem/p".to_string(),
3878 mode: BuildMode::Discovery,
3879 trigger: crate::pipeline::IngestTrigger::Manual,
3880 batch_size: 20,
3881 deny_paths: Vec::new(),
3882 projection_ref: "srcmem/p".to_string(),
3883 projection_mem: "srcmem".to_string(),
3884 projection_name: "p".to_string(),
3885 intent: None,
3886 sources: vec![ResolvedSource::Primary(Source {
3887 name: "g".to_string(),
3888 medium_type: MediumType::Graph,
3889 pointer: "srcmem".to_string(),
3890 change_detection: None,
3891 scope,
3892 engagement: None,
3893 preparation: None,
3894 })],
3895 destination_mem: "srcmem".to_string(),
3896 rules: None,
3897 post_actions: None,
3898 };
3899
3900 let scoped = compute_source_cursor(
3901 &engine,
3902 &resolved_with(vec![crate::pipeline::PatternEntry {
3903 path: "*".to_string(),
3904 mode: PatternMode::Allow,
3905 }]),
3906 root,
3907 );
3908 let unscoped = compute_source_cursor(&engine, &resolved_with(Vec::new()), root);
3909
3910 let reason_of = |c: &SourceCursor| c.no_signal.first().map(|n| n.reason);
3911 assert_eq!(
3912 reason_of(&scoped),
3913 Some(NoSignalReason::GraphSnapshotMissing),
3914 "a scoped graph facet still routes to the graph strategy and reports \
3915 its own no-signal reason — the change-detection half is untouched"
3916 );
3917 assert_eq!(
3918 reason_of(&unscoped),
3919 Some(NoSignalReason::Unscoped),
3920 "an unscoped graph facet refuses like every other medium's, instead of \
3921 silently proceeding"
3922 );
3923 }
3924
3925 #[test]
3932 fn git_medium_enumerates_through_the_path_walk() {
3933 let ws = tempfile::tempdir().unwrap();
3934 let root = ws.path();
3935 std::fs::write(root.join("a.rs"), "").unwrap();
3936 std::fs::write(root.join("b.rs"), "").unwrap();
3937
3938 let source = |medium: MediumType| Source {
3939 name: "s".to_string(),
3940 medium_type: medium,
3941 pointer: ".".to_string(),
3942 change_detection: None,
3943 scope: vec![crate::pipeline::PatternEntry {
3944 path: "**/*.rs".to_string(),
3945 mode: PatternMode::Allow,
3946 }],
3947 engagement: None,
3948 preparation: None,
3949 };
3950
3951 let want = vec!["a.rs".to_string(), "b.rs".to_string()];
3952 for medium in [
3953 MediumType::Codebase,
3954 MediumType::Filesystem,
3955 MediumType::Git,
3956 ] {
3957 assert_eq!(
3958 enumerate_facet_files(&source(medium), &[], root),
3959 want,
3960 "{medium:?} walks the file tree — every medium the matrix marks \
3961 enumerable with a path namespace must actually enumerate"
3962 );
3963 assert!(
3964 crate::binding::medium_capabilities(medium).enumerable,
3965 "{medium:?} claims enumerability, and now delivers it"
3966 );
3967 }
3968 }
3969
3970 #[test]
3978 fn a_narrowing_selector_bounds_the_changed_slice_too() {
3979 use crate::workspace::{
3980 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
3981 };
3982 use crate::workspace_store::WorkspaceStoreAdapter;
3983
3984 let tmp = tempfile::tempdir().unwrap();
3985 let root = tmp.path();
3986 let mem_dir = root.join("srcmem");
3987 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3988 std::fs::write(
3989 mem_dir.join(".memstead").join("config.json"),
3990 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3991 )
3992 .unwrap();
3993 let write = |slug: &str, ty: &str| {
3994 std::fs::write(
3995 mem_dir.join(format!("{slug}.md")),
3996 format!("---\ntype: {ty}\n---\n\n# {slug}\n\n## Decision\n\nBody.\n"),
3997 )
3998 .unwrap();
3999 };
4000 write("kept", "decision");
4001 write("other", "memo");
4002 std::fs::create_dir_all(root.join(".memstead")).unwrap();
4003 std::fs::write(
4004 root.join(".memstead").join("workspace.toml"),
4005 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4006 )
4007 .unwrap();
4008 crate::FileWorkspaceStore::new()
4009 .save_state(
4010 root,
4011 &Workspace {
4012 mounts: vec![Mount {
4013 mem: "srcmem".to_string(),
4014 schema: Some("default@1.0.0".parse().unwrap()),
4015 storage: MountStorage::Folder {
4016 path: mem_dir.clone(),
4017 },
4018 capability: MountCapability::Write,
4019 lifecycle: MountLifecycle::Eager,
4020 cross_linkable: false,
4021 migration_target: None,
4022 }],
4023 settings: WorkspaceSettings::default(),
4024 },
4025 )
4026 .unwrap();
4027 let engine = crate::Engine::from_workspace_root(root).unwrap();
4028
4029 let source = Source {
4030 name: "g".to_string(),
4031 medium_type: MediumType::Graph,
4032 pointer: "srcmem".to_string(),
4033 change_detection: None,
4034 scope: vec![crate::pipeline::PatternEntry {
4035 path: "type:decision".to_string(),
4036 mode: PatternMode::Allow,
4037 }],
4038 engagement: None,
4039 preparation: None,
4040 };
4041
4042 let mut slice = Slice {
4043 added: vec!["srcmem--other".to_string()],
4044 modified: vec!["srcmem--kept".to_string(), "srcmem--other".to_string()],
4045 deleted: vec!["srcmem--vanished".to_string()],
4046 };
4047 filter_graph_slice_to_scope(&engine, &source, &mut slice);
4048
4049 assert_eq!(
4050 slice.modified,
4051 vec!["srcmem--kept".to_string()],
4052 "the out-of-scope memo is dropped from the slice the brief presents"
4053 );
4054 assert!(
4055 slice.added.is_empty(),
4056 "an added out-of-scope entity is out of scope too"
4057 );
4058 assert_eq!(
4059 slice.deleted,
4060 vec!["srcmem--vanished".to_string()],
4061 "a DELETED entity is kept even though its type can no longer be \
4062 read — a deletion that cannot be classified must be reported, \
4063 never silently dropped"
4064 );
4065
4066 let mut wide = Slice {
4068 added: Vec::new(),
4069 modified: vec!["srcmem--kept".to_string(), "srcmem--other".to_string()],
4070 deleted: Vec::new(),
4071 };
4072 let mut all = source.clone();
4073 all.scope = vec![crate::pipeline::PatternEntry {
4074 path: "*".to_string(),
4075 mode: PatternMode::Allow,
4076 }];
4077 filter_graph_slice_to_scope(&engine, &all, &mut wide);
4078 assert_eq!(wide.modified.len(), 2, "`*` selects the whole mem");
4079 }
4080
4081 #[test]
4085 fn entity_selector_grammar_is_closed() {
4086 use super::EntitySelector;
4087 assert_eq!(parse_entity_selector("*"), Some(EntitySelector::All));
4088 assert_eq!(
4089 parse_entity_selector("type:decision"),
4090 Some(EntitySelector::Type("decision".to_string()))
4091 );
4092 assert_eq!(
4093 parse_entity_selector("id:engine--*"),
4094 Some(EntitySelector::Id("engine--*".to_string()))
4095 );
4096 assert_eq!(parse_entity_selector("**/*"), None);
4099 assert_eq!(parse_entity_selector("src/**"), None);
4100 assert_eq!(parse_entity_selector("type:"), None);
4101 assert_eq!(parse_entity_selector("id:"), None);
4102 assert_eq!(parse_entity_selector(""), None);
4103 }
4104
4105 #[test]
4108 fn mtime_driver_reseeds_then_diffs_precisely() {
4109 let ws = tempfile::tempdir().unwrap();
4110 let root = ws.path();
4111 let cache = root.join(".memstead.cache").join("ingest");
4112 std::fs::write(root.join("a.rs"), "one").unwrap();
4113 std::fs::write(root.join("gone.rs"), "bye").unwrap();
4114 let source = primary(vec![PatternEntry {
4115 path: "**/*.rs".to_string(),
4116 mode: PatternMode::Allow,
4117 }]);
4118
4119 let token = match compute_mtime_slice(&source, "ing", &[], root, &cache, None) {
4121 SliceOutcome::Reseed { token } => token,
4122 other => panic!("expected Reseed, got {other:?}"),
4123 };
4124
4125 std::fs::write(root.join("a.rs"), "one-longer").unwrap();
4127 std::fs::remove_file(root.join("gone.rs")).unwrap();
4128 std::fs::write(root.join("new.rs"), "x").unwrap();
4129
4130 match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&token)) {
4132 SliceOutcome::Changed {
4133 slice, degraded, ..
4134 } => {
4135 assert!(
4136 !degraded,
4137 "memo present → precise, not a degraded full scan"
4138 );
4139 assert_eq!(slice.added, vec!["new.rs"]);
4140 assert_eq!(slice.modified, vec!["a.rs"]);
4141 assert_eq!(
4142 slice.deleted,
4143 vec!["gone.rs"],
4144 "deletions come from the memo"
4145 );
4146 }
4147 other => panic!("expected Changed, got {other:?}"),
4148 }
4149
4150 let stale = super::super::change_detection::serialize_digest_token(
4153 &super::super::change_detection::digest_stat_map(&stat_map_for(&["absent.rs"])),
4154 );
4155 match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&stale)) {
4156 SliceOutcome::Changed { degraded, .. } => assert!(degraded, "memo miss → degraded"),
4157 other => panic!("expected degraded Changed, got {other:?}"),
4158 }
4159 }
4160
4161 fn head_sha(repo: &Path) -> String {
4162 String::from_utf8(
4163 std::process::Command::new("git")
4164 .args(["rev-parse", "HEAD"])
4165 .current_dir(repo)
4166 .output()
4167 .unwrap()
4168 .stdout,
4169 )
4170 .unwrap()
4171 .trim()
4172 .to_string()
4173 }
4174
4175 fn slice_contains(slice: &Slice, path: &str) -> bool {
4176 let p = path.to_string();
4177 slice.added.contains(&p) || slice.modified.contains(&p) || slice.deleted.contains(&p)
4178 }
4179
4180 fn mtime_token(source: &Source, deny: &[String], root: &Path) -> String {
4184 let files = enumerate_facet_files(source, deny, root);
4185 serialize_digest_token(&digest_stat_map(&compute_stat_map(&files, root)))
4186 }
4187
4188 #[test]
4194 fn deny_paths_excluded_from_every_strategy_and_token() {
4195 use crate::binding::BuildMode;
4196 use crate::ingest::refinement::next_batch;
4197 use crate::pipeline::IngestTrigger;
4198
4199 let repo = tempfile::tempdir().unwrap();
4200 let root = repo.path();
4201 let cache = root.join(".memstead.cache").join("ingest");
4202
4203 git(root, &["init", "-q"]);
4206 std::fs::write(root.join("keep.rs"), "one").unwrap();
4207 std::fs::write(root.join("denied.rs"), "secret-one").unwrap();
4208 git(root, &["add", "-A"]);
4209 git(root, &["commit", "-qm", "base"]);
4210 let baseline = head_sha(root);
4211
4212 std::fs::write(root.join("keep.rs"), "two").unwrap();
4214 std::fs::write(root.join("denied.rs"), "secret-two").unwrap();
4215 git(root, &["add", "-A"]);
4216 git(root, &["commit", "-qm", "move"]);
4217
4218 let source = primary(vec![PatternEntry {
4221 path: "**/*.rs".to_string(),
4222 mode: PatternMode::Allow,
4223 }]);
4224 let deny = vec!["denied.rs".to_string()];
4225
4226 match compute_git_slice(&source, &deny, root, Some(&baseline)) {
4228 SliceOutcome::Changed { slice, .. } => {
4229 assert_eq!(slice.modified, vec!["keep.rs"]);
4230 assert!(!slice_contains(&slice, "denied.rs"), "git deny leak");
4231 }
4232 other => panic!("git: expected Changed, got {other:?}"),
4233 }
4234 match compute_git_slice(&source, &[], root, Some(&baseline)) {
4237 SliceOutcome::Changed { slice, .. } => {
4238 assert!(
4239 slice_contains(&slice, "denied.rs"),
4240 "un-denied, denied.rs is a genuine git change"
4241 );
4242 }
4243 other => panic!("git(no-deny): expected Changed, got {other:?}"),
4244 }
4245
4246 assert_eq!(enumerate_facet_files(&source, &deny, root), vec!["keep.rs"]);
4248 assert!(
4249 enumerate_facet_files(&source, &[], root).contains(&"denied.rs".to_string()),
4250 "un-denied, denied.rs is enumerated"
4251 );
4252
4253 let token = match compute_mtime_slice(&source, "ing", &deny, root, &cache, None) {
4255 SliceOutcome::Reseed { token } => token,
4256 other => panic!("mtime reseed expected, got {other:?}"),
4257 };
4258 std::fs::write(root.join("keep.rs"), "three-longer").unwrap();
4259 std::fs::write(root.join("denied.rs"), "secret-three-longer").unwrap();
4260 match compute_mtime_slice(&source, "ing", &deny, root, &cache, Some(&token)) {
4261 SliceOutcome::Changed { slice, .. } => {
4262 assert_eq!(slice.modified, vec!["keep.rs"]);
4263 assert!(!slice_contains(&slice, "denied.rs"), "mtime deny leak");
4264 }
4265 other => panic!("mtime: expected Changed, got {other:?}"),
4266 }
4267
4268 let token_present = mtime_token(&source, &deny, root);
4273 std::fs::remove_file(root.join("denied.rs")).unwrap();
4274 let token_absent = mtime_token(&source, &deny, root);
4275 assert_eq!(
4276 token_present, token_absent,
4277 "denied.rs must not influence the mtime digest / source_moved token"
4278 );
4279 std::fs::write(root.join("denied.rs"), "secret-restored").unwrap();
4280
4281 let resolved = ResolvedIngest {
4283 name: "ing".to_string(),
4284 mode: BuildMode::Discovery,
4285 trigger: IngestTrigger::Loop,
4286 batch_size: 50,
4287 deny_paths: deny.clone(),
4288 projection_ref: "m/p".to_string(),
4289 projection_mem: "m".to_string(),
4290 projection_name: "p".to_string(),
4291 intent: None,
4292 sources: vec![ResolvedSource::Primary(source.clone())],
4293 destination_mem: "m".to_string(),
4294 rules: None,
4295 post_actions: None,
4296 };
4297 let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
4298 let batch = next_batch(&engine, &resolved, root, &cache, 20).unwrap();
4299 assert!(
4300 batch.files.contains(&"keep.rs".to_string()),
4301 "keep.rs batched"
4302 );
4303 assert!(
4304 !batch.files.contains(&"denied.rs".to_string()),
4305 "denied.rs must never enter a refinement batch"
4306 );
4307 }
4308
4309 #[test]
4316 fn unscoped_facet_refuses_uniformly_and_empty_deny_is_distinct() {
4317 let repo = tempfile::tempdir().unwrap();
4318 let root = repo.path();
4319 let cache = root.join(".memstead.cache").join("ingest");
4320 git(root, &["init", "-q"]);
4321 std::fs::write(root.join("a.rs"), "one").unwrap();
4322 git(root, &["add", "-A"]);
4323 git(root, &["commit", "-qm", "base"]);
4324 let baseline = head_sha(root);
4325 std::fs::write(root.join("a.rs"), "two").unwrap();
4326 git(root, &["add", "-A"]);
4327 git(root, &["commit", "-qm", "move"]);
4328
4329 let unscoped = primary(vec![PatternEntry {
4332 path: "target/**".to_string(),
4333 mode: PatternMode::Deny,
4334 }]);
4335 assert_eq!(
4336 compute_git_slice(&unscoped, &[], root, Some(&baseline)),
4337 SliceOutcome::NoSignal {
4338 reason: NoSignalReason::Unscoped
4339 },
4340 "git refuses an unscoped facet"
4341 );
4342 assert_eq!(
4343 compute_mtime_slice(&unscoped, "ing", &[], root, &cache, None),
4344 SliceOutcome::NoSignal {
4345 reason: NoSignalReason::Unscoped
4346 },
4347 "mtime refuses an unscoped facet identically"
4348 );
4349 let empty_scope = primary(vec![]);
4351 assert_eq!(
4352 compute_git_slice(&empty_scope, &[], root, Some(&baseline)),
4353 SliceOutcome::NoSignal {
4354 reason: NoSignalReason::Unscoped
4355 }
4356 );
4357
4358 let scoped = primary(vec![PatternEntry {
4361 path: "**/*.rs".to_string(),
4362 mode: PatternMode::Allow,
4363 }]);
4364 assert!(
4365 matches!(
4366 compute_git_slice(&scoped, &[], root, Some(&baseline)),
4367 SliceOutcome::Changed { .. }
4368 ),
4369 "scoped facet + empty deny_paths → normal git slice, not a refusal"
4370 );
4371 assert!(
4372 matches!(
4373 compute_mtime_slice(&scoped, "ing", &[], root, &cache, None),
4374 SliceOutcome::Reseed { .. }
4375 ),
4376 "scoped facet + empty deny_paths → normal mtime reseed, not a refusal"
4377 );
4378 }
4379
4380 #[test]
4383 fn unscoped_facet_emits_no_refinement_batch() {
4384 use crate::binding::BuildMode;
4385 use crate::ingest::refinement::next_batch;
4386 use crate::pipeline::IngestTrigger;
4387
4388 let ws = tempfile::tempdir().unwrap();
4389 let root = ws.path();
4390 let cache = root.join(".memstead.cache").join("ingest");
4391 std::fs::write(root.join("a.rs"), "x").unwrap();
4392
4393 let resolved = ResolvedIngest {
4394 name: "ing".to_string(),
4395 mode: BuildMode::Discovery,
4396 trigger: IngestTrigger::Loop,
4397 batch_size: 50,
4398 deny_paths: vec![],
4399 projection_ref: "m/p".to_string(),
4400 projection_mem: "m".to_string(),
4401 projection_name: "p".to_string(),
4402 intent: None,
4403 sources: vec![ResolvedSource::Primary(primary(vec![]))],
4405 destination_mem: "m".to_string(),
4406 rules: None,
4407 post_actions: None,
4408 };
4409 assert!(
4410 next_batch(
4411 &crate::Engine::from_mounts(Vec::new()).unwrap(),
4412 &resolved,
4413 root,
4414 &cache,
4415 20
4416 )
4417 .is_none(),
4418 "an all-unscoped ingest emits no refinement batch"
4419 );
4420 }
4421
4422 #[test]
4428 fn compute_source_cursor_notes_no_signal_reasons() {
4429 use crate::binding::BuildMode;
4430 use crate::pipeline::IngestTrigger;
4431
4432 let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
4433 let ws = tempfile::tempdir().unwrap();
4435 let root = ws.path();
4436 std::fs::write(root.join("a.rs"), "x").unwrap();
4437
4438 let allow_rs = || {
4439 vec![PatternEntry {
4440 path: "**/*.rs".to_string(),
4441 mode: PatternMode::Allow,
4442 }]
4443 };
4444 let src = |facet: &str, declared: &str, scope: Vec<PatternEntry>| {
4445 ResolvedSource::Primary(Source {
4446 name: facet.to_string(),
4447 medium_type: MediumType::Filesystem,
4448 pointer: String::new(),
4449 change_detection: Some(declared.to_string()),
4450 scope,
4451 engagement: None,
4452 preparation: None,
4453 })
4454 };
4455
4456 let resolved = ResolvedIngest {
4457 name: "ing".to_string(),
4458 mode: BuildMode::Discovery,
4459 trigger: IngestTrigger::Loop,
4460 batch_size: 20,
4461 deny_paths: vec![],
4462 projection_ref: "m/p".to_string(),
4463 projection_mem: "m".to_string(),
4464 projection_name: "p".to_string(),
4465 intent: None,
4466 sources: vec![
4467 src("plan", "none", allow_rs()),
4469 src("blind", "mtime", vec![]),
4471 src("watched", "mtime", allow_rs()),
4473 ],
4474 destination_mem: "m".to_string(),
4475 rules: None,
4476 post_actions: None,
4477 };
4478
4479 let cursor = compute_source_cursor(&engine, &resolved, root);
4480 let reasons: BTreeMap<&str, NoSignalReason> = cursor
4481 .no_signal
4482 .iter()
4483 .map(|n| (n.source.as_str(), n.reason))
4484 .collect();
4485 assert_eq!(reasons.get("plan"), Some(&NoSignalReason::DetectionNone));
4486 assert_eq!(reasons.get("blind"), Some(&NoSignalReason::Unscoped));
4487 assert!(
4488 !reasons.contains_key("watched"),
4489 "a first-seen (reseed) source is not a no-signal note"
4490 );
4491 assert_eq!(cursor.no_signal.len(), 2);
4492 assert!(cursor.reseed.iter().any(|c| c.key == "ing/watched#synced"));
4494
4495 let out = crate::ingest::brief::render_changed_slice(&cursor);
4497 assert!(out.contains("- `plan`: `signal:none`"));
4498 assert!(out.contains("- `blind`: unscoped facet"));
4499 }
4500
4501 fn stat_map_for(paths: &[&str]) -> super::super::change_detection::StatMap {
4502 paths
4503 .iter()
4504 .map(|p| {
4505 (
4506 (*p).to_string(),
4507 super::super::change_detection::StatEntry { mtime: 1, size: 1 },
4508 )
4509 })
4510 .collect()
4511 }
4512
4513 #[test]
4519 fn engine_state_never_enumerates_even_when_allowed() {
4520 let ws = tempfile::tempdir().unwrap();
4521 let root = ws.path();
4522 for rel in [
4523 ".memstead/state/findings/muehle/f.json",
4524 ".memstead/projections/muehle/f.json",
4525 ".memstead.cache/ingest/source-cursor/muehle/f/f.json",
4526 "custom-repo/README.md",
4527 "Allgemein/Protokoll.md",
4528 "Allgemein/Vertrag.md",
4529 ] {
4530 let path = root.join(rel);
4531 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4532 std::fs::write(&path, "x").unwrap();
4533 }
4534 std::fs::write(
4538 root.join(".memstead/workspace.toml"),
4539 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4540 )
4541 .unwrap();
4542 std::fs::write(
4543 root.join(".memstead/state/mounts.json"),
4544 serde_json::json!({
4545 "format": "memstead-mounts-3",
4546 "mounts": [{
4547 "mem": "muehle",
4548 "schema": "default@1.0.0",
4549 "storage": {
4550 "type": "git-branch",
4551 "gitdir": "custom-repo/.git",
4552 "branch": "refs/heads/muehle"
4553 },
4554 "capability": "write",
4555 "lifecycle": "eager",
4556 "cross_linkable": true
4557 }]
4558 })
4559 .to_string(),
4560 )
4561 .unwrap();
4562
4563 let source = primary(vec![
4565 PatternEntry {
4566 path: "**/*".to_string(),
4567 mode: PatternMode::Allow,
4568 },
4569 PatternEntry {
4570 path: ".memstead/**".to_string(),
4571 mode: PatternMode::Allow,
4572 },
4573 PatternEntry {
4574 path: "custom-repo/**".to_string(),
4575 mode: PatternMode::Allow,
4576 },
4577 ]);
4578 let got = enumerate_facet_files(&source, &[], root);
4579 assert_eq!(
4580 got,
4581 vec!["Allgemein/Protokoll.md", "Allgemein/Vertrag.md"],
4582 "only source artifacts may enter the denominator"
4583 );
4584 }
4585
4586 #[test]
4591 fn git_slice_excludes_engine_state() {
4592 let repo = tempfile::tempdir().unwrap();
4593 let root = repo.path();
4594 git(root, &["init", "-q"]);
4595 std::fs::write(
4596 root.join("workspace.rs"), "x",
4598 )
4599 .unwrap();
4600 std::fs::create_dir_all(root.join(".memstead/state")).unwrap();
4601 std::fs::write(
4602 root.join(".memstead/workspace.toml"),
4603 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4604 )
4605 .unwrap();
4606 std::fs::write(
4607 root.join(".memstead/state/mounts.json"),
4608 serde_json::json!({
4609 "format": "memstead-mounts-3",
4610 "mounts": [{
4611 "mem": "muehle",
4612 "schema": "default@1.0.0",
4613 "storage": {
4614 "type": "git-branch",
4615 "gitdir": "custom-repo/.git",
4616 "branch": "refs/heads/muehle"
4617 },
4618 "capability": "write",
4619 "lifecycle": "eager",
4620 "cross_linkable": true
4621 }]
4622 })
4623 .to_string(),
4624 )
4625 .unwrap();
4626 git(root, &["add", "-A"]);
4627 git(root, &["commit", "-qm", "base"]);
4628 let baseline = String::from_utf8(
4629 std::process::Command::new("git")
4630 .args(["rev-parse", "HEAD"])
4631 .current_dir(root)
4632 .output()
4633 .unwrap()
4634 .stdout,
4635 )
4636 .unwrap()
4637 .trim()
4638 .to_string();
4639
4640 std::fs::write(root.join("real.md"), "signal").unwrap();
4642 std::fs::write(root.join(".memstead/state/findings.json"), "self").unwrap();
4643 std::fs::create_dir_all(root.join("custom-repo")).unwrap();
4644 std::fs::write(root.join("custom-repo/README.md"), "repo").unwrap();
4645 git(root, &["add", "-A"]);
4646 git(root, &["commit", "-qm", "move"]);
4647
4648 let source = primary(vec![PatternEntry {
4649 path: "**/*".to_string(),
4650 mode: PatternMode::Allow,
4651 }]);
4652 match compute_git_slice(&source, &[], root, Some(&baseline)) {
4653 SliceOutcome::Changed { slice, .. } => {
4654 assert_eq!(
4655 slice.added,
4656 vec!["real.md"],
4657 "engine state leaked: {slice:?}"
4658 );
4659 assert!(slice.modified.is_empty(), "{slice:?}");
4660 }
4661 other => panic!("expected Changed, got {other:?}"),
4662 }
4663 }
4664
4665 #[test]
4671 fn dead_deny_lint_exempts_scaffold_defaults_but_not_user_typos() {
4672 use crate::binding::{BuildMode, DEFAULT_SCAFFOLD_DENY_PATHS};
4673 use crate::pipeline::IngestTrigger;
4674
4675 let repo = tempfile::tempdir().unwrap();
4676 let root = repo.path();
4677 git(root, &["init", "-q"]);
4678 std::fs::create_dir_all(root.join("src")).unwrap();
4679 std::fs::write(root.join("src/lib.rs"), "code").unwrap();
4680
4681 let mut deny_paths: Vec<String> = DEFAULT_SCAFFOLD_DENY_PATHS
4682 .iter()
4683 .map(|s| s.to_string())
4684 .collect();
4685 deny_paths.push("typo/**".to_string()); deny_paths.push("src/**".to_string()); let resolved = ResolvedIngest {
4689 name: "ing".to_string(),
4690 mode: BuildMode::Discovery,
4691 trigger: IngestTrigger::Loop,
4692 batch_size: 20,
4693 deny_paths,
4694 projection_ref: "m/p".to_string(),
4695 projection_mem: "m".to_string(),
4696 projection_name: "p".to_string(),
4697 intent: None,
4698 sources: vec![ResolvedSource::Primary(primary(vec![PatternEntry {
4699 path: "**/*.rs".to_string(),
4700 mode: PatternMode::Allow,
4701 }]))],
4702 destination_mem: "m".to_string(),
4703 rules: None,
4704 post_actions: None,
4705 };
4706
4707 let dead = dead_deny_entries(&resolved, root);
4708 assert_eq!(
4709 dead,
4710 vec!["typo/**".to_string()],
4711 "only the user typo is flagged — scaffold defaults and matching \
4712 entries stay silent"
4713 );
4714 }
4715}