Files
mempalace/tests/test_sync.py
T
Pim MesselinkandClaude Opus 4.7 cd7a8658a2 fix(backends): require SQLite magic header for chroma + sqlite_exact detect() (#1893) (#1896)
* fix(chroma): require SQLite magic header for ChromaBackend.detect() (#1893)

Closes #1893.

ChromaBackend.detect() was returning True for a 0-byte chroma.sqlite3 file
because the check was just os.path.isfile(...). On a palace that has any
other backend marker alongside a stale 0-byte chroma.sqlite3,
resolve_backend_name then raises BackendMismatchError and the palace becomes
unopenable until the user manually rm's the empty file.

The 0-byte file appears as a side effect of any sqlite3.connect() on a
missing path — Python creates the file immediately but writes the SQLite
header only on the first statement. So any code path that touches the
chroma.sqlite3 path with bare sqlite3.connect(), including chromadb's own
PersistentClient lazy-init (see the comment at backends/chroma.py:2052),
can leave a 0-byte artifact behind.

Fix: detect() now reads the first 16 bytes and compares to the SQLite
magic prefix b"SQLite format 3\x00" instead of relying on file presence
alone. One extra open() + 16-byte read; detect() isn't a hot path.

Properties:
- Rejects 0-byte files (the symptom #1893 is about).
- Rejects non-SQLite garbage at the canonical path (partial writes, etc.).
- Doesn't false-negative on real chroma palaces: any chroma palace whose
  PersistentClient has done any work has the magic header on disk
  (verified — CREATE TABLE is enough to land the header).
- Doesn't couple detect() to chroma's specific schema; the magic header
  is stable across chromadb releases.

Test sweep: many test files used (chroma.sqlite3).touch() or
.write_bytes(b"") as a "fake palace" shortcut, exploiting the loose
isfile() check (one such site even had the comment "# pass the isfile
guard"). After this change, those stand-ins no longer register as chroma
palaces. Introduced tests/_chroma_palace_helper.py::make_minimal_chroma_sqlite
following the existing _backend_conformance.py precedent, and updated 15
call sites across 8 test files to use it. The existing
test_chroma_detect_matches_palace_with_chroma_sqlite (which encoded the
buggy semantics with write_bytes(b"")) is renamed to
test_chroma_detect_matches_palace_with_sqlite_header and now writes a
real SQLite database via the helper. Added two new tests for the
rejection paths (empty file, non-SQLite garbage).

Full env-cleared suite: 3137 passed, 20 skipped, 0 failed. ruff check
and ruff format --check both clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC5Qsknh2zFRtRvVyjXiTA

* fix(sqlite_exact): require SQLite magic header for SQLiteExactBackend.detect()

Per gemini-code-assist review on #1892 PR #1896: SQLiteExactBackend has the
same os.path.isfile() detection pattern as ChromaBackend did, with the same
0-byte-file vulnerability. Mirrors the chroma fix for repo-wide consistency.

- SQLiteExactBackend.detect() now does the same 16-byte SQLite magic-prefix
  check as ChromaBackend.detect().
- _chroma_palace_helper.py: factored its body into a private
  _write_minimal_sqlite_file() and gained a sibling
  make_minimal_sqlite_exact_sqlite() for the sqlite_exact filename. No churn
  to any existing chroma call sites.
- test_sqlite_exact_backend.py:426 (the one site that wrote b"" for
  sqlite_exact.sqlite3) updated to use the new helper.
- Three new tests in test_sqlite_exact_backend.py mirror the chroma trio:
  matches with valid header, rejects empty file, rejects non-SQLite garbage.

Full env-cleared suite: 3140 passed, 20 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC5Qsknh2zFRtRvVyjXiTA

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-28 18:01:59 -03:00

1492 lines
54 KiB
Python

"""
test_sync.py — Tests for `mempalace.sync` (gitignore-aware drawer prune, #1252).
Builds a focused fixture: a temp project with .gitignore + on-disk files +
matching drawers, exercising every classification bucket sync produces.
"""
import os
from pathlib import Path
import chromadb
import pytest
from _chroma_palace_helper import make_minimal_chroma_sqlite
def _seed_drawers(palace_path, repo_path, deleted_path, elsewhere_path):
"""Populate the drawers collection with 6 entries covering all buckets."""
client = chromadb.PersistentClient(path=palace_path)
col = client.get_or_create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
metas = [
{
"wing": "demo",
"room": "src",
"source_file": str(repo_path / "src" / "keep.py"),
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-05-09T00:00:00",
},
{
"wing": "demo",
"room": "build",
"source_file": str(repo_path / "build" / "ignored.py"),
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-05-09T00:00:00",
},
{
"wing": "demo",
"room": "logs",
"source_file": str(repo_path / "app.log"),
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-05-09T00:00:00",
},
{
"wing": "demo",
"room": "stale",
"source_file": str(deleted_path),
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-05-09T00:00:00",
},
{
"wing": "demo",
"room": "convo",
# No source_file key — convo / explicit-add drawers.
"chunk_index": 0,
"added_by": "convo_miner",
"filed_at": "2026-05-09T00:00:00",
},
{
"wing": "demo",
"room": "elsewhere",
"source_file": str(elsewhere_path),
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-05-09T00:00:00",
},
]
col.add(
ids=[
"drawer_keep",
"drawer_gitignored_dir",
"drawer_gitignored_glob",
"drawer_missing",
"drawer_no_source",
"drawer_out_of_scope",
],
documents=[f"doc {i}" for i in range(6)],
embeddings=[[float(i + 1), 0.0, 0.0] for i in range(6)],
metadatas=metas,
)
del client
@pytest.fixture
def synced_world(tmp_dir, palace_path):
"""Temp project with .gitignore + on-disk files + matching drawers."""
repo_path = Path(tmp_dir) / "repo"
(repo_path / "src").mkdir(parents=True)
(repo_path / "build").mkdir()
# .gitignore: ignore build/ directory and any *.log file
(repo_path / ".gitignore").write_text("build/\n*.log\n")
# Files that exist on disk
(repo_path / "src" / "keep.py").write_text("# keep\n")
(repo_path / "build" / "ignored.py").write_text("# ignored by gitignore\n")
(repo_path / "app.log").write_text("log line\n")
# File that the drawer points to but no longer exists
deleted = repo_path / "deleted.py"
deleted.write_text("# was here\n")
deleted.unlink()
# Use tmp_dir for an absolute path; `/tmp/...` literals are not absolute on Windows.
elsewhere = Path(tmp_dir) / "elsewhere" / "x.md"
_seed_drawers(palace_path, repo_path, deleted, elsewhere)
return {"palace_path": palace_path, "repo_path": str(repo_path)}
def _open_drawers(palace_path):
client = chromadb.PersistentClient(path=palace_path)
col = client.get_or_create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
return client, col
def _drawer_ids(col):
return set(col.get(include=[])["ids"])
class TestSyncPalace:
def test_dry_run_classifies_correctly(self, synced_world):
from mempalace.sync import sync_palace
report = sync_palace(
palace_path=synced_world["palace_path"],
project_dirs=[synced_world["repo_path"]],
dry_run=True,
)
assert report["scanned"] == 6
assert report["gitignored"] == 2 # build/ignored.py, app.log
assert report["missing"] == 1 # deleted.py
assert report["no_source"] == 1
assert report["out_of_scope"] == 1
assert report["kept"] == 1 # only src/keep.py
assert report["dry_run"] is True
assert report["removed_drawers"] == 0
# Mutation check — collection still has all 6 drawers.
client, col = _open_drawers(synced_world["palace_path"])
try:
assert len(_drawer_ids(col)) == 6
finally:
del client
def test_apply_removes_gitignored_and_missing(self, synced_world):
from mempalace.sync import sync_palace
report = sync_palace(
palace_path=synced_world["palace_path"],
project_dirs=[synced_world["repo_path"]],
dry_run=False,
)
assert report["dry_run"] is False
assert report["removed_drawers"] == 3 # 2 gitignored + 1 missing
client, col = _open_drawers(synced_world["palace_path"])
try:
survivors = _drawer_ids(col)
assert survivors == {
"drawer_keep",
"drawer_no_source",
"drawer_out_of_scope",
}
finally:
del client
def test_dry_run_does_not_touch_collection(self, synced_world):
from mempalace.sync import sync_palace
client, col = _open_drawers(synced_world["palace_path"])
before = _drawer_ids(col)
del client
sync_palace(
palace_path=synced_world["palace_path"],
project_dirs=[synced_world["repo_path"]],
dry_run=True,
)
client, col = _open_drawers(synced_world["palace_path"])
try:
after = _drawer_ids(col)
finally:
del client
assert before == after
def test_wing_scope_filters(self, tmp_dir, palace_path):
"""A drawer in another wing must survive a wing-scoped sync."""
from mempalace.sync import sync_palace
repo_path = Path(tmp_dir) / "repo"
(repo_path / "build").mkdir(parents=True)
(repo_path / ".gitignore").write_text("build/\n")
(repo_path / "build" / "ignored.py").write_text("# ignored\n")
client = chromadb.PersistentClient(path=palace_path)
col = client.get_or_create_collection(
"mempalace_drawers", metadata={"hnsw:space": "cosine"}
)
col.add(
ids=["d_demo", "d_other"],
documents=["x", "y"],
embeddings=[[1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
metadatas=[
{
"wing": "demo",
"room": "build",
"source_file": str(repo_path / "build" / "ignored.py"),
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-05-09T00:00:00",
},
{
"wing": "other",
"room": "build",
"source_file": str(repo_path / "build" / "ignored.py"),
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-05-09T00:00:00",
},
],
)
del client
sync_palace(
palace_path=palace_path,
project_dirs=[str(repo_path)],
wing="demo",
dry_run=False,
)
client, col = _open_drawers(palace_path)
try:
assert _drawer_ids(col) == {"d_other"}
finally:
del client
def test_no_source_file_drawers_preserved_on_apply(self, synced_world):
from mempalace.sync import sync_palace
sync_palace(
palace_path=synced_world["palace_path"],
project_dirs=[synced_world["repo_path"]],
dry_run=False,
)
client, col = _open_drawers(synced_world["palace_path"])
try:
assert "drawer_no_source" in _drawer_ids(col)
finally:
del client
def test_out_of_scope_drawers_preserved(self, synced_world):
from mempalace.sync import sync_palace
sync_palace(
palace_path=synced_world["palace_path"],
project_dirs=[synced_world["repo_path"]],
dry_run=False,
)
client, col = _open_drawers(synced_world["palace_path"])
try:
assert "drawer_out_of_scope" in _drawer_ids(col)
finally:
del client
def test_negated_gitignore_rules_respected(self, tmp_dir, palace_path):
"""`!build/keep.py` must un-ignore one specific file under build/."""
from mempalace.sync import sync_palace
repo_path = Path(tmp_dir) / "repo"
(repo_path / "build").mkdir(parents=True)
(repo_path / ".gitignore").write_text("build/\n!build/keep.py\n")
(repo_path / "build" / "keep.py").write_text("# survivor\n")
(repo_path / "build" / "doomed.py").write_text("# doomed\n")
client = chromadb.PersistentClient(path=palace_path)
col = client.get_or_create_collection(
"mempalace_drawers", metadata={"hnsw:space": "cosine"}
)
col.add(
ids=["d_keep", "d_doom"],
documents=["x", "y"],
embeddings=[[1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
metadatas=[
{
"wing": "demo",
"room": "build",
"source_file": str(repo_path / "build" / "keep.py"),
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-05-09T00:00:00",
},
{
"wing": "demo",
"room": "build",
"source_file": str(repo_path / "build" / "doomed.py"),
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-05-09T00:00:00",
},
],
)
del client
sync_palace(
palace_path=palace_path,
project_dirs=[str(repo_path)],
dry_run=False,
)
client, col = _open_drawers(palace_path)
try:
survivors = _drawer_ids(col)
finally:
del client
assert "d_keep" in survivors
assert "d_doom" not in survivors
def test_nested_gitignore_layers(self, tmp_dir, palace_path):
"""Subdir .gitignore can deny what root allows."""
from mempalace.sync import sync_palace
repo_path = Path(tmp_dir) / "repo"
(repo_path / "vendor").mkdir(parents=True)
# Root gitignore is empty.
(repo_path / ".gitignore").write_text("\n")
# Subdir gitignore ignores everything under vendor/.
(repo_path / "vendor" / ".gitignore").write_text("*.py\n")
(repo_path / "vendor" / "lib.py").write_text("# nested-ignored\n")
client = chromadb.PersistentClient(path=palace_path)
col = client.get_or_create_collection(
"mempalace_drawers", metadata={"hnsw:space": "cosine"}
)
col.add(
ids=["d_nested"],
documents=["x"],
embeddings=[[1.0, 0.0, 0.0]],
metadatas=[
{
"wing": "demo",
"room": "vendor",
"source_file": str(repo_path / "vendor" / "lib.py"),
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-05-09T00:00:00",
}
],
)
del client
sync_palace(
palace_path=palace_path,
project_dirs=[str(repo_path)],
dry_run=False,
)
client, col = _open_drawers(palace_path)
try:
assert "d_nested" not in _drawer_ids(col)
finally:
del client
def test_closet_purge_runs_on_apply(self, synced_world):
"""Closets pointing at removed sources must also disappear."""
from mempalace.sync import sync_palace
# Seed a closet referencing the to-be-pruned ignored.py source.
client = chromadb.PersistentClient(path=synced_world["palace_path"])
closets = client.get_or_create_collection(
"mempalace_closets", metadata={"hnsw:space": "cosine"}
)
ignored_path = str(Path(synced_world["repo_path"]) / "build" / "ignored.py")
closets.add(
ids=["closet_ignored_01"],
documents=["topic line"],
embeddings=[[1.0, 0.0, 0.0]],
metadatas=[
{
"wing": "demo",
"room": "build",
"source_file": ignored_path,
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-05-09T00:00:00",
}
],
)
del client
report = sync_palace(
palace_path=synced_world["palace_path"],
project_dirs=[synced_world["repo_path"]],
dry_run=False,
)
assert report["removed_closets"] >= 1
client = chromadb.PersistentClient(path=synced_world["palace_path"])
closets = client.get_or_create_collection(
"mempalace_closets", metadata={"hnsw:space": "cosine"}
)
try:
assert closets.get(ids=["closet_ignored_01"])["ids"] == []
finally:
del client
def test_handles_empty_palace(self, palace_path):
from mempalace.sync import sync_palace
client = chromadb.PersistentClient(path=palace_path)
client.get_or_create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
del client
report = sync_palace(palace_path=palace_path, dry_run=True)
assert report["scanned"] == 0
assert report["removed_drawers"] == 0
def test_emits_wal_entries_on_apply(self, synced_world):
from mempalace.sync import sync_palace
seen = []
def fake_wal(operation, params, result=None):
seen.append((operation, params, result))
sync_palace(
palace_path=synced_world["palace_path"],
project_dirs=[synced_world["repo_path"]],
dry_run=False,
wal_log=fake_wal,
)
ops = [op for op, _, _ in seen]
assert "sync_prune" in ops
# F4 — result payload carries the audit trail.
sync_entry = next(e for e in seen if e[0] == "sync_prune")
op, params, result = sync_entry
assert result is not None and "removed_count" in result
assert result["removed_count"] >= 1
# Allow-list — params must be exactly the documented audit shape so
# any future leak (source_file, content, ID lists, etc.) trips a
# test failure rather than slipping through a deny-list.
assert set(params.keys()) <= {"first_id"}, (
f"WAL params drifted from the audit allow-list: {params.keys()}"
)
def test_registry_sentinels_preserved_on_apply(self, tmp_dir, palace_path):
"""F2 regression: convo miner `_reg_*` sentinels must survive sync apply.
Deleting them forces full re-mine + re-embed of the transcript on the
next miner run, even though the transcript content has not changed.
"""
from mempalace.sync import sync_palace
repo_path = Path(tmp_dir) / "repo"
repo_path.mkdir(parents=True)
(repo_path / ".gitignore").write_text("transcripts/\n")
(repo_path / "transcripts").mkdir()
moved_transcript = repo_path / "transcripts" / "convo.jsonl"
moved_transcript.write_text("{}\n")
client = chromadb.PersistentClient(path=palace_path)
col = client.get_or_create_collection(
"mempalace_drawers", metadata={"hnsw:space": "cosine"}
)
col.add(
ids=[
"_reg_abc123_room_match",
"_reg_def456_meta_match",
"_reg_ghi789_id_match",
],
documents=["[registry] x", "[registry] y", "[registry] z"],
embeddings=[[1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [3.0, 0.0, 0.0]],
metadatas=[
{
"wing": "demo",
"room": "_registry",
"source_file": str(moved_transcript),
"chunk_index": 0,
"added_by": "convo_miner",
"filed_at": "2026-05-09T00:00:00",
},
{
"wing": "demo",
"room": "convo",
"source_file": str(moved_transcript),
"chunk_index": 0,
"added_by": "convo_miner",
"filed_at": "2026-05-09T00:00:00",
"ingest_mode": "registry",
},
{
"wing": "demo",
"room": "convo",
"source_file": str(moved_transcript),
"chunk_index": 0,
"added_by": "convo_miner",
"filed_at": "2026-05-09T00:00:00",
},
],
)
del client
# Sentinel transcript is gitignored; without F2 it would also delete
# the `_reg_*` sentinel rows.
sync_palace(
palace_path=palace_path,
project_dirs=[str(repo_path)],
dry_run=False,
)
client, col = _open_drawers(palace_path)
try:
survivors = _drawer_ids(col)
finally:
del client
assert "_reg_abc123_room_match" in survivors # room=_registry
assert "_reg_def456_meta_match" in survivors # ingest_mode=registry
assert "_reg_ghi789_id_match" in survivors # id prefix
def test_auto_detect_picks_deepest_root(self, tmp_dir, palace_path):
"""F3 regression (white-box): when multiple ancestors hold markers
the DEEPEST one wins. Direct assertion on the helper avoids the
tautology of round-1's classifier-based test where ancestor walks
loaded the same matcher chain regardless of which root was picked.
"""
from mempalace.sync import _auto_detect_project_roots
outer = Path(tmp_dir) / "outer"
inner = outer / "inner"
inner.mkdir(parents=True)
# Both have markers. Deepest wins.
(outer / ".gitignore").write_text("*.txt\n")
(inner / ".gitignore").write_text("*.py\n")
client = chromadb.PersistentClient(path=palace_path)
col = client.get_or_create_collection(
"mempalace_drawers", metadata={"hnsw:space": "cosine"}
)
col.add(
ids=["d_inner"],
documents=["x"],
embeddings=[[1.0, 0.0, 0.0]],
metadatas=[
{
"wing": "demo",
"room": "src",
"source_file": str(inner / "x.py"),
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-05-09T00:00:00",
}
],
)
del client
client, col = _open_drawers(palace_path)
try:
roots = _auto_detect_project_roots(col, wing="demo")
finally:
del client
inner_resolved = inner.resolve(strict=False)
outer_resolved = outer.resolve(strict=False)
assert inner_resolved in roots, f"expected inner in roots, got {roots}"
assert outer_resolved not in roots, (
f"deepest should win exclusively: roots={roots}, outer leaked"
)
def test_apply_with_empty_project_dirs_raises(self, palace_path):
"""Round-2 P1: `project_dirs=[]` (empty list) with apply must raise,
not silently classify everything as out_of_scope."""
from mempalace.sync import sync_palace
client = chromadb.PersistentClient(path=palace_path)
client.get_or_create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
del client
with pytest.raises(ValueError, match="empty"):
sync_palace(
palace_path=palace_path,
project_dirs=[],
wing="demo",
dry_run=False,
)
def test_closet_log_warning_when_collection_unavailable(
self, monkeypatch, synced_world, caplog
):
"""F7 regression: closets-collection-missing logs a warning."""
import logging
from mempalace import sync as sync_mod
from mempalace.sync import sync_palace
def boom(*args, **kwargs):
raise RuntimeError("simulated missing closets collection")
monkeypatch.setattr(sync_mod, "get_closets_collection", boom)
with caplog.at_level(logging.WARNING, logger="mempalace.sync"):
sync_palace(
palace_path=synced_world["palace_path"],
project_dirs=[synced_world["repo_path"]],
dry_run=False,
)
assert any("Closet purge skipped" in record.getMessage() for record in caplog.records), (
f"expected closet-skip warning, got: {[r.getMessage() for r in caplog.records]}"
)
def test_metadata_cache_cleared_on_exception(self, monkeypatch, config, synced_world, kg):
"""F9 regression: tool_sync's try/finally must clear `_metadata_cache`
even if sync_palace raises mid-apply.
Tracks an explicit `called` flag on the explode mock so a refactor
that bypasses the patched name (and lets the real sync_palace run)
cannot fake-pass — the assertion below verifies the patched explode
actually ran before the cache was cleared.
"""
from mempalace import mcp_server
# Reconfigure to point at synced_world.
from mempalace.config import MempalaceConfig
import json
cfg_dir = Path(synced_world["palace_path"]).parent / "cfg_for_cache_test"
cfg_dir.mkdir(parents=True, exist_ok=True)
with open(cfg_dir / "config.json", "w") as f:
json.dump({"palace_path": synced_world["palace_path"]}, f)
monkeypatch.setattr(mcp_server, "_config", MempalaceConfig(config_dir=str(cfg_dir)))
monkeypatch.setattr(mcp_server, "_get_kg", lambda: kg)
monkeypatch.setattr(mcp_server, "_metadata_cache", ["dirty-cache-marker"])
called = {"n": 0}
def explode(*args, **kwargs):
called["n"] += 1
raise RuntimeError("simulated mid-apply failure")
monkeypatch.setattr("mempalace.sync.sync_palace", explode)
# tool_sync's broad except catches RuntimeError → returns structured error.
result = mcp_server.tool_sync(
project_dir=synced_world["repo_path"], wing="demo", apply=True
)
assert called["n"] == 1, "explode mock did not actually run; test is a fake-pass"
assert result.get("success") is False
assert "simulated" in result.get("error", "")
assert mcp_server._metadata_cache is None, (
"F9: cache must be cleared even when sync_palace raises"
)
def test_sync_report_keys_stable(self, synced_world):
"""Regression: SyncReport schema must not silently drop a field."""
from mempalace.sync import sync_palace
report = sync_palace(
palace_path=synced_world["palace_path"],
project_dirs=[synced_world["repo_path"]],
dry_run=True,
)
expected = {
"scanned",
"kept",
"gitignored",
"missing",
"no_source",
"out_of_scope",
"removed_drawers",
"removed_closets",
"dry_run",
"by_source",
}
assert set(report.keys()) == expected
def test_batch_size_boundary(self, tmp_dir, palace_path):
"""`_delete_in_batches` correctness at batch_size smaller than dataset."""
from mempalace.sync import sync_palace
repo_path = Path(tmp_dir) / "repo"
repo_path.mkdir(parents=True)
(repo_path / ".gitignore").write_text("ignored/\n")
(repo_path / "ignored").mkdir()
n = 5
for i in range(n):
(repo_path / "ignored" / f"f{i}.py").write_text(f"# {i}\n")
client = chromadb.PersistentClient(path=palace_path)
col = client.get_or_create_collection(
"mempalace_drawers", metadata={"hnsw:space": "cosine"}
)
col.add(
ids=[f"d_{i}" for i in range(n)],
documents=[f"x{i}" for i in range(n)],
embeddings=[[float(i + 1), 0.0, 0.0] for i in range(n)],
metadatas=[
{
"wing": "demo",
"room": "ignored",
"source_file": str(repo_path / "ignored" / f"f{i}.py"),
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-05-09T00:00:00",
}
for i in range(n)
],
)
del client
seen = []
def fake_wal(operation, params, result=None):
if operation == "sync_prune":
seen.append(result["removed_count"])
report = sync_palace(
palace_path=palace_path,
project_dirs=[str(repo_path)],
wing="demo",
dry_run=False,
batch_size=2,
wal_log=fake_wal,
)
assert report["removed_drawers"] == n
# 5 ids at batch_size=2 → chunks of 2,2,1 → 3 wal entries
assert seen == [2, 2, 1], f"unexpected chunk sizes: {seen}"
def test_apply_is_idempotent(self, synced_world):
"""Round-3: a second apply on the same palace must be a no-op."""
from mempalace.sync import sync_palace
first = sync_palace(
palace_path=synced_world["palace_path"],
project_dirs=[synced_world["repo_path"]],
dry_run=False,
)
assert first["removed_drawers"] >= 1
second = sync_palace(
palace_path=synced_world["palace_path"],
project_dirs=[synced_world["repo_path"]],
dry_run=False,
)
assert second["removed_drawers"] == 0
assert second["gitignored"] == 0
assert second["missing"] == 0
def test_relative_source_file_classified_as_no_source(self, tmp_dir, palace_path):
"""Round-3: a drawer whose source_file metadata is relative is upstream
corruption (miner writes absolute paths). Sync must NOT guess at
path resolution; it routes the drawer to `no_source` and leaves it."""
from mempalace.sync import sync_palace
client = chromadb.PersistentClient(path=palace_path)
col = client.get_or_create_collection(
"mempalace_drawers", metadata={"hnsw:space": "cosine"}
)
col.add(
ids=["d_relative"],
documents=["x"],
embeddings=[[1.0, 0.0, 0.0]],
metadatas=[
{
"wing": "demo",
"room": "src",
"source_file": "relative/path.py", # malformed, not absolute
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-05-09T00:00:00",
}
],
)
del client
repo_path = Path(tmp_dir) / "repo"
repo_path.mkdir()
(repo_path / ".gitignore").write_text("*.py\n")
report = sync_palace(
palace_path=palace_path,
project_dirs=[str(repo_path)],
wing="demo",
dry_run=False,
)
assert report["no_source"] == 1
assert report["removed_drawers"] == 0
client, col = _open_drawers(palace_path)
try:
assert "d_relative" in _drawer_ids(col)
finally:
del client
def test_overlapping_project_dirs_picks_longest(self, tmp_dir, palace_path):
"""`_resolve_project_root` longest-prefix matching: nested project
dirs both contain the source; the deeper (longer) one wins."""
from mempalace.sync import sync_palace
outer = Path(tmp_dir) / "outer"
inner = outer / "inner"
inner.mkdir(parents=True)
# Outer .gitignore would NOT block file. Inner .gitignore blocks it.
(outer / ".gitignore").write_text("# empty\n")
(inner / ".gitignore").write_text("x.py\n")
(inner / "x.py").write_text("# inner-ignored\n")
client = chromadb.PersistentClient(path=palace_path)
col = client.get_or_create_collection(
"mempalace_drawers", metadata={"hnsw:space": "cosine"}
)
col.add(
ids=["d_x"],
documents=["x"],
embeddings=[[1.0, 0.0, 0.0]],
metadatas=[
{
"wing": "demo",
"room": "src",
"source_file": str(inner / "x.py"),
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-05-09T00:00:00",
}
],
)
del client
# Pass BOTH outer AND inner as project_dirs. inner is the longest
# prefix, so it should be the chosen root and inner/.gitignore
# rules apply (file is ignored → drawer removed).
report = sync_palace(
palace_path=palace_path,
project_dirs=[str(outer), str(inner)],
wing="demo",
dry_run=False,
)
assert report["gitignored"] == 1, f"expected 1 gitignored, got {report}"
def test_apply_without_scope_raises(self, palace_path):
"""F6: apply=True with both wing=None AND project_dirs=None refuses."""
from mempalace.sync import sync_palace
# Empty palace; we never reach delete code, but the guard must fire
# before any work.
client = chromadb.PersistentClient(path=palace_path)
client.get_or_create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
del client
with pytest.raises(ValueError, match="explicit wing="):
sync_palace(palace_path=palace_path, dry_run=False)
# Dry-run with no scope is still allowed — preview is read-only.
report = sync_palace(palace_path=palace_path, dry_run=True)
assert report["dry_run"] is True
@pytest.mark.skipif(os.name == "nt", reason="fcntl-based contention test is POSIX only")
def test_mine_already_running_propagates(self, synced_world):
"""F1 + T4: sync acquires `mine_palace_lock` for the whole call.
Hold the palace lock via raw fcntl on a separate open file
description; mine_palace_lock opens its own handle and must
raise MineAlreadyRunning rather than silently running against
a partial snapshot.
"""
import fcntl
import hashlib
from mempalace.palace import MineAlreadyRunning
from mempalace.sync import sync_palace
palace_path = synced_world["palace_path"]
resolved = os.path.realpath(os.path.expanduser(palace_path))
palace_key = hashlib.sha256(os.path.normcase(resolved).encode()).hexdigest()[:16]
lock_dir = os.path.join(os.path.expanduser("~"), ".mempalace", "locks")
os.makedirs(lock_dir, exist_ok=True)
lock_path = os.path.join(lock_dir, f"mine_palace_{palace_key}.lock")
Path(lock_path).touch()
with open(lock_path, "r+") as lf:
fcntl.flock(lf, fcntl.LOCK_EX | fcntl.LOCK_NB)
try:
with pytest.raises(MineAlreadyRunning):
sync_palace(
palace_path=palace_path,
project_dirs=[synced_world["repo_path"]],
dry_run=True,
)
finally:
fcntl.flock(lf, fcntl.LOCK_UN)
# Lock released — sync now succeeds.
sync_palace(
palace_path=palace_path,
project_dirs=[synced_world["repo_path"]],
dry_run=True,
)
@pytest.mark.skipif(os.name == "nt", reason="os.symlink needs admin on Windows")
def test_symlinked_project_root_resolves(self, tmp_dir, palace_path):
"""source_file may be written through a symlinked tmp directory
(real macOS behaviour: /var/folders/... is a symlink to
/private/var/folders/...). project_dirs goes through .resolve()
which follows the symlink. Without matching .resolve() on the
source side, _resolve_project_root would mis-bucket every drawer
as out_of_scope. This test pins symmetric resolution.
"""
from mempalace.sync import sync_palace
real_root = Path(tmp_dir) / "real"
(real_root / "build").mkdir(parents=True)
(real_root / ".gitignore").write_text("build/\n")
(real_root / "build" / "x.py").write_text("# ignored\n")
link_root = Path(tmp_dir) / "link"
os.symlink(str(real_root), str(link_root))
client = chromadb.PersistentClient(path=palace_path)
col = client.get_or_create_collection(
"mempalace_drawers", metadata={"hnsw:space": "cosine"}
)
col.add(
ids=["d_via_link"],
documents=["x"],
embeddings=[[1.0, 0.0, 0.0]],
metadatas=[
{
"wing": "demo",
"room": "build",
"source_file": str(link_root / "build" / "x.py"),
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-05-09T00:00:00",
}
],
)
del client
report = sync_palace(
palace_path=palace_path,
project_dirs=[str(real_root)],
wing="demo",
dry_run=True,
)
assert report["gitignored"] == 1, (
f"symmetric resolve broken: drawer mis-bucketed; report={report}"
)
assert report["out_of_scope"] == 0
def test_classification_cache_avoids_redundant_disk_hits(
self, tmp_dir, palace_path, monkeypatch
):
"""Per-file classification cache: N chunks of the same source_file
cost one _classify_drawer invocation, not N. Verifies the perf
optimisation actually short-circuits without changing behaviour.
"""
from mempalace import sync as sync_mod
from mempalace.sync import sync_palace
repo_path = Path(tmp_dir) / "repo"
(repo_path / "build").mkdir(parents=True)
(repo_path / ".gitignore").write_text("build/\n")
(repo_path / "build" / "shared.py").write_text("# ignored\n")
client = chromadb.PersistentClient(path=palace_path)
col = client.get_or_create_collection(
"mempalace_drawers", metadata={"hnsw:space": "cosine"}
)
col.add(
ids=[f"d_chunk_{i}" for i in range(5)],
documents=[f"chunk{i}" for i in range(5)],
embeddings=[[float(i + 1), 0.0, 0.0] for i in range(5)],
metadatas=[
{
"wing": "demo",
"room": "build",
"source_file": str(repo_path / "build" / "shared.py"),
"chunk_index": i,
"added_by": "miner",
"filed_at": "2026-05-09T00:00:00",
}
for i in range(5)
],
)
del client
call_count = {"n": 0}
real_classify = sync_mod._classify_drawer
def counting_classify(*args, **kwargs):
call_count["n"] += 1
return real_classify(*args, **kwargs)
monkeypatch.setattr(sync_mod, "_classify_drawer", counting_classify)
report = sync_palace(
palace_path=palace_path,
project_dirs=[str(repo_path)],
wing="demo",
dry_run=True,
)
assert report["scanned"] == 5
assert report["gitignored"] == 5
assert call_count["n"] == 1, (
f"cache miss: expected 1 _classify_drawer call (4 cache hits), got {call_count['n']}"
)
def test_closet_batch_purge_single_call(self, synced_world, monkeypatch):
"""Batched $in closet purge: one delete() call across all removable
source files, not N. Wraps the real collection so chromadb still
does the work; only the call count is intercepted.
"""
from mempalace import sync as sync_mod
repo_path = Path(synced_world["repo_path"])
palace_path = synced_world["palace_path"]
client = chromadb.PersistentClient(path=palace_path)
closets_col = client.get_or_create_collection(
"mempalace_closets", metadata={"hnsw:space": "cosine"}
)
closets_col.add(
ids=["c1", "c2", "c3"],
documents=["c1", "c2", "c3"],
embeddings=[[1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [3.0, 0.0, 0.0]],
metadatas=[
{"source_file": str(repo_path / "build" / "ignored.py")},
{"source_file": str(repo_path / "app.log")},
{"source_file": str(repo_path / "deleted.py")},
],
)
del client
class CallCountingCol:
def __init__(self, real):
self._real = real
self.delete_calls = 0
self.get_calls = 0
def get(self, *args, **kwargs):
self.get_calls += 1
return self._real.get(*args, **kwargs)
def delete(self, *args, **kwargs):
self.delete_calls += 1
return self._real.delete(*args, **kwargs)
captured: dict = {}
real_get_closets = sync_mod.get_closets_collection
def wrapped_get_closets(p, create=False):
real = real_get_closets(p, create=create)
wrapper = CallCountingCol(real)
captured["wrapper"] = wrapper
return wrapper
monkeypatch.setattr(sync_mod, "get_closets_collection", wrapped_get_closets)
from mempalace.sync import sync_palace
report = sync_palace(
palace_path=palace_path,
project_dirs=[synced_world["repo_path"]],
dry_run=False,
)
seeded_sources = {
str(repo_path / "build" / "ignored.py"),
str(repo_path / "app.log"),
str(repo_path / "deleted.py"),
}
expected = len(seeded_sources & set(report["by_source"].keys()))
assert report["removed_closets"] == expected, (
f"removed_closets ({report['removed_closets']}) != |seeded ∩ removable| ({expected})"
)
assert "wrapper" in captured, "get_closets_collection patch not invoked"
assert captured["wrapper"].delete_calls == 1, (
f"expected one batch delete call, got {captured['wrapper'].delete_calls}"
)
assert captured["wrapper"].get_calls == 1, (
f"expected one batch get call, got {captured['wrapper'].get_calls}"
)
def test_registry_check_runs_before_cache_lookup(self, tmp_dir, palace_path):
"""A non-registry drawer with the same source_file must NOT poison
the bucket of a subsequent _reg_* drawer via the classification
cache. Order matters for chromadb iteration: seed the regular
drawer FIRST so it caches `gitignored`, then a registry sentinel
with the same source_file. Without the registry-bypass at the
top of the main loop, the cache lookup would route the sentinel
to gitignored and delete it.
"""
from mempalace.sync import sync_palace
repo_path = Path(tmp_dir) / "repo"
(repo_path / "build").mkdir(parents=True)
(repo_path / ".gitignore").write_text("build/\n")
(repo_path / "build" / "shared.py").write_text("# ignored\n")
client = chromadb.PersistentClient(path=palace_path)
col = client.get_or_create_collection(
"mempalace_drawers", metadata={"hnsw:space": "cosine"}
)
shared_source = str(repo_path / "build" / "shared.py")
col.add(
ids=["a_regular", "_reg_zzz_sentinel"],
documents=["regular chunk", "registry sentinel"],
embeddings=[[1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
metadatas=[
{
"wing": "demo",
"room": "build",
"source_file": shared_source,
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-05-09T00:00:00",
},
{
"wing": "demo",
"room": "_registry",
"source_file": shared_source,
"chunk_index": 0,
"ingest_mode": "registry",
"added_by": "convo_miner",
"filed_at": "2026-05-09T00:00:00",
},
],
)
del client
report = sync_palace(
palace_path=palace_path,
project_dirs=[str(repo_path)],
wing="demo",
dry_run=False,
)
assert report["gitignored"] == 1
assert report["kept"] == 1
assert report["removed_drawers"] == 1
client, col = _open_drawers(palace_path)
try:
survivors = _drawer_ids(col)
finally:
del client
assert "a_regular" not in survivors
assert "_reg_zzz_sentinel" in survivors, (
"registry sentinel was incorrectly pruned via cached non-registry verdict"
)
def test_normalize_project_dirs_sort_stable_on_equal_length(self):
"""`_normalize_project_dirs` must sort by `(-len, str)` so equal-length
roots are alphabetically deterministic; otherwise overlapping nested
scope choice depends on argv order.
"""
from mempalace.sync import _normalize_project_dirs
result = _normalize_project_dirs(["/tmp/zzz", "/tmp/aaa"])
names = [p.name for p in result]
assert names == ["aaa", "zzz"], f"equal-length sort not deterministic: got {names}"
# Different lengths: deepest first.
deep = _normalize_project_dirs(["/tmp/short", "/tmp/much/deeper/path"])
assert str(deep[0]).endswith("path")
assert str(deep[1]).endswith("short")
class TestSyncMcpTool:
"""T2: `mempalace_sync` MCP entry point must keep apply polarity stable."""
def _patch(self, monkeypatch, config, kg):
from mempalace import mcp_server
monkeypatch.setattr(mcp_server, "_config", config)
monkeypatch.setattr(mcp_server, "_get_kg", lambda: kg)
def test_default_is_dry_run(self, monkeypatch, config, palace_path, kg):
from mempalace import mcp_server
self._patch(monkeypatch, config, kg)
client = chromadb.PersistentClient(path=palace_path)
client.get_or_create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
del client
report = mcp_server.tool_sync(project_dir=palace_path)
assert report["dry_run"] is True
def test_success_true_on_dry_run(self, monkeypatch, config, palace_path, kg):
"""Round-4: success path returns `success: True` for API symmetry
with the structured-error branches that all return `success: False`."""
from mempalace import mcp_server
self._patch(monkeypatch, config, kg)
client = chromadb.PersistentClient(path=palace_path)
client.get_or_create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
del client
report = mcp_server.tool_sync(project_dir=palace_path)
assert report.get("success") is True
assert report.get("dry_run") is True
def test_apply_true_is_destructive(self, monkeypatch, config, synced_world, kg):
from mempalace import mcp_server
# Rebuild config to point at synced_world's palace.
from mempalace.config import MempalaceConfig
import json
cfg_dir = Path(synced_world["palace_path"]).parent / "cfg_for_mcp_test"
cfg_dir.mkdir(parents=True, exist_ok=True)
with open(cfg_dir / "config.json", "w") as f:
json.dump({"palace_path": synced_world["palace_path"]}, f)
cfg = MempalaceConfig(config_dir=str(cfg_dir))
self._patch(monkeypatch, cfg, kg)
report = mcp_server.tool_sync(
project_dir=synced_world["repo_path"], apply=True, wing="demo"
)
assert report["dry_run"] is False
assert report["removed_drawers"] >= 1
def test_no_palace_returns_structured_error(self, monkeypatch, kg):
"""Round-3: tool_sync must keep the {success:False,error:...} contract
even on the early `_no_palace` short-circuit, not return the bare
legacy `{error,hint}` dict."""
from mempalace import mcp_server
class _EmptyConfig:
palace_path = ""
collection_name = "mempalace_drawers"
monkeypatch.setattr(mcp_server, "_config", _EmptyConfig())
monkeypatch.setattr(mcp_server, "_get_kg", lambda: kg)
result = mcp_server.tool_sync()
assert result.get("success") is False
assert "error" in result
def test_apply_without_scope_returns_structured_error(
self, monkeypatch, config, palace_path, kg
):
"""Round-2 P0: tool_sync must return {success: False, error: ...}
rather than letting ValueError propagate to the MCP client."""
from mempalace import mcp_server
client = chromadb.PersistentClient(path=palace_path)
client.get_or_create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
del client
self._patch(monkeypatch, config, kg)
result = mcp_server.tool_sync(apply=True) # no project_dir, no wing
assert result.get("success") is False
assert "wing=" in result.get("error", "") or "project_dirs" in result.get("error", "")
@pytest.mark.skipif(os.name == "nt", reason="fcntl-based contention test is POSIX only")
def test_lock_contention_returns_structured_error(self, monkeypatch, config, synced_world, kg):
"""Round-2 P0: tool_sync with apply=True under contention returns
a structured `{success: False, error: ...}` instead of raising."""
import fcntl
import hashlib
from mempalace import mcp_server
from mempalace.config import MempalaceConfig
import json
# Wire MCP config at synced_world.
cfg_dir = Path(synced_world["palace_path"]).parent / "cfg_for_lock_test"
cfg_dir.mkdir(parents=True, exist_ok=True)
with open(cfg_dir / "config.json", "w") as f:
json.dump({"palace_path": synced_world["palace_path"]}, f)
self._patch(monkeypatch, MempalaceConfig(config_dir=str(cfg_dir)), kg)
# Compute lock path the same way mine_palace_lock does.
resolved = os.path.realpath(os.path.expanduser(synced_world["palace_path"]))
palace_key = hashlib.sha256(os.path.normcase(resolved).encode()).hexdigest()[:16]
lock_dir = os.path.join(os.path.expanduser("~"), ".mempalace", "locks")
os.makedirs(lock_dir, exist_ok=True)
lock_path = os.path.join(lock_dir, f"mine_palace_{palace_key}.lock")
Path(lock_path).touch()
with open(lock_path, "r+") as lf:
fcntl.flock(lf, fcntl.LOCK_EX | fcntl.LOCK_NB)
try:
result = mcp_server.tool_sync(
project_dir=synced_world["repo_path"], wing="demo", apply=True
)
finally:
fcntl.flock(lf, fcntl.LOCK_UN)
assert result.get("success") is False
assert "another mine" in result.get("error", "").lower()
class TestSyncCli:
"""T1: `cmd_sync` argparse + dispatch wrapper round-trip."""
def test_dry_run_default_no_mutation(self, monkeypatch, tmp_dir, synced_world, capsys):
from mempalace import cli
argv = [
"mempalace",
"--palace",
synced_world["palace_path"],
"sync",
synced_world["repo_path"],
]
monkeypatch.setattr("sys.argv", argv)
cli.main()
captured = capsys.readouterr().out
assert "DRY RUN" in captured
assert "would remove" in captured
client, col = _open_drawers(synced_world["palace_path"])
try:
assert len(_drawer_ids(col)) == 6 # synced_world seeds 6, dry-run touches none
finally:
del client
def test_apply_flag_deletes(self, monkeypatch, tmp_dir, synced_world, capsys):
from mempalace import cli
argv = [
"mempalace",
"--palace",
synced_world["palace_path"],
"sync",
synced_world["repo_path"],
"--apply",
"--wing",
"demo",
]
monkeypatch.setattr("sys.argv", argv)
cli.main()
captured = capsys.readouterr().out
assert "Removed" in captured
assert "(removed)" in captured
client, col = _open_drawers(synced_world["palace_path"])
try:
survivors = _drawer_ids(col)
finally:
del client
assert survivors == {
"drawer_keep",
"drawer_no_source",
"drawer_out_of_scope",
}
def test_cli_emits_wal_on_apply(self, monkeypatch, synced_world):
"""F8 regression: cmd_sync must wire `_wal_log` so CLI deletes are
audited. Without this, scripted CLI invocations leave no trail."""
from mempalace import cli, wal
seen = []
original = wal._wal_log
def recording_wal(operation, params, result=None):
seen.append((operation, params, result))
original(operation, params, result)
monkeypatch.setattr(wal, "_wal_log", recording_wal)
argv = [
"mempalace",
"--palace",
synced_world["palace_path"],
"sync",
synced_world["repo_path"],
"--apply",
"--wing",
"demo",
]
monkeypatch.setattr("sys.argv", argv)
cli.main()
ops = [op for op, _, _ in seen]
assert "sync_prune" in ops, f"CLI --apply did not emit WAL sync_prune entries; seen={ops}"
def test_apply_without_scope_exits_2(self, monkeypatch, synced_world, capsys):
"""F6 + F8 CLI hardening: --apply with no scope exits non-zero."""
from mempalace import cli
argv = [
"mempalace",
"--palace",
synced_world["palace_path"],
"sync",
"--apply",
]
monkeypatch.setattr("sys.argv", argv)
with pytest.raises(SystemExit) as exc_info:
cli.main()
assert exc_info.value.code == 2
class TestServiceRunSyncReport:
"""Daemon path (service.run_sync) must render the same report shape as the
direct CLI path, with no KeyError on report['deleted'] (regression: the old
code read a non-existent 'deleted' key and dropped no_source/out_of_scope/
by_source and the Re-run/Removed hints).
sync_palace is mocked so the test exercises only run_sync's report
formatting — opening the real Chroma collection reinitializes the embedder,
which disturbs sys.stdout and defeats capsys.
"""
@pytest.fixture(autouse=True)
def _cache_mcp_server_import(self):
"""run_sync lazily imports mempalace.mcp_server, whose import initializes
the embedder and rebinds sys.stdout — defeating capsys for any prints
after sync_palace returns. Lazy-load it here, scoped to just these report
tests (not the whole module at collection time), so the import is a cached
no-op by the time run_sync runs and its report output stays capturable.
"""
import mempalace.mcp_server # noqa: F401
yield
def _fake_report(self, **overrides):
report = {
"scanned": 6,
"kept": 1,
"gitignored": 2,
"missing": 1,
"no_source": 1,
"out_of_scope": 1,
"removed_drawers": 0,
"removed_closets": 0,
"dry_run": True,
"by_source": {"src/a.py": 2, "src/b.py": 1},
}
report.update(overrides)
return report
def test_dry_run_renders_full_report(self, monkeypatch, tmp_dir, capsys):
import mempalace.sync as sync_module
from mempalace import service
palace = os.path.join(tmp_dir, "palace")
os.makedirs(palace)
# Satisfy run_sync's detect_backend_for_path guard without spinning up
# the real Chroma/embedder stack (which would disturb sys.stdout).
make_minimal_chroma_sqlite(palace)
monkeypatch.setattr(
sync_module,
"sync_palace",
lambda **kw: self._fake_report(dry_run=True),
)
result = service.run_sync({"palace_path": palace, "dir": tmp_dir, "dry_run": True})
assert result["success"] is True
out = capsys.readouterr().out
# The fields the stripped daemon report used to drop.
assert "No source:" in out
assert "Out of scope:" in out
# by_source top sources block.
assert "Top sources to remove" in out
assert "src/a.py (2)" in out
# Re-run hint fires when there is something to remove.
assert "Re-run with --apply" in out
# The old KeyError line must not be present.
assert "Deleted:" not in out
def test_apply_renders_removed_counts(self, monkeypatch, tmp_dir, capsys):
import mempalace.sync as sync_module
from mempalace import service
palace = os.path.join(tmp_dir, "palace")
os.makedirs(palace)
make_minimal_chroma_sqlite(palace)
monkeypatch.setattr(
sync_module,
"sync_palace",
lambda **kw: self._fake_report(
dry_run=False, removed_drawers=3, removed_closets=2, by_source={"src/a.py": 3}
),
)
result = service.run_sync({"palace_path": palace, "dir": tmp_dir, "dry_run": False})
assert result["success"] is True
out = capsys.readouterr().out
# Apply mode prints the removed-drawers/closets line, not the Re-run hint.
assert "Removed 3 drawers, 2 closets" in out
assert "Top sources removed" in out
assert "Re-run with --apply" not in out