#!/usr/bin/env python3 """ selftest.py -- exercises memory_dream.py against a synthetic fixture store. Builds a throwaway repo + profile memory store in a temp dir, runs the analyzer both in report-only and --apply-safe modes (as a subprocess, with CLAUDETOOLS_ROOT / HOME / CLAUDE_PROJECT_DIR pointed at the fixtures), and asserts: * each detector fires (orphan, missing index target, broken backlink, stale referenced path, overlap cluster, profile drift), * --apply-safe is strictly additive (no file deleted, no file overwritten, orphan index line appended, profile-only file migrated, differing file skipped not clobbered). Stdlib only. Exit 0 on success, 1 on any failed assertion. """ from __future__ import annotations import os import re import subprocess import sys import tempfile from pathlib import Path SCRIPT = Path(__file__).resolve().with_name("memory_dream.py") PY = sys.executable or "python" FAILURES: list[str] = [] def check(cond: bool, msg: str) -> None: status = "[OK]" if cond else "[ERROR]" print(f"{status} {msg}") if not cond: FAILURES.append(msg) def write(path: Path, text: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8") def build_fixture(root: Path): """Create repo + profile fixture stores. Returns (repo_root, project_dir, home).""" repo_root = root / "repo" mem = repo_root / ".claude" / "memory" mem.mkdir(parents=True, exist_ok=True) # A real script the memory can reference (exists -> must NOT be flagged). write(repo_root / ".claude" / "scripts" / "real.sh", "#!/bin/sh\necho hi\n") # --- memory files --- # indexed + clean write(mem / "reference_alpha.md", "---\nname: Alpha\ndescription: alpha thing\nmetadata:\n type: reference\n---\n" "Uses `.claude/scripts/real.sh` which exists.\n") # orphan (no index line) + broken backlink + stale referenced path write(mem / "feedback_orphan.md", "---\nname: Orphan Feedback\ndescription: an orphan\ntype: feedback\n---\n" "See [[no_such_memory]] and `scripts/ghost_missing.py` which is gone.\n") # two overlapping feedback memories (same slug prefix -> cluster) write(mem / "feedback_syncro_aaa.md", "---\nname: Syncro AAA\ndescription: syncro billing rule one\ntype: feedback\n---\nbody\n") write(mem / "feedback_syncro_bbb.md", "---\nname: Syncro BBB\ndescription: syncro billing rule two\ntype: feedback\n---\nbody\n") # stale dated project fact write(mem / "project_old.md", "---\nname: Old Project\ndescription: ancient\ntype: project\n---\n" "Migration completed as of 2019-01-01 and never touched since.\n") # --- MEMORY.md index --- # references reference_alpha + a MISSING target; omits feedback_orphan (orphan) write(mem / "MEMORY.md", "# Memory Index\n\n" "## Reference\n" "- [Alpha](reference_alpha.md) -- alpha thing\n" "- [Ghost](reference_ghost.md) -- points at a missing file\n\n" "## Feedback\n" "- [Syncro AAA](feedback_syncro_aaa.md) -- syncro billing rule one\n" "- [Syncro BBB](feedback_syncro_bbb.md) -- syncro billing rule two\n\n" "## Project\n" "- [Old Project](project_old.md) -- ancient\n") # --- profile store --- # slug derivation mirrors memory_dream.profile_memory_dir project_dir = repo_root # we set CLAUDE_PROJECT_DIR to repo_root home = root / "home" slug = re.sub(r"[^A-Za-z0-9]+", "-", str(project_dir.resolve())) prof = home / ".claude" / "projects" / slug / "memory" prof.mkdir(parents=True, exist_ok=True) # profile-only file (candidate to migrate INTO repo) write(prof / "feedback_profile_only.md", "---\nname: Profile Only\ndescription: lives only in profile\ntype: feedback\n---\nkeep me\n") # same-named in BOTH but DIFFERING content (must be skipped, not overwritten) write(prof / "reference_alpha.md", "---\nname: Alpha\ndescription: alpha thing\nmetadata:\n type: reference\n---\n" "PROFILE VERSION -- different content.\n") return repo_root, project_dir, home, prof def run_analyzer(repo_root: Path, project_dir: Path, home: Path, *extra) -> str: env = dict(os.environ) env["CLAUDETOOLS_ROOT"] = str(repo_root) env["CLAUDE_PROJECT_DIR"] = str(project_dir) env["HOME"] = str(home) env["PYTHONIOENCODING"] = "utf-8" cmd = [PY, str(SCRIPT), "--no-file", *extra] res = subprocess.run(cmd, env=env, capture_output=True, text=True, encoding="utf-8", errors="replace") return res.stdout + "\n" + res.stderr def main() -> int: with tempfile.TemporaryDirectory() as td: root = Path(td) repo_root, project_dir, home, prof = build_fixture(root) mem = repo_root / ".claude" / "memory" # ---- report-only run ---- out = run_analyzer(repo_root, project_dir, home) check("Mode: REPORT-ONLY" in out, "default run is report-only") check("feedback_orphan.md" in out and "Orphan files (no index line): 1" in out, "detects the orphan file") check("reference_ghost.md" in out and "missing files: 1" in out, "detects index line pointing at missing file") check("[[no_such_memory]]" in out, "detects broken backlink") check("ghost_missing.py" in out, "flags stale referenced path") check("real.sh" not in out.split("REFERENCED-ARTIFACT")[-1].split("##")[0] if "REFERENCED-ARTIFACT" in out else True, "does NOT flag an existing referenced path (real.sh)") check("feedback_syncro_aaa.md" in out and "feedback_syncro_bbb.md" in out and "CLUSTER" in out.upper(), "detects overlap cluster") check("project_old.md" in out and "2019-01-01" in out, "detects stale dated project fact") check("feedback_profile_only.md" in out and "MIGRATE INTO repo" in out, "detects profile-only drift") check("reference_alpha.md" in out and "differs between repo and profile" in out, "detects repo<->profile content conflict") check("PROPOSED (needs human approval" in out, "emits PROPOSED section") # ---- snapshot repo state before apply-safe ---- before = {p.name: p.read_text(encoding="utf-8") for p in mem.glob("*.md")} # ---- apply-safe run (additive only) ---- out2 = run_analyzer(repo_root, project_dir, home, "--apply-safe") after = {p.name: p.read_text(encoding="utf-8") for p in mem.glob("*.md")} # No file deleted. check(set(before).issubset(set(after)), "apply-safe deleted no repo file") # Orphan index line appended (file content for non-index unchanged). for fn, content in before.items(): if fn == "MEMORY.md": continue check(after.get(fn) == content, f"apply-safe did not alter memory body: {fn}") # MEMORY.md grew (orphan appended) and kept all old lines. idx_before = before["MEMORY.md"] idx_after = after["MEMORY.md"] check("feedback_orphan.md" in idx_after, "apply-safe appended orphan index line") check(all(line in idx_after for line in idx_before.splitlines() if line.strip()), "apply-safe preserved every existing index line") # Profile-only migrated INTO repo. check("feedback_profile_only.md" in after, "apply-safe migrated profile-only file into repo") # Differing same-named file was SKIPPED, not overwritten. check(after["reference_alpha.md"] == before["reference_alpha.md"], "apply-safe did NOT overwrite differing repo file (skipped)") # The differing same-named file is surfaced as a drift conflict, not a # copy target -- apply-safe leaves it for human review. check("reference_alpha.md" in out2 and "differs between repo and profile" in out2, "apply-safe reported the differing file as a conflict (not overwritten)") # Profile store itself untouched by dream (dream only writes repo side). check((prof / "feedback_profile_only.md").exists(), "profile-only source still present after migration") print() if FAILURES: print(f"[ERROR] {len(FAILURES)} self-test assertion(s) failed:") for f in FAILURES: print(f" - {f}") return 1 print("[SUCCESS] all self-test assertions passed") return 0 if __name__ == "__main__": sys.exit(main())