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