feat(wiki): sync upstream v2026.10.4 core maintenance
Selectively port source hashing, locked manifest/memory writes, path-aware link lint and graph analysis, and revision-checked staging from upstream 4f457e154653756b04802d5aeb25c20f42b4770c. Record source hashes and exclusions. Preserve local archive/catalog citations, curated manifest metadata, config location, file-index and MCP behavior. Wire maintenance guidance into four skills without migrating vault contents or changing running services. Validation: 609 tests passed, including existing MCP/file-asset regressions and imported upstream maintenance tests. Read-only vault status left shared metadata hashes unchanged. Wiki MCP remains inactive; orders MCP is active.
This commit is contained in:
@@ -0,0 +1,536 @@
|
||||
"""Tests for the content-hash cache module."""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from obsidian_wiki.cache import (
|
||||
ManifestLockTimeout,
|
||||
check_sources,
|
||||
compute_hash,
|
||||
hash_file,
|
||||
manifest_lock,
|
||||
sha256_file,
|
||||
sha256_dir,
|
||||
update_source,
|
||||
_load_manifest,
|
||||
_lock_path,
|
||||
_manifest_path,
|
||||
_write_manifest,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vault(tmp_path):
|
||||
v = tmp_path / "vault"
|
||||
v.mkdir()
|
||||
return v
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def src_file(tmp_path):
|
||||
f = tmp_path / "doc.md"
|
||||
f.write_text("# Hello\nSome content.", encoding="utf-8")
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def src_dir(tmp_path):
|
||||
d = tmp_path / "repo"
|
||||
d.mkdir()
|
||||
(d / "a.py").write_text("x = 1")
|
||||
(d / "b.py").write_text("y = 2")
|
||||
return d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hash functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHashing:
|
||||
def test_sha256_file_deterministic(self, src_file):
|
||||
assert sha256_file(src_file) == sha256_file(src_file)
|
||||
|
||||
def test_sha256_file_changes_on_edit(self, src_file):
|
||||
h1 = sha256_file(src_file)
|
||||
src_file.write_text("# Different content")
|
||||
h2 = sha256_file(src_file)
|
||||
assert h1 != h2
|
||||
|
||||
def test_sha256_dir_deterministic(self, src_dir):
|
||||
assert sha256_dir(src_dir) == sha256_dir(src_dir)
|
||||
|
||||
def test_sha256_dir_changes_on_edit(self, src_dir):
|
||||
h1 = sha256_dir(src_dir)
|
||||
(src_dir / "a.py").write_text("x = 999")
|
||||
h2 = sha256_dir(src_dir)
|
||||
assert h1 != h2
|
||||
|
||||
def test_compute_hash_dispatches(self, src_file, src_dir):
|
||||
assert len(compute_hash(src_file)) == 64 # hex SHA-256
|
||||
assert len(compute_hash(src_dir)) == 64
|
||||
|
||||
def test_sha256_dir_independent_of_path_separator(self, src_dir):
|
||||
"""Hash must match hashlib computed with POSIX separators regardless of platform (#178)."""
|
||||
import hashlib
|
||||
|
||||
(src_dir / "sub").mkdir()
|
||||
(src_dir / "sub" / "c.py").write_text("z = 3")
|
||||
|
||||
h = hashlib.sha256()
|
||||
for fp in sorted(src_dir.rglob("*"), key=lambda p: p.relative_to(src_dir).as_posix()):
|
||||
if fp.is_file():
|
||||
h.update(fp.relative_to(src_dir).as_posix().encode())
|
||||
h.update(sha256_file(fp).encode())
|
||||
assert sha256_dir(src_dir) == h.hexdigest()
|
||||
|
||||
def test_hash_file_alias(self, src_file):
|
||||
assert hash_file(src_file) == sha256_file(src_file)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_sources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCheckSources:
|
||||
def test_new_source(self, vault, src_file):
|
||||
result = check_sources(vault, [src_file])
|
||||
assert str(src_file) in result["new"]
|
||||
assert result["modified"] == []
|
||||
assert result["unchanged"] == []
|
||||
|
||||
def test_unchanged_after_update(self, vault, src_file):
|
||||
update_source(vault, src_file)
|
||||
result = check_sources(vault, [src_file])
|
||||
assert str(src_file) in result["unchanged"]
|
||||
assert result["new"] == []
|
||||
assert result["modified"] == []
|
||||
|
||||
def test_modified_after_content_change(self, vault, src_file):
|
||||
update_source(vault, src_file)
|
||||
src_file.write_text("# Changed content")
|
||||
result = check_sources(vault, [src_file])
|
||||
assert str(src_file) in result["modified"]
|
||||
|
||||
def test_missing_path(self, vault, tmp_path):
|
||||
ghost = tmp_path / "ghost.md"
|
||||
result = check_sources(vault, [ghost])
|
||||
assert str(ghost) in result["missing"]
|
||||
|
||||
def test_empty_source_list(self, vault):
|
||||
result = check_sources(vault, [])
|
||||
assert result == {
|
||||
"new": [], "modified": [], "unchanged": [], "missing": [], "unavailable": []
|
||||
}
|
||||
|
||||
def test_multiple_sources(self, vault, src_file, src_dir):
|
||||
update_source(vault, src_file)
|
||||
result = check_sources(vault, [src_file, src_dir])
|
||||
assert str(src_file) in result["unchanged"]
|
||||
assert str(src_dir) in result["new"]
|
||||
|
||||
def test_timestamp_irrelevant(self, vault, src_file):
|
||||
# Touch the file (change mtime) without changing content — still unchanged
|
||||
update_source(vault, src_file)
|
||||
src_file.touch()
|
||||
result = check_sources(vault, [src_file])
|
||||
assert str(src_file) in result["unchanged"]
|
||||
|
||||
def _write_relative_manifest(self, vault, rel_key, content_hash):
|
||||
"""Write a manifest whose source key is stored vault-relative."""
|
||||
_manifest_path(vault).write_text(
|
||||
json.dumps(
|
||||
{"sources": {rel_key: {"content_hash": content_hash, "last_ingested": "2026-07-14"}}}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def test_relative_manifest_key_unchanged_for_abs_path(self, vault):
|
||||
# Manifest stores a vault-relative key; caller passes the absolute path.
|
||||
src = vault / "_raw" / "articles" / "foo.md"
|
||||
src.parent.mkdir(parents=True)
|
||||
src.write_text("body", encoding="utf-8")
|
||||
self._write_relative_manifest(vault, "_raw/articles/foo.md", sha256_file(src))
|
||||
result = check_sources(vault, [src])
|
||||
assert str(src) in result["unchanged"]
|
||||
assert result["new"] == []
|
||||
assert result["missing"] == []
|
||||
|
||||
def test_relative_manifest_key_not_falsely_missing(self, vault):
|
||||
# A relative key whose file exists under the vault must not be flagged missing,
|
||||
# even when CWD != vault root.
|
||||
src = vault / "_raw" / "articles" / "foo.md"
|
||||
src.parent.mkdir(parents=True)
|
||||
src.write_text("body", encoding="utf-8")
|
||||
self._write_relative_manifest(vault, "_raw/articles/foo.md", sha256_file(src))
|
||||
result = check_sources(vault, [])
|
||||
assert "_raw/articles/foo.md" not in result["missing"]
|
||||
|
||||
def test_relative_manifest_key_modified(self, vault):
|
||||
src = vault / "_raw" / "articles" / "foo.md"
|
||||
src.parent.mkdir(parents=True)
|
||||
src.write_text("body", encoding="utf-8")
|
||||
self._write_relative_manifest(vault, "_raw/articles/foo.md", "stale-hash")
|
||||
result = check_sources(vault, [src])
|
||||
assert str(src) in result["modified"]
|
||||
|
||||
def test_relative_manifest_key_genuinely_missing(self, vault):
|
||||
# A relative key whose top-level directory really is part of this vault
|
||||
# is a vault-local loss when the file is gone.
|
||||
(vault / "_raw" / "articles").mkdir(parents=True)
|
||||
self._write_relative_manifest(vault, "_raw/articles/gone.md", "abc")
|
||||
result = check_sources(vault, [])
|
||||
assert "_raw/articles/gone.md" in result["missing"]
|
||||
|
||||
def test_relative_key_under_an_unknown_top_dir_is_unavailable(self, vault):
|
||||
# A relative key whose first segment is not a vault entry is relative to
|
||||
# some other root (a legacy ingest root, or an out-of-vault namespace), so
|
||||
# it is unavailable here rather than a missing vault source.
|
||||
self._write_relative_manifest(vault, "-Users-x/abc.jsonl", "abc")
|
||||
result = check_sources(vault, [])
|
||||
assert result["missing"] == []
|
||||
assert "-Users-x/abc.jsonl" in result["unavailable"]
|
||||
|
||||
def test_check_sources_resolves_each_path_once(self, vault, tmp_path, monkeypatch):
|
||||
"""Matching resolves each entry and each query once (an index of forms), not once
|
||||
per (entry, query) pair: with relative keys the string fast path never fires, and a
|
||||
3,000-entry manifest made every queried file cost a second of ``realpath`` walks."""
|
||||
src = tmp_path / "src"
|
||||
src.mkdir()
|
||||
files = []
|
||||
for i in range(300):
|
||||
f = src / f"f{i:03d}.md"
|
||||
f.write_text(f"doc {i}", encoding="utf-8")
|
||||
files.append(f)
|
||||
sources = {}
|
||||
for i, f in enumerate(files):
|
||||
# "../src/f000.md" — the relative form the CLI writes for out-of-vault sources
|
||||
sources[os.path.relpath(f, vault)] = {"content_hash": sha256_file(f) if i % 3 else "0" * 64}
|
||||
for i in range(2000): # unrelated entries, like a real manifest
|
||||
sources[f"../elsewhere/e{i:04d}.md"] = {"content_hash": "1" * 64}
|
||||
_write_manifest(vault, {"version": 1, "sources": sources})
|
||||
|
||||
calls = {"n": 0}
|
||||
real_resolve = Path.resolve
|
||||
|
||||
def counting_resolve(self, *args, **kwargs):
|
||||
calls["n"] += 1
|
||||
return real_resolve(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "resolve", counting_resolve)
|
||||
result = check_sources(vault, files)
|
||||
|
||||
assert len(result["unchanged"]) == 200
|
||||
assert len(result["modified"]) == 100
|
||||
assert result["new"] == []
|
||||
assert len(result["missing"]) + len(result["unavailable"]) == 2000
|
||||
# one resolve per entry and per query (plus a handful inside helpers), never entries × queries
|
||||
assert calls["n"] <= 3 * (2300 + 300), calls["n"]
|
||||
|
||||
def test_check_sources_first_entry_wins_for_duplicate_forms(self, vault, tmp_path):
|
||||
"""Two entries naming the same file in different forms: the earlier one is the match,
|
||||
the later one is not reported missing (it is a duplicate of a scanned path)."""
|
||||
f = tmp_path / "dup.md"
|
||||
f.write_text("same", encoding="utf-8")
|
||||
sources = {
|
||||
os.path.relpath(f, vault): {"content_hash": "0" * 64}, # earlier: stale hash → modified
|
||||
str(f): {"content_hash": sha256_file(f)}, # later: same file, absolute form
|
||||
}
|
||||
_write_manifest(vault, {"version": 1, "sources": sources})
|
||||
result = check_sources(vault, [f])
|
||||
assert result["modified"] == [str(f)] and result["unchanged"] == []
|
||||
assert result["missing"] == [] and result["unavailable"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_source / manifest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUpdateSource:
|
||||
def test_writes_manifest(self, vault, src_file):
|
||||
update_source(vault, src_file)
|
||||
assert _manifest_path(vault).exists()
|
||||
|
||||
def test_records_correct_hash(self, vault, src_file):
|
||||
h = update_source(vault, src_file)
|
||||
assert h == sha256_file(src_file)
|
||||
sources = _load_manifest(vault)
|
||||
assert sources[str(src_file)]["content_hash"] == h
|
||||
|
||||
def test_records_pages_produced(self, vault, src_file):
|
||||
update_source(vault, src_file, pages_produced=["concepts/foo.md", "entities/bar.md"])
|
||||
sources = _load_manifest(vault)
|
||||
assert sources[str(src_file)]["pages_produced"] == ["concepts/foo.md", "entities/bar.md"]
|
||||
|
||||
def test_records_last_ingested_timestamp(self, vault, src_file):
|
||||
update_source(vault, src_file)
|
||||
sources = _load_manifest(vault)
|
||||
assert "last_ingested" in sources[str(src_file)]
|
||||
|
||||
def test_update_overwrites_old_hash(self, vault, src_file):
|
||||
update_source(vault, src_file)
|
||||
src_file.write_text("new content")
|
||||
h2 = update_source(vault, src_file)
|
||||
sources = _load_manifest(vault)
|
||||
assert sources[str(src_file)]["content_hash"] == h2
|
||||
|
||||
def test_preserves_other_manifest_entries(self, vault, src_file, src_dir):
|
||||
update_source(vault, src_file)
|
||||
update_source(vault, src_dir)
|
||||
sources = _load_manifest(vault)
|
||||
assert str(src_file) in sources
|
||||
assert str(src_dir) in sources
|
||||
|
||||
def test_update_source_live_draft_beats_same_named_archive(self, vault):
|
||||
staging = vault / "_raw" / "notes.md"
|
||||
archived = vault / "_raw" / "_archived" / "notes.md"
|
||||
archived.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging.write_text("staging-bytes\n", encoding="utf-8")
|
||||
archived.write_text("archived-bytes\n", encoding="utf-8")
|
||||
h = update_source(vault, staging)
|
||||
assert h == compute_hash(staging)
|
||||
sources = _load_manifest(vault)
|
||||
assert "_raw/notes.md" in sources
|
||||
assert "_raw/_archived/notes.md" not in sources
|
||||
|
||||
def test_update_source_hashes_archive_when_staging_gone(self, vault):
|
||||
staging = vault / "_raw" / "notes.md"
|
||||
archived = vault / "_raw" / "_archived" / "notes.md"
|
||||
archived.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging.write_text("staging-bytes\n", encoding="utf-8")
|
||||
archived.write_text("archived-bytes\n", encoding="utf-8")
|
||||
update_source(vault, staging)
|
||||
staging.unlink()
|
||||
h = update_source(vault, staging)
|
||||
assert h == compute_hash(archived)
|
||||
assert "_raw/_archived/notes.md" in _load_manifest(vault)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCacheCLI:
|
||||
def _run(self, *args, cwd=None):
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "obsidian_wiki.cli", *args],
|
||||
capture_output=True, text=True, cwd=cwd,
|
||||
)
|
||||
|
||||
def test_cache_hash_file(self, src_file):
|
||||
proc = self._run("cache-hash", str(src_file))
|
||||
assert proc.returncode == 0
|
||||
data = json.loads(proc.stdout)
|
||||
assert data["sha256"] == sha256_file(src_file)
|
||||
|
||||
def test_cache_hash_missing_exits_nonzero(self, tmp_path):
|
||||
proc = self._run("cache-hash", str(tmp_path / "nope.md"))
|
||||
assert proc.returncode != 0
|
||||
|
||||
def test_cache_check_new(self, vault, src_file):
|
||||
proc = self._run("cache-check", str(vault), str(src_file))
|
||||
assert proc.returncode == 0
|
||||
data = json.loads(proc.stdout)
|
||||
assert str(src_file) in data["new"]
|
||||
|
||||
def test_cache_check_pretty(self, vault, src_file):
|
||||
proc = self._run("cache-check", "--pretty", str(vault), str(src_file))
|
||||
assert proc.returncode == 0
|
||||
assert "\n " in proc.stdout
|
||||
|
||||
def test_cache_update_then_check_unchanged(self, vault, src_file):
|
||||
self._run("cache-update", str(vault), str(src_file))
|
||||
proc = self._run("cache-check", str(vault), str(src_file))
|
||||
data = json.loads(proc.stdout)
|
||||
assert str(src_file) in data["unchanged"]
|
||||
|
||||
def test_cache_update_with_pages(self, vault, src_file):
|
||||
proc = self._run("cache-update", str(vault), str(src_file),
|
||||
"--pages", "concepts/foo.md", "entities/bar.md")
|
||||
assert proc.returncode == 0
|
||||
sources = _load_manifest(vault)
|
||||
assert sources[str(src_file)]["pages_produced"] == ["concepts/foo.md", "entities/bar.md"]
|
||||
|
||||
def test_cache_update_non_portable_source_warns_on_stderr(self, vault, src_file):
|
||||
# src_file lives outside the vault and $HOME, so the fallback absolute
|
||||
# key is stored — but the CLI must say so rather than stay silent.
|
||||
proc = self._run("cache-update", str(vault), str(src_file))
|
||||
assert proc.returncode == 0
|
||||
assert "no portable key" in proc.stderr
|
||||
|
||||
def test_cache_update_explicit_key_warns_nothing(self, vault, src_file):
|
||||
proc = self._run("cache-update", str(vault), str(src_file), "--key", "repo:o/n")
|
||||
assert proc.returncode == 0
|
||||
assert "no portable key" not in proc.stderr
|
||||
assert "repo:o/n" in _load_manifest(vault)
|
||||
|
||||
def test_cache_update_cli_uses_archive_if_staging_gone(self, vault):
|
||||
staging = vault / "_raw" / "notes.md"
|
||||
archived = vault / "_raw" / "_archived" / "notes.md"
|
||||
archived.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging.write_text("staging-bytes\n", encoding="utf-8")
|
||||
archived.write_text("archived-bytes\n", encoding="utf-8")
|
||||
staging.unlink()
|
||||
proc = self._run("cache-update", str(vault), str(staging))
|
||||
assert proc.returncode == 0
|
||||
data = json.loads(proc.stdout)
|
||||
assert data["key"] == "_raw/_archived/notes.md"
|
||||
assert data["content_hash"] == compute_hash(archived)
|
||||
|
||||
|
||||
class TestCacheCLIVaultRelativeSources:
|
||||
"""Relative source arguments must also resolve against the vault root.
|
||||
|
||||
The manifest stores vault-relative keys, and ``cache.resolve_key`` reads
|
||||
them that way — so ``cache-update <vault> Raw/x.pdf`` has to work from any
|
||||
CWD, not just when the shell happens to sit at the vault root.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def vault(self, tmp_path):
|
||||
v = tmp_path / "vault"
|
||||
(v / "Raw" / "database").mkdir(parents=True)
|
||||
(v / "Raw" / "database" / "x.pdf").write_bytes(b"pdf-bytes")
|
||||
return v
|
||||
|
||||
def _run(self, *args, cwd=None):
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "obsidian_wiki.cli", *args],
|
||||
capture_output=True, text=True, cwd=cwd,
|
||||
)
|
||||
|
||||
def test_cache_update_vault_relative_from_other_cwd(self, vault, tmp_path):
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
proc = self._run("cache-update", str(vault), "Raw/database/x.pdf",
|
||||
cwd=elsewhere)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
data = json.loads(proc.stdout)
|
||||
assert data["key"] == "Raw/database/x.pdf"
|
||||
assert "Raw/database/x.pdf" in json.dumps(_load_manifest(vault))
|
||||
|
||||
def test_cache_check_vault_relative_from_other_cwd(self, vault, tmp_path):
|
||||
update_source(vault, vault / "Raw" / "database" / "x.pdf")
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
proc = self._run("cache-check", str(vault), "Raw/database/x.pdf",
|
||||
cwd=elsewhere)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
data = json.loads(proc.stdout)
|
||||
assert str(vault / "Raw" / "database" / "x.pdf") in data["unchanged"]
|
||||
assert data["missing"] == []
|
||||
|
||||
def test_cache_update_cwd_relative_still_wins(self, vault, tmp_path):
|
||||
# Backward compat: an out-of-vault relative path resolves against the
|
||||
# CWD as before, even when the vault contains a same-named file.
|
||||
workdir = tmp_path / "workdir"
|
||||
(workdir / "Raw" / "database").mkdir(parents=True)
|
||||
(workdir / "Raw" / "database" / "x.pdf").write_bytes(b"different-bytes")
|
||||
proc = self._run("cache-update", str(vault), "Raw/database/x.pdf",
|
||||
cwd=workdir)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
data = json.loads(proc.stdout)
|
||||
assert data["path"] == str(workdir / "Raw" / "database" / "x.pdf")
|
||||
# The typed string is byte-for-byte a manifest key, yet the vault file
|
||||
# it names is not the one hashed — say so rather than choosing silently.
|
||||
assert (f"note: Raw/database/x.pdf matched both "
|
||||
f"{workdir / 'Raw' / 'database' / 'x.pdf'} and "
|
||||
f"{vault / 'Raw' / 'database' / 'x.pdf'}") in proc.stderr
|
||||
|
||||
def test_cache_update_missing_source_clean_error(self, vault, tmp_path):
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
proc = self._run("cache-update", str(vault), "Raw/absent.md", cwd=elsewhere)
|
||||
assert proc.returncode == 1
|
||||
assert "Traceback" not in proc.stderr
|
||||
assert str(elsewhere / "Raw" / "absent.md") in proc.stderr
|
||||
assert str(vault / "Raw" / "absent.md") in proc.stderr
|
||||
|
||||
def test_cache_update_absolute_missing_clean_error(self, vault):
|
||||
proc = self._run("cache-update", str(vault), str(vault / "Raw" / "gone.pdf"))
|
||||
assert proc.returncode == 1
|
||||
assert "Traceback" not in proc.stderr
|
||||
assert str(vault / "Raw" / "gone.pdf") in proc.stderr
|
||||
|
||||
|
||||
class TestManifestLock:
|
||||
"""Concurrent writers must serialize instead of clobbering the manifest."""
|
||||
|
||||
def test_lock_is_released_after_use(self, vault):
|
||||
with manifest_lock(vault):
|
||||
assert _lock_path(vault).exists()
|
||||
assert not _lock_path(vault).exists()
|
||||
|
||||
def test_second_holder_times_out(self, vault):
|
||||
with manifest_lock(vault):
|
||||
with pytest.raises(ManifestLockTimeout):
|
||||
with manifest_lock(vault, timeout=0.3):
|
||||
pass
|
||||
|
||||
def test_stale_lock_is_stolen(self, vault):
|
||||
lock = _lock_path(vault)
|
||||
lock.write_text("999999")
|
||||
old = time.time() - 120
|
||||
os.utime(lock, (old, old))
|
||||
with manifest_lock(vault, timeout=0.5, stale_after=60.0):
|
||||
pass
|
||||
assert not lock.exists()
|
||||
|
||||
def test_lock_released_when_body_raises(self, vault):
|
||||
with pytest.raises(ValueError):
|
||||
with manifest_lock(vault):
|
||||
raise ValueError("boom")
|
||||
assert not _lock_path(vault).exists()
|
||||
|
||||
def test_update_source_leaves_no_lock_behind(self, vault, src_file):
|
||||
update_source(vault, src_file)
|
||||
assert not _lock_path(vault).exists()
|
||||
|
||||
def test_concurrent_updates_both_survive(self, vault, tmp_path):
|
||||
"""The race the lock exists for: two processes, neither entry lost."""
|
||||
a = tmp_path / "a.md"
|
||||
a.write_text("alpha", encoding="utf-8")
|
||||
b = tmp_path / "b.md"
|
||||
b.write_text("beta", encoding="utf-8")
|
||||
code = (
|
||||
"import sys;from pathlib import Path;"
|
||||
"from obsidian_wiki.cache import update_source;"
|
||||
"update_source(Path(sys.argv[1]), Path(sys.argv[2]))"
|
||||
)
|
||||
env = {**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parent.parent)}
|
||||
procs = [
|
||||
subprocess.Popen([sys.executable, "-c", code, str(vault), str(src)], env=env)
|
||||
for src in (a, b)
|
||||
]
|
||||
for p in procs:
|
||||
assert p.wait(timeout=30) == 0
|
||||
sources = _load_manifest(vault)
|
||||
assert str(a) in sources and str(b) in sources
|
||||
|
||||
|
||||
class TestAtomicWrite:
|
||||
def test_failed_write_leaves_original_intact(self, vault, monkeypatch):
|
||||
_write_manifest(vault, {"sources": {"kept": {"content_hash": "abc"}}})
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr("obsidian_wiki.cache.os.replace", boom)
|
||||
with pytest.raises(OSError):
|
||||
_write_manifest(vault, {"sources": {"lost": {}}})
|
||||
|
||||
assert _load_manifest(vault) == {"kept": {"content_hash": "abc"}}
|
||||
leftovers = list(vault.glob(".manifest.json.*.tmp"))
|
||||
assert leftovers == []
|
||||
|
||||
def test_manifest_is_never_partially_written(self, vault, src_file):
|
||||
update_source(vault, src_file)
|
||||
# A complete, parseable JSON document — never a truncated prefix.
|
||||
json.loads(_manifest_path(vault).read_text(encoding="utf-8"))
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Regression tests for issue #134.
|
||||
|
||||
cache-check / cache-update were written for a dict-keyed manifest with bare hex
|
||||
hashes, but the wiki-ingest skill writes ``sources`` as a list of objects with
|
||||
``sha256:``-prefixed hashes and vault-relative paths. That combination caused a
|
||||
crash, then false "modified"/"new" once the crash was patched. These tests pin
|
||||
all four failure modes the issue enumerated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from obsidian_wiki.cache import (
|
||||
check_sources,
|
||||
compute_hash,
|
||||
update_source,
|
||||
_load_raw,
|
||||
_manifest_path,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vault(tmp_path):
|
||||
v = tmp_path / "vault"
|
||||
v.mkdir()
|
||||
return v
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def raw_file(vault):
|
||||
f = vault / "_raw" / "foo.md"
|
||||
f.parent.mkdir(parents=True)
|
||||
f.write_text("# Foo\nbody", encoding="utf-8")
|
||||
return f
|
||||
|
||||
|
||||
def _write_list_manifest(vault: Path, entries: list[dict]) -> None:
|
||||
_manifest_path(vault).write_text(
|
||||
json.dumps({"version": 1, "sources": entries}, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
# 1. Crash on list-shaped sources ------------------------------------------
|
||||
|
||||
def test_list_shaped_manifest_does_not_crash(vault, raw_file):
|
||||
_write_list_manifest(vault, [{"path": "_raw/foo.md", "content_hash": "sha256:x"}])
|
||||
result = check_sources(vault, [raw_file]) # previously: AttributeError
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
# 2. Hash prefix mismatch ---------------------------------------------------
|
||||
|
||||
def test_sha256_prefixed_hash_reads_as_unchanged(vault, raw_file):
|
||||
# Skill records "sha256:<hex>"; compute_hash returns bare hex.
|
||||
_write_list_manifest(
|
||||
vault, [{"path": "_raw/foo.md", "content_hash": f"sha256:{compute_hash(raw_file)}"}]
|
||||
)
|
||||
result = check_sources(vault, [raw_file])
|
||||
assert str(raw_file) in result["unchanged"]
|
||||
assert result["modified"] == []
|
||||
assert result["new"] == []
|
||||
|
||||
|
||||
def test_prefixed_hash_still_detects_real_change(vault, raw_file):
|
||||
_write_list_manifest(
|
||||
vault, [{"path": "_raw/foo.md", "content_hash": f"sha256:{compute_hash(raw_file)}"}]
|
||||
)
|
||||
raw_file.write_text("# Foo\nchanged", encoding="utf-8")
|
||||
result = check_sources(vault, [raw_file])
|
||||
assert str(raw_file) in result["modified"]
|
||||
|
||||
|
||||
# 3. Vault-relative path stored, absolute path queried ----------------------
|
||||
|
||||
def test_relative_list_path_matches_absolute_query(vault, raw_file):
|
||||
_write_list_manifest(
|
||||
vault, [{"path": "_raw/foo.md", "content_hash": f"sha256:{compute_hash(raw_file)}"}]
|
||||
)
|
||||
result = check_sources(vault, [raw_file.resolve()])
|
||||
assert str(raw_file.resolve()) in result["unchanged"]
|
||||
|
||||
|
||||
# 4. update_source preserves shape, duplicates, and extra fields ------------
|
||||
|
||||
def test_update_preserves_list_shape(vault, raw_file):
|
||||
_write_list_manifest(
|
||||
vault,
|
||||
[{"path": "_raw/foo.md", "content_hash": "sha256:old", "source_type": "document",
|
||||
"pages_produced": ["concepts/foo.md"]}],
|
||||
)
|
||||
update_source(vault, raw_file, pages_produced=["concepts/foo.md", "concepts/bar.md"])
|
||||
sources = _load_raw(vault)["sources"]
|
||||
assert isinstance(sources, list), "must stay a list, not collapse to a dict"
|
||||
assert len(sources) == 1
|
||||
entry = sources[0]
|
||||
assert entry["source_type"] == "document" # skill field preserved
|
||||
assert entry["content_hash"] == f"sha256:{compute_hash(raw_file)}" # prefix kept
|
||||
assert entry["pages_produced"] == ["concepts/foo.md", "concepts/bar.md"]
|
||||
|
||||
|
||||
def test_update_preserves_duplicate_path_entries(vault, raw_file):
|
||||
# Same path ingested on two occasions — a dict round-trip would collapse these.
|
||||
_write_list_manifest(
|
||||
vault,
|
||||
[
|
||||
{"path": "_raw/foo.md", "content_hash": "sha256:v1", "ingested_at": "2026-01-01"},
|
||||
{"path": "_raw/foo.md", "content_hash": "sha256:v2", "ingested_at": "2026-02-01"},
|
||||
],
|
||||
)
|
||||
update_source(vault, raw_file)
|
||||
sources = _load_raw(vault)["sources"]
|
||||
assert len(sources) == 2, "duplicate-path entries must survive"
|
||||
# First match updated; the second occasion is left intact.
|
||||
assert sources[0]["content_hash"] == f"sha256:{compute_hash(raw_file)}"
|
||||
assert sources[1]["content_hash"] == "sha256:v2"
|
||||
|
||||
|
||||
def test_update_appends_new_list_entry(vault, raw_file):
|
||||
_write_list_manifest(vault, [{"path": "_raw/other.md", "content_hash": "sha256:z"}])
|
||||
update_source(vault, raw_file)
|
||||
sources = _load_raw(vault)["sources"]
|
||||
assert isinstance(sources, list)
|
||||
# A new in-vault source is keyed vault-relative (source key contract v2),
|
||||
# not by the machine absolute path, so the vault stays portable.
|
||||
assert {e["path"] for e in sources} == {"_raw/other.md", "_raw/foo.md"}
|
||||
|
||||
|
||||
def test_top_level_keys_preserved(vault, raw_file):
|
||||
_manifest_path(vault).write_text(
|
||||
json.dumps({"version": 1, "stats": {"total_sources_ingested": 5}, "sources": []}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
update_source(vault, raw_file)
|
||||
manifest = _load_raw(vault)
|
||||
assert manifest["version"] == 1
|
||||
assert manifest["stats"]["total_sources_ingested"] == 5
|
||||
|
||||
|
||||
# Non-file keys (URLs / pseudo-paths) must not be flagged missing -----------
|
||||
|
||||
def test_list_manifest_rekeys_stale_raw_to_archived(vault):
|
||||
staging = vault / "_raw" / "foo.md"
|
||||
archived = vault / "_raw" / "_archived" / "foo.md"
|
||||
archived.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging.write_text("staging-bytes\n", encoding="utf-8")
|
||||
archived.write_text("archived-bytes\n", encoding="utf-8")
|
||||
_write_list_manifest(
|
||||
vault,
|
||||
[{"path": "_raw/foo.md", "content_hash": "sha256:old", "source_type": "document"}],
|
||||
)
|
||||
staging.unlink() # ingest moved the draft into _archived/
|
||||
h = update_source(vault, staging)
|
||||
assert h == compute_hash(archived)
|
||||
entries = _load_raw(vault)["sources"]
|
||||
assert isinstance(entries, list)
|
||||
paths = [e.get("path") for e in entries]
|
||||
assert paths == ["_raw/_archived/foo.md"]
|
||||
assert entries[0]["source_type"] == "document"
|
||||
|
||||
|
||||
def test_url_source_key_not_flagged_missing(vault):
|
||||
_write_list_manifest(
|
||||
vault,
|
||||
[{"path": "https://example.com/x", "content_hash": "sha256:x", "source_type": "repository"}],
|
||||
)
|
||||
result = check_sources(vault, [])
|
||||
assert result["missing"] == []
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Tests for the wiki clock (`obsidian_wiki.clock`) and the writers that use it.
|
||||
|
||||
The guarantees: a vault without ``timezone:`` gets byte-identical stamps to
|
||||
before (UTC, ``Z``); a vault with one gets that zone's own offset on every
|
||||
stamp, correct across a daylight-saving transition; bare dates are calendar
|
||||
days in the zone; a misspelled zone fails the write instead of silently
|
||||
writing UTC; and the docs name the knob.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from obsidian_wiki import clock
|
||||
from obsidian_wiki import memory as mem
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DETROIT = ZoneInfo("America/Detroit")
|
||||
Z_STAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
|
||||
OFFSET_STAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$")
|
||||
DETROIT_STAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}-0[45]:00$")
|
||||
BARE_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
|
||||
def _index(vault: Path, *extra: str) -> Path:
|
||||
lines = ["---", "title: Wiki Index", *extra, "generated_by: obsidian-wiki memory index", "---", "", "# Wiki Index", ""]
|
||||
path = vault / "index.md"
|
||||
path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def vault(tmp_path: Path) -> Path:
|
||||
for name in ("concepts", "_meta"):
|
||||
(tmp_path / name).mkdir()
|
||||
(tmp_path / "concepts" / "attention.md").write_text(
|
||||
"---\ntitle: Attention\nsummary: Core block\ntags: [ml]\nupdated: 2026-09-01\n---\n\n# Attention\n\nBody.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
_index(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def detroit_vault(vault: Path) -> Path:
|
||||
_index(vault, "timezone: America/Detroit")
|
||||
return vault
|
||||
|
||||
|
||||
def _cli(*args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "obsidian_wiki.cli", "memory", *args],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# pure functions
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParse:
|
||||
def test_empty_and_utc_mean_the_default(self) -> None:
|
||||
for name in ("", " ", "UTC", "utc", "Z", '"UTC"'):
|
||||
assert clock.parse_timezone(name) is None, name
|
||||
|
||||
def test_an_iana_name_resolves(self) -> None:
|
||||
assert clock.parse_timezone("America/Detroit") == DETROIT
|
||||
assert clock.parse_timezone("'Europe/Berlin'") == ZoneInfo("Europe/Berlin")
|
||||
|
||||
def test_an_unknown_name_fails_loudly(self) -> None:
|
||||
with pytest.raises(clock.TimezoneError, match="Mars/Olympus"):
|
||||
clock.parse_timezone("Mars/Olympus")
|
||||
assert issubclass(clock.TimezoneError, ValueError)
|
||||
|
||||
|
||||
class TestStamp:
|
||||
SUMMER = datetime(2026, 10, 10, 18, 40, 37, tzinfo=timezone.utc) # EDT
|
||||
WINTER = datetime(2026, 11, 2, 18, 40, 37, tzinfo=timezone.utc) # EST
|
||||
|
||||
def test_default_is_the_original_z_form(self) -> None:
|
||||
assert clock.stamp(None, now=self.SUMMER) == "2026-10-10T18:40:37Z"
|
||||
assert clock.compact(None, now=self.SUMMER) == "20261010T184037Z"
|
||||
assert clock.today(None, now=self.SUMMER) == "2026-10-10"
|
||||
|
||||
def test_a_zone_carries_its_own_offset_for_that_instant(self) -> None:
|
||||
assert clock.stamp(DETROIT, now=self.SUMMER) == "2026-10-10T14:40:37-04:00"
|
||||
assert clock.stamp(DETROIT, now=self.WINTER) == "2026-11-02T13:40:37-05:00"
|
||||
assert clock.compact(DETROIT, now=self.SUMMER) == "20261010T144037-0400"
|
||||
|
||||
def test_a_zone_input_is_fine_too(self) -> None:
|
||||
local = self.SUMMER.astimezone(ZoneInfo("Asia/Tokyo"))
|
||||
assert clock.stamp(DETROIT, now=local) == "2026-10-10T14:40:37-04:00"
|
||||
assert clock.stamp(None, now=local) == "2026-10-10T18:40:37Z"
|
||||
|
||||
def test_a_bare_date_is_the_calendar_day_in_the_zone(self) -> None:
|
||||
evening = datetime(2026, 10, 11, 0, 30, 0, tzinfo=timezone.utc) # 20:30 the 10th in Detroit
|
||||
assert clock.today(None, now=evening) == "2026-10-11"
|
||||
assert clock.today(DETROIT, now=evening) == "2026-10-10"
|
||||
|
||||
def test_live_stamps_have_the_expected_shape(self) -> None:
|
||||
assert Z_STAMP.match(clock.stamp(None))
|
||||
assert DETROIT_STAMP.match(clock.stamp(DETROIT))
|
||||
assert BARE_DATE.match(clock.today(DETROIT))
|
||||
|
||||
def test_a_naive_now_is_refused(self) -> None:
|
||||
with pytest.raises(ValueError, match="aware"):
|
||||
clock.stamp(DETROIT, now=datetime(2026, 10, 10, 12, 0, 0))
|
||||
|
||||
|
||||
class TestVaultLookup:
|
||||
def test_no_index_or_no_key_means_default(self, tmp_path: Path) -> None:
|
||||
assert clock.wiki_timezone(tmp_path) is None
|
||||
assert clock.timezone_name(tmp_path) == ""
|
||||
_index(tmp_path)
|
||||
assert clock.wiki_timezone(tmp_path) is None
|
||||
assert clock.timezone_name(tmp_path) == ""
|
||||
|
||||
def test_the_key_is_read_from_index_frontmatter(self, detroit_vault: Path) -> None:
|
||||
assert clock.wiki_timezone(detroit_vault) == DETROIT
|
||||
assert clock.timezone_name(detroit_vault) == "America/Detroit"
|
||||
|
||||
def test_quotes_are_tolerated(self, vault: Path) -> None:
|
||||
_index(vault, 'timezone: "Europe/Berlin"')
|
||||
assert clock.wiki_timezone(vault) == ZoneInfo("Europe/Berlin")
|
||||
|
||||
def test_the_key_in_the_body_does_not_count(self, vault: Path) -> None:
|
||||
(vault / "index.md").write_text(
|
||||
"---\ntitle: Wiki Index\n---\n\n# Wiki Index\n\ntimezone: America/Detroit\n", encoding="utf-8"
|
||||
)
|
||||
assert clock.wiki_timezone(vault) is None
|
||||
|
||||
def test_a_typo_raises(self, vault: Path) -> None:
|
||||
_index(vault, "timezone: America/Detroi")
|
||||
with pytest.raises(clock.TimezoneError):
|
||||
clock.wiki_timezone(vault)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the writers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWritersWithoutAZone:
|
||||
"""The regression guard: nothing changes for a vault that never set the key."""
|
||||
|
||||
def test_log_hot_adopted_and_tables_stay_utc_z(self, vault: Path) -> None:
|
||||
line = mem.append_log(vault, "INGEST", {"source": "x"})
|
||||
assert Z_STAMP.match(mem.parse_log_line(line).timestamp)
|
||||
hot = mem.rebuild_hot(vault, force=True)
|
||||
assert Z_STAMP.match(mem.parse_frontmatter(mem.split_frontmatter(hot.text)[0])["updated"])
|
||||
mem.mark_adopted(vault)
|
||||
adopted = (vault / mem.ADOPTED_REL).read_text(encoding="utf-8")
|
||||
assert re.search(r"^adopted: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", adopted, re.MULTILINE)
|
||||
todo = mem.add_todo(vault, "Ship the parser")
|
||||
assert BARE_DATE.match(todo.created) and todo.created == clock.today(None)
|
||||
fact = mem.set_fact(vault, "stack", "Python")
|
||||
assert fact.updated == clock.today(None)
|
||||
assert mem.memory_status(vault)["timezone"] == "UTC"
|
||||
|
||||
|
||||
class TestWritersWithAZone:
|
||||
def test_every_stamp_carries_the_zone_offset(self, detroit_vault: Path) -> None:
|
||||
vault = detroit_vault
|
||||
line = mem.append_log(vault, "INGEST", {"source": "x"})
|
||||
assert DETROIT_STAMP.match(mem.parse_log_line(line).timestamp), line
|
||||
hot = mem.rebuild_hot(vault, force=True)
|
||||
updated = mem.parse_frontmatter(mem.split_frontmatter(hot.text)[0])["updated"]
|
||||
assert DETROIT_STAMP.match(updated), updated
|
||||
mem.mark_adopted(vault)
|
||||
adopted = (vault / mem.ADOPTED_REL).read_text(encoding="utf-8")
|
||||
assert re.search(r"^adopted: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}-0[45]:00$", adopted, re.MULTILINE)
|
||||
|
||||
def test_bare_dates_are_the_zone_calendar_day(self, detroit_vault: Path) -> None:
|
||||
vault = detroit_vault
|
||||
todo = mem.add_todo(vault, "Ship the parser", origin="projects/x.md")
|
||||
assert todo.created == clock.today(DETROIT) == todo.touched
|
||||
closed = mem.set_todo_status(vault, todo.id, "done")
|
||||
assert closed.touched == clock.today(DETROIT)
|
||||
fact = mem.set_fact(vault, "stack", "Python")
|
||||
assert fact.updated == clock.today(DETROIT)
|
||||
table = (vault / "_meta" / "todos.md").read_text(encoding="utf-8")
|
||||
assert f"created: {clock.today(DETROIT)}" in table
|
||||
assert mem.memory_status(vault)["timezone"] == "America/Detroit"
|
||||
|
||||
def test_an_explicit_log_timestamp_still_wins(self, detroit_vault: Path) -> None:
|
||||
line = mem.append_log(detroit_vault, "INGEST", {}, timestamp="2026-01-01T00:00:00Z")
|
||||
assert line.startswith("- [2026-01-01T00:00:00Z] INGEST")
|
||||
|
||||
def test_rebuilding_the_index_keeps_the_key(self, detroit_vault: Path) -> None:
|
||||
mem.rebuild_index(detroit_vault, force=True)
|
||||
assert clock.timezone_name(detroit_vault) == "America/Detroit"
|
||||
text = (detroit_vault / "index.md").read_text(encoding="utf-8")
|
||||
assert text.count("timezone:") == 1
|
||||
|
||||
def test_the_migration_backup_uses_the_zone(self, detroit_vault: Path) -> None:
|
||||
(detroit_vault / "hot.md").write_text("# Hot\n\n## Key Takeaways\n\nhand-written\n", encoding="utf-8")
|
||||
result = mem.migrate(detroit_vault)
|
||||
assert re.search(r"pre-memory-migration-\d{8}T\d{6}-0[45]00$", result["backup_dir"]), result["backup_dir"]
|
||||
|
||||
def test_a_typo_fails_the_write(self, vault: Path) -> None:
|
||||
_index(vault, "timezone: America/Detroi")
|
||||
with pytest.raises(clock.TimezoneError):
|
||||
mem.append_log(vault, "INGEST", {})
|
||||
assert not (vault / "log.md").exists()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the CLI
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_now_prints_the_stamp_to_write(self, detroit_vault: Path) -> None:
|
||||
result = _cli("now", "--vault", str(detroit_vault))
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert DETROIT_STAMP.match(result.stdout.strip()), result.stdout
|
||||
|
||||
def test_now_without_a_zone_is_utc_z(self, vault: Path) -> None:
|
||||
result = _cli("now", "--vault", str(vault))
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert Z_STAMP.match(result.stdout.strip()), result.stdout
|
||||
|
||||
def test_now_date_and_json(self, detroit_vault: Path) -> None:
|
||||
date = _cli("now", "--date", "--vault", str(detroit_vault))
|
||||
assert date.returncode == 0 and date.stdout.strip() == clock.today(DETROIT)
|
||||
as_json = _cli("now", "--json", "--vault", str(detroit_vault))
|
||||
assert as_json.returncode == 0, as_json.stderr
|
||||
import json
|
||||
payload = json.loads(as_json.stdout)
|
||||
assert set(payload) == {"timestamp", "date", "timezone"}
|
||||
assert payload["timezone"] == "America/Detroit"
|
||||
assert DETROIT_STAMP.match(payload["timestamp"]) and BARE_DATE.match(payload["date"])
|
||||
|
||||
def test_status_names_the_zone(self, detroit_vault: Path, vault: Path) -> None:
|
||||
result = _cli("status", "--vault", str(detroit_vault))
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "timezone: America/Detroit" in result.stdout
|
||||
|
||||
def test_a_typo_is_an_error_not_a_utc_stamp(self, vault: Path) -> None:
|
||||
_index(vault, "timezone: Americas/Detroit")
|
||||
result = _cli("now", "--vault", str(vault))
|
||||
assert result.returncode == 1
|
||||
assert "Americas/Detroit" in result.stderr
|
||||
assert result.stdout.strip() == ""
|
||||
|
||||
def test_sync_stamps_the_log_in_the_zone(self, detroit_vault: Path) -> None:
|
||||
mem.mark_adopted(detroit_vault)
|
||||
result = _cli("sync", "INGEST", "source=x", "--vault", str(detroit_vault))
|
||||
assert result.returncode == 0, result.stderr
|
||||
log = (detroit_vault / "log.md").read_text(encoding="utf-8")
|
||||
assert re.search(r"^- \[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}-0[45]:00\] INGEST", log, re.MULTILINE), log
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# docs
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# setup
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSetup:
|
||||
def test_set_timezone_inserts_replaces_and_validates(self, vault: Path) -> None:
|
||||
assert clock.set_timezone(vault, "America/Detroit") == "America/Detroit"
|
||||
text = (vault / "index.md").read_text(encoding="utf-8")
|
||||
assert text.startswith("---\ntitle: Wiki Index\ntimezone: America/Detroit\n"), text
|
||||
assert "# Wiki Index" in text and text.count("timezone:") == 1
|
||||
clock.set_timezone(vault, "Europe/Berlin")
|
||||
text = (vault / "index.md").read_text(encoding="utf-8")
|
||||
assert text.count("timezone:") == 1 and "timezone: Europe/Berlin" in text
|
||||
assert clock.set_timezone(vault, "") == "" and "Europe/Berlin" in (vault / "index.md").read_text()
|
||||
with pytest.raises(clock.TimezoneError):
|
||||
clock.set_timezone(vault, "America/Detroi")
|
||||
assert "Europe/Berlin" in (vault / "index.md").read_text(encoding="utf-8")
|
||||
|
||||
def test_set_timezone_on_an_index_without_frontmatter(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "index.md").write_text("# Wiki Index\n\n## Concepts\n", encoding="utf-8")
|
||||
clock.set_timezone(tmp_path, "America/Detroit")
|
||||
text = (tmp_path / "index.md").read_text(encoding="utf-8")
|
||||
assert text.startswith("---\ntitle: Wiki Index\ntimezone: America/Detroit\n---\n\n# Wiki Index\n"), text
|
||||
assert clock.wiki_timezone(tmp_path) == DETROIT
|
||||
@@ -0,0 +1,716 @@
|
||||
"""Tests for vault graph analysis: community detection, god nodes, surprising connections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from obsidian_wiki.graph_analysis import (
|
||||
analyse_vault,
|
||||
dead_ends,
|
||||
detect_communities_greedy,
|
||||
god_nodes,
|
||||
isolated,
|
||||
parse_vault_graph,
|
||||
surprising_connections,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures — synthetic vault
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def vault(tmp_path):
|
||||
v = tmp_path / "vault"
|
||||
v.mkdir()
|
||||
return v
|
||||
|
||||
|
||||
def _page(vault: Path, name: str, links: list[str], tags: list[str] | None = None) -> Path:
|
||||
"""Write a minimal wiki page with wikilinks."""
|
||||
lines = ["---", f"title: {name}"]
|
||||
if tags:
|
||||
lines.append(f"tags: [{', '.join(tags)}]")
|
||||
lines += ["---", f"# {name}", ""]
|
||||
lines += [f"[[{lnk}]]" for lnk in links]
|
||||
p = vault / f"{name}.md"
|
||||
p.write_text("\n".join(lines) + "\n")
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_vault(vault):
|
||||
"""
|
||||
A → B → C
|
||||
A → C
|
||||
D (isolated)
|
||||
E → F (dead-end cluster)
|
||||
"""
|
||||
_page(vault, "a", ["b", "c"], tags=["concepts"])
|
||||
_page(vault, "b", ["c"], tags=["concepts"])
|
||||
_page(vault, "c", [], tags=["references"])
|
||||
_page(vault, "d", [], tags=["entities"])
|
||||
_page(vault, "e", ["f"])
|
||||
_page(vault, "f", [])
|
||||
return vault
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_vault_graph
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseVaultGraph:
|
||||
def test_reads_wikilinks(self, simple_vault):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
assert "b" in outgoing["a"]
|
||||
assert "c" in outgoing["a"]
|
||||
|
||||
def test_all_pages_present_as_keys(self, simple_vault):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
assert set(outgoing.keys()) == {"a", "b", "c", "d", "e", "f"}
|
||||
|
||||
def test_reads_tags(self, simple_vault):
|
||||
_, tags = parse_vault_graph(simple_vault)
|
||||
assert "concepts" in tags.get("a", [])
|
||||
|
||||
def test_empty_vault(self, vault):
|
||||
outgoing, tags = parse_vault_graph(vault)
|
||||
assert outgoing == {}
|
||||
|
||||
def test_self_links_ignored(self, vault):
|
||||
_page(vault, "selfref", ["selfref"])
|
||||
outgoing, _ = parse_vault_graph(vault)
|
||||
assert "selfref" not in outgoing.get("selfref", [])
|
||||
|
||||
def test_links_to_nonexistent_pages_excluded(self, vault):
|
||||
_page(vault, "orphan", ["doesnotexist"])
|
||||
outgoing, _ = parse_vault_graph(vault)
|
||||
assert outgoing["orphan"] == []
|
||||
|
||||
def test_archive_wikilink_is_not_an_edge_to_same_stem(self, vault):
|
||||
_page(vault, "attention", ["other"])
|
||||
_page(vault, "other", ["attention"])
|
||||
arch = vault / "_raw" / "_archived" / "attention.md"
|
||||
arch.parent.mkdir(parents=True)
|
||||
arch.write_text("clip\n", encoding="utf-8")
|
||||
cited = vault / "cited.md"
|
||||
cited.write_text(
|
||||
"---\ntitle: cited\nsnapshots:\n - [[_raw/_archived/attention]]\n---\n# cited\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
outgoing, _ = parse_vault_graph(vault)
|
||||
assert "attention" not in outgoing.get("cited", [])
|
||||
|
||||
def test_archive_markdown_snapshot_is_not_an_edge_to_same_stem(self, vault):
|
||||
_page(vault, "attention", ["other"])
|
||||
_page(vault, "other", ["attention"])
|
||||
arch = vault / "_raw" / "_archived" / "attention.md"
|
||||
arch.parent.mkdir(parents=True)
|
||||
arch.write_text("clip\n", encoding="utf-8")
|
||||
cited = vault / "cited.md"
|
||||
cited.write_text(
|
||||
"---\ntitle: cited\nsnapshots:\n"
|
||||
' - "[attention](_raw/_archived/attention.md)"\n---\n# cited\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
outgoing, _ = parse_vault_graph(vault)
|
||||
assert "attention" not in outgoing.get("cited", [])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# god_nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGodNodes:
|
||||
def test_c_is_top_hub(self, simple_vault):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
result = god_nodes(outgoing, top_n=3)
|
||||
# c has 2 in-links (from a and b), so should be in top 3
|
||||
top_pages = {r["page"] for r in result}
|
||||
assert "c" in top_pages
|
||||
|
||||
def test_degree_sum(self, simple_vault):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
result = god_nodes(outgoing)
|
||||
for node in result:
|
||||
assert node["degree"] == node["in_degree"] + node["out_degree"]
|
||||
|
||||
def test_respects_top_n(self, simple_vault):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
result = god_nodes(outgoing, top_n=2)
|
||||
assert len(result) <= 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dead_ends / isolated
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDeadEndsIsolated:
|
||||
def test_dead_ends(self, simple_vault):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
de = dead_ends(outgoing)
|
||||
assert "c" in de
|
||||
assert "f" in de
|
||||
assert "a" not in de
|
||||
|
||||
def test_isolated(self, simple_vault):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
iso = isolated(outgoing)
|
||||
assert "d" in iso
|
||||
assert "a" not in iso
|
||||
assert "c" not in iso # c has incoming links so not isolated
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Community detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCommunityDetection:
|
||||
def test_returns_list_of_sets(self, simple_vault):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
comms = detect_communities_greedy(outgoing)
|
||||
assert isinstance(comms, list)
|
||||
for c in comms:
|
||||
assert isinstance(c, set)
|
||||
|
||||
def test_all_nodes_assigned(self, simple_vault):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
comms = detect_communities_greedy(outgoing)
|
||||
all_nodes = set(outgoing.keys())
|
||||
assigned = set()
|
||||
for c in comms:
|
||||
assigned |= c
|
||||
assert assigned == all_nodes
|
||||
|
||||
def test_no_overlap(self, simple_vault):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
comms = detect_communities_greedy(outgoing)
|
||||
seen = set()
|
||||
for c in comms:
|
||||
assert c.isdisjoint(seen), "Node appears in two communities"
|
||||
seen |= c
|
||||
|
||||
def test_connected_cluster_grouped(self, vault):
|
||||
# a-b-c tightly connected, x-y-z separate — should land in different communities
|
||||
_page(vault, "a", ["b", "c"])
|
||||
_page(vault, "b", ["a", "c"])
|
||||
_page(vault, "c", ["a", "b"])
|
||||
_page(vault, "x", ["y", "z"])
|
||||
_page(vault, "y", ["x", "z"])
|
||||
_page(vault, "z", ["x", "y"])
|
||||
outgoing, _ = parse_vault_graph(vault)
|
||||
comms = detect_communities_greedy(outgoing)
|
||||
# At least 2 communities
|
||||
assert len(comms) >= 2
|
||||
|
||||
def test_empty_graph(self, vault):
|
||||
outgoing, _ = parse_vault_graph(vault)
|
||||
comms = detect_communities_greedy(outgoing)
|
||||
assert comms == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Surprising connections
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSurprisingConnections:
|
||||
def test_cross_community_edge_found(self, vault):
|
||||
# Build two tight clusters with one bridge
|
||||
_page(vault, "a", ["b", "c", "x"])
|
||||
_page(vault, "b", ["a", "c"])
|
||||
_page(vault, "c", ["a", "b"])
|
||||
_page(vault, "x", ["y", "z"])
|
||||
_page(vault, "y", ["x", "z"])
|
||||
_page(vault, "z", ["x", "y"])
|
||||
outgoing, _ = parse_vault_graph(vault)
|
||||
comms = detect_communities_greedy(outgoing)
|
||||
sc = surprising_connections(outgoing, comms)
|
||||
sources = {s["source"] for s in sc}
|
||||
targets = {s["target"] for s in sc}
|
||||
assert sources | targets # at least one cross-community edge found
|
||||
|
||||
def test_no_intra_community_edges(self, vault):
|
||||
_page(vault, "a", ["b"])
|
||||
_page(vault, "b", ["a"])
|
||||
outgoing, _ = parse_vault_graph(vault)
|
||||
comms = [{"a", "b"}] # one community
|
||||
sc = surprising_connections(outgoing, comms)
|
||||
assert sc == []
|
||||
|
||||
def test_scores_positive(self, vault):
|
||||
_page(vault, "a", ["b", "x"])
|
||||
_page(vault, "b", ["a"])
|
||||
_page(vault, "x", ["y"])
|
||||
_page(vault, "y", ["x"])
|
||||
outgoing, _ = parse_vault_graph(vault)
|
||||
comms = detect_communities_greedy(outgoing)
|
||||
sc = surprising_connections(outgoing, comms)
|
||||
for item in sc:
|
||||
assert item["score"] > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# analyse_vault (integration)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAnalyseVault:
|
||||
def test_returns_all_keys(self, simple_vault):
|
||||
result = analyse_vault(simple_vault)
|
||||
assert set(result.keys()) == {
|
||||
"god_nodes", "bridges", "communities", "surprising_connections",
|
||||
"suggested_questions", "dead_ends", "isolated", "stats",
|
||||
}
|
||||
|
||||
def test_stats_correct(self, simple_vault):
|
||||
result = analyse_vault(simple_vault)
|
||||
assert result["stats"]["pages"] == 6
|
||||
assert result["stats"]["edges"] > 0
|
||||
|
||||
def test_empty_vault(self, vault):
|
||||
result = analyse_vault(vault)
|
||||
assert result["stats"]["pages"] == 0
|
||||
|
||||
def test_json_serialisable(self, simple_vault):
|
||||
result = analyse_vault(simple_vault)
|
||||
json.dumps(result) # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGraphAnalyseCLI:
|
||||
def test_outputs_json(self, simple_vault):
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "obsidian_wiki.cli", "graph-analyse", str(simple_vault)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
data = json.loads(proc.stdout)
|
||||
assert "god_nodes" in data
|
||||
|
||||
def test_pretty_flag(self, simple_vault):
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "obsidian_wiki.cli", "graph-analyse", str(simple_vault), "--pretty"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
assert "\n " in proc.stdout
|
||||
|
||||
def test_top_flag(self, simple_vault):
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "obsidian_wiki.cli", "graph-analyse", str(simple_vault), "--top", "3"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
data = json.loads(proc.stdout)
|
||||
assert len(data["god_nodes"]) <= 3
|
||||
|
||||
def test_missing_vault_exits_nonzero(self, tmp_path):
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "obsidian_wiki.cli", "graph-analyse", str(tmp_path / "nope")],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert proc.returncode != 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# graphify-derived algorithms: betweenness, cohesion, path, neighbourhood,
|
||||
# diff, suggested questions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from obsidian_wiki.graph_analysis import ( # noqa: E402
|
||||
betweenness_centrality,
|
||||
bridge_pages,
|
||||
cohesion_score,
|
||||
graph_diff,
|
||||
load_snapshot,
|
||||
neighborhood,
|
||||
shortest_path,
|
||||
snapshot,
|
||||
suggest_questions,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def barbell_vault(vault):
|
||||
"""Two triangles joined through a single bridge page `m`.
|
||||
a-b-c clique — m — x-y-z clique
|
||||
"""
|
||||
_page(vault, "a", ["b", "c", "m"], tags=["left"])
|
||||
_page(vault, "b", ["a", "c"], tags=["left"])
|
||||
_page(vault, "c", ["a", "b"], tags=["left"])
|
||||
_page(vault, "m", ["a", "x"], tags=["mid"])
|
||||
_page(vault, "x", ["m", "y", "z"], tags=["right"])
|
||||
_page(vault, "y", ["x", "z"], tags=["right"])
|
||||
_page(vault, "z", ["x", "y"], tags=["right"])
|
||||
return vault
|
||||
|
||||
|
||||
class TestBetweenness:
|
||||
def test_bridge_has_highest_betweenness(self, barbell_vault):
|
||||
outgoing, _ = parse_vault_graph(barbell_vault)
|
||||
bc = betweenness_centrality(outgoing)
|
||||
assert max(bc, key=bc.get) == "m"
|
||||
# leaf-of-clique nodes lie on no shortest paths
|
||||
assert bc["b"] == 0.0 and bc["y"] == 0.0
|
||||
|
||||
def test_normalised_range(self, barbell_vault):
|
||||
outgoing, _ = parse_vault_graph(barbell_vault)
|
||||
for v in betweenness_centrality(outgoing).values():
|
||||
assert 0.0 <= v <= 1.0
|
||||
|
||||
def test_matches_star_exact_value(self, vault):
|
||||
# Star: hub h with 4 leaves — hub betweenness = 1.0 normalised
|
||||
_page(vault, "h", ["p", "q", "r", "s"])
|
||||
for leaf in "pqrs":
|
||||
_page(vault, leaf, [])
|
||||
outgoing, _ = parse_vault_graph(vault)
|
||||
bc = betweenness_centrality(outgoing)
|
||||
assert bc["h"] == pytest.approx(1.0)
|
||||
|
||||
def test_bridge_pages_reports_connected_communities(self, barbell_vault):
|
||||
outgoing, _ = parse_vault_graph(barbell_vault)
|
||||
comms = detect_communities_greedy(outgoing)
|
||||
bridges = bridge_pages(outgoing, comms, top_n=3)
|
||||
assert bridges[0]["page"] == "m"
|
||||
assert bridges[0]["betweenness"] > 0
|
||||
assert "connects" in bridges[0]
|
||||
|
||||
def test_empty(self):
|
||||
assert betweenness_centrality({}) == {}
|
||||
assert bridge_pages({}, [], 5) == []
|
||||
|
||||
|
||||
class TestCohesion:
|
||||
def test_clique_is_one(self, barbell_vault):
|
||||
outgoing, _ = parse_vault_graph(barbell_vault)
|
||||
assert cohesion_score(outgoing, {"a", "b", "c"}) == 1.0
|
||||
|
||||
def test_no_internal_links_is_zero(self, simple_vault):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
assert cohesion_score(outgoing, {"d", "f", "c"}) == 0.0
|
||||
|
||||
def test_singleton_is_one(self, simple_vault):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
assert cohesion_score(outgoing, {"d"}) == 1.0
|
||||
|
||||
def test_analyse_includes_cohesion(self, barbell_vault):
|
||||
result = analyse_vault(barbell_vault)
|
||||
for c in result["communities"]:
|
||||
assert 0.0 <= c["cohesion"] <= 1.0
|
||||
|
||||
|
||||
class TestShortestPath:
|
||||
def test_finds_path_across_bridge(self, barbell_vault):
|
||||
outgoing, _ = parse_vault_graph(barbell_vault)
|
||||
path = shortest_path(outgoing, "b", "y")
|
||||
assert path[0] == "b" and path[-1] == "y"
|
||||
assert "m" in path
|
||||
assert len(path) == 5 # b-a-m-x-y
|
||||
|
||||
def test_directed_respects_link_direction(self, simple_vault):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
assert shortest_path(outgoing, "a", "c", directed=True) == ["a", "c"]
|
||||
assert shortest_path(outgoing, "c", "a", directed=True) is None
|
||||
assert shortest_path(outgoing, "c", "a") == ["c", "a"]
|
||||
|
||||
def test_unreachable_and_unknown(self, simple_vault):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
assert shortest_path(outgoing, "a", "d") is None
|
||||
assert shortest_path(outgoing, "a", "nope") is None
|
||||
assert shortest_path(outgoing, "a", "a") == ["a"]
|
||||
|
||||
def test_slug_normalisation(self, simple_vault):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
assert shortest_path(outgoing, "A", "B") == ["a", "b"]
|
||||
|
||||
|
||||
class TestNeighborhood:
|
||||
def test_depth_layers(self, barbell_vault):
|
||||
outgoing, _ = parse_vault_graph(barbell_vault)
|
||||
hits = neighborhood(outgoing, "m", depth=1)
|
||||
assert {h["page"] for h in hits} == {"a", "x"}
|
||||
hits2 = neighborhood(outgoing, "m", depth=2)
|
||||
assert {h["page"] for h in hits2} == {"a", "x", "b", "c", "y", "z"}
|
||||
assert all(h["depth"] in (1, 2) for h in hits2)
|
||||
|
||||
def test_incoming_blast_radius(self, simple_vault):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
# who would break if `c` were renamed? a and b link to it
|
||||
hits = neighborhood(outgoing, "c", depth=1, direction="in")
|
||||
assert {h["page"] for h in hits} == {"a", "b"}
|
||||
assert neighborhood(outgoing, "c", depth=1, direction="out") == []
|
||||
|
||||
def test_unknown_seed(self, simple_vault):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
assert neighborhood(outgoing, "nope") == []
|
||||
|
||||
|
||||
class TestGraphDiff:
|
||||
def test_added_removed(self):
|
||||
old = {"nodes": ["a", "b", "c"], "edges": [["a", "b"]]}
|
||||
new = {"nodes": ["a", "b", "d"], "edges": [["a", "b"], ["a", "d"], ["d", "b"]]}
|
||||
d = graph_diff(old, new)
|
||||
assert d["added_pages"] == ["d"]
|
||||
assert d["removed_pages"] == ["c"]
|
||||
assert ["a", "d"] in d["added_edges"]
|
||||
assert d["removed_edges"] == []
|
||||
assert d["summary"] == {"pages": 0, "edges": 2}
|
||||
|
||||
def test_newly_connected_and_lost_incoming(self):
|
||||
old = {"nodes": ["a", "b", "c"], "edges": [["a", "b"]]}
|
||||
new = {"nodes": ["a", "b", "c"], "edges": [["a", "c"]]}
|
||||
d = graph_diff(old, new)
|
||||
assert d["newly_connected"] == ["c"]
|
||||
assert d["lost_incoming"] == ["b"]
|
||||
|
||||
def test_snapshot_roundtrip_via_insights_comment(self, simple_vault, tmp_path):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
snap = snapshot(outgoing)
|
||||
insights = tmp_path / "_insights.md"
|
||||
insights.write_text("# Insights\n\nstuff\n\n<!-- GRAPH_SNAPSHOT: " + json.dumps(snap) + " -->\n")
|
||||
loaded = load_snapshot(insights)
|
||||
assert loaded == snap
|
||||
assert graph_diff(loaded, snap)["summary"] == {"pages": 0, "edges": 0}
|
||||
|
||||
def test_load_snapshot_missing(self, tmp_path):
|
||||
p = tmp_path / "x.md"
|
||||
p.write_text("no snapshot here")
|
||||
assert load_snapshot(p) is None
|
||||
assert load_snapshot(tmp_path / "absent.md") is None
|
||||
|
||||
def test_analyse_with_previous(self, simple_vault):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
prev = snapshot(outgoing)
|
||||
_page(simple_vault, "g", ["a"])
|
||||
result = analyse_vault(simple_vault, previous_snapshot=prev, include_snapshot=True)
|
||||
assert result["diff"]["added_pages"] == ["g"]
|
||||
assert "snapshot" in result
|
||||
|
||||
|
||||
class TestSuggestQuestions:
|
||||
def test_bridge_question_first(self, barbell_vault):
|
||||
outgoing, _ = parse_vault_graph(barbell_vault)
|
||||
comms = detect_communities_greedy(outgoing)
|
||||
labels = {i: f"c{i}" for i in range(len(comms))}
|
||||
bridges = bridge_pages(outgoing, comms)
|
||||
qs = suggest_questions(outgoing, comms, labels, bridges)
|
||||
assert qs
|
||||
assert qs[0]["type"] == "bridge_node" and "`m`" in qs[0]["question"]
|
||||
|
||||
def test_isolated_question(self, simple_vault):
|
||||
outgoing, _ = parse_vault_graph(simple_vault)
|
||||
qs = suggest_questions(outgoing, [], {}, [])
|
||||
assert any(q["type"] == "isolated_nodes" for q in qs)
|
||||
|
||||
def test_no_signal(self):
|
||||
qs = suggest_questions({"a": ["b"], "b": ["a"]}, [{"a", "b"}], {0: "x"}, [])
|
||||
assert qs == [] or qs[0]["type"] in ("no_signal", "isolated_nodes")
|
||||
|
||||
def test_included_in_analyse(self, barbell_vault):
|
||||
result = analyse_vault(barbell_vault)
|
||||
assert "suggested_questions" in result and "bridges" in result
|
||||
assert result["stats"]["density"] > 0
|
||||
|
||||
|
||||
class TestSurprisingDedup:
|
||||
def test_note_and_pair_dedup(self, vault):
|
||||
# Hub h in community 0 links to 3 different pages of community 1;
|
||||
# community 1 also has one link to community 2. Pair (1,2) must not
|
||||
# be pushed out by the three (0,1) edges.
|
||||
_page(vault, "h", ["p", "q", "r"])
|
||||
_page(vault, "p", ["q"]); _page(vault, "q", ["p"]); _page(vault, "r", ["p", "s"])
|
||||
_page(vault, "s", ["t"]); _page(vault, "t", ["s"])
|
||||
outgoing, _ = parse_vault_graph(vault)
|
||||
comms = [{"h"}, {"p", "q", "r"}, {"s", "t"}]
|
||||
sc = surprising_connections(outgoing, comms, top_n=2)
|
||||
assert all("note" in x for x in sc)
|
||||
pairs = {tuple(sorted(x["note"].replace("bridges community ", "").split(" -> community "))) for x in sc}
|
||||
assert pairs == {("0", "1"), ("1", "2")}
|
||||
|
||||
|
||||
class TestGraphAnalyseCLIQueryModes:
|
||||
def _run(self, *argv):
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "obsidian_wiki.cli", "graph-analyse", *argv],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
def test_path(self, barbell_vault):
|
||||
proc = self._run(str(barbell_vault), "--path", "b", "y")
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
data = json.loads(proc.stdout)
|
||||
assert data["hops"] == 4 and "m" in data["path"]
|
||||
|
||||
def test_around(self, barbell_vault):
|
||||
proc = self._run(str(barbell_vault), "--around", "m", "--depth", "1")
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
data = json.loads(proc.stdout)
|
||||
assert {p["page"] for p in data["pages"]} == {"a", "x"}
|
||||
|
||||
def test_diff_against_and_snapshot(self, simple_vault, tmp_path):
|
||||
proc = self._run(str(simple_vault), "--snapshot")
|
||||
snap = json.loads(proc.stdout)["snapshot"]
|
||||
prev = tmp_path / "_insights.md"
|
||||
prev.write_text("<!-- GRAPH_SNAPSHOT: " + json.dumps(snap) + " -->")
|
||||
_page(simple_vault, "new", ["a"])
|
||||
proc = self._run(str(simple_vault), "--diff-against", str(prev))
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert json.loads(proc.stdout)["diff"]["added_pages"] == ["new"]
|
||||
|
||||
|
||||
class TestUniqueCommunityLabels:
|
||||
def test_labels_disambiguated(self, vault):
|
||||
# two disconnected clusters both dominated by tag `x`
|
||||
_page(vault, "a", ["b"], tags=["x", "left"]); _page(vault, "b", ["a"], tags=["x"])
|
||||
_page(vault, "c", ["d"], tags=["x", "right"]); _page(vault, "d", ["c"], tags=["x"])
|
||||
result = analyse_vault(vault)
|
||||
labels = [c["label"] for c in result["communities"]]
|
||||
assert len(labels) == len(set(labels))
|
||||
assert "x" in labels and any(l.startswith("x/") for l in labels)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared page selection + betweenness cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from obsidian_wiki.graph_analysis import ( # noqa: E402
|
||||
CACHE_FILENAME,
|
||||
SKIP_ROOT_FILES,
|
||||
betweenness_cached,
|
||||
graph_fingerprint,
|
||||
is_wiki_page,
|
||||
iter_pages,
|
||||
)
|
||||
|
||||
|
||||
class TestPageSelection:
|
||||
def test_root_bookkeeping_excluded(self, vault):
|
||||
_page(vault, "a", ["b"]); _page(vault, "b", [])
|
||||
for name in SKIP_ROOT_FILES:
|
||||
(vault / f"{name}.md").write_text("---\ntitle: x\n---\n[[a]]\n")
|
||||
out, _ = parse_vault_graph(vault)
|
||||
assert set(out) == {"a", "b"}
|
||||
|
||||
def test_nested_index_is_a_real_page(self, vault):
|
||||
"""Only ROOT index/log/hot are bookkeeping — concepts/index.md is content."""
|
||||
(vault / "concepts").mkdir()
|
||||
(vault / "concepts" / "index.md").write_text("---\ntitle: Concept Index\n---\n[[a]]\n")
|
||||
_page(vault, "a", [])
|
||||
out, _ = parse_vault_graph(vault)
|
||||
assert "index" in out
|
||||
assert is_wiki_page(vault / "concepts" / "index.md", vault)
|
||||
assert not is_wiki_page(vault / "index.md", vault)
|
||||
|
||||
def test_skip_dirs_excluded(self, vault):
|
||||
for d in ("_raw", "_meta", "_readouts", "_staging"):
|
||||
(vault / d).mkdir()
|
||||
(vault / d / "note.md").write_text("---\ntitle: x\n---\n")
|
||||
_page(vault, "a", [])
|
||||
assert [p.stem for p in iter_pages(vault)] == ["a"]
|
||||
|
||||
def test_tool_owned_directories_excluded(self, vault):
|
||||
"""Dot-dirs are skipped outright (`.venv`, `.git`, `.trash`, …); the
|
||||
non-hidden dependency trees batch.py skips are skipped here too."""
|
||||
for d in (".venv/lib/site-packages/pkg", ".git/hooks", ".trash",
|
||||
"node_modules/pkg", "venv", "__pycache__"):
|
||||
(vault / d).mkdir(parents=True, exist_ok=True)
|
||||
(vault / d / "note.md").write_text("---\ntitle: x\n---\n")
|
||||
_page(vault, "a", [])
|
||||
assert [p.stem for p in iter_pages(vault)] == ["a"]
|
||||
assert not is_wiki_page(vault / ".venv" / "lib" / "site-packages" / "pkg" / "note.md", vault)
|
||||
assert not is_wiki_page(vault / "node_modules" / "pkg" / "note.md", vault)
|
||||
|
||||
def test_iter_pages_is_sorted(self, vault):
|
||||
for n in ("zebra", "alpha", "middle"):
|
||||
_page(vault, n, [])
|
||||
assert [p.stem for p in iter_pages(vault)] == ["alpha", "middle", "zebra"]
|
||||
|
||||
def test_path_outside_vault_is_not_a_page(self, vault, tmp_path):
|
||||
assert not is_wiki_page(tmp_path / "elsewhere.md", vault)
|
||||
|
||||
|
||||
class TestGraphFingerprint:
|
||||
def test_stable_across_calls(self, simple_vault):
|
||||
out, _ = parse_vault_graph(simple_vault)
|
||||
assert graph_fingerprint(out) == graph_fingerprint(out)
|
||||
|
||||
def test_changes_when_an_edge_changes(self, simple_vault):
|
||||
out, _ = parse_vault_graph(simple_vault)
|
||||
before = graph_fingerprint(out)
|
||||
_page(simple_vault, "e", ["f", "a"]) # add an edge
|
||||
out2, _ = parse_vault_graph(simple_vault)
|
||||
assert graph_fingerprint(out2) != before
|
||||
|
||||
def test_insensitive_to_key_order(self, simple_vault):
|
||||
out, _ = parse_vault_graph(simple_vault)
|
||||
shuffled = {k: out[k] for k in reversed(list(out))}
|
||||
assert graph_fingerprint(shuffled) == graph_fingerprint(out)
|
||||
|
||||
|
||||
class TestBetweennessCache:
|
||||
def test_matches_uncached(self, barbell_vault):
|
||||
out, _ = parse_vault_graph(barbell_vault)
|
||||
exact = betweenness_centrality(out)
|
||||
cached = betweenness_cached(out, barbell_vault)
|
||||
assert all(abs(exact[k] - cached[k]) < 1e-12 for k in exact)
|
||||
|
||||
def test_no_cache_file_for_small_vault(self, barbell_vault):
|
||||
"""Tiny graphs recompute in microseconds — don't litter the vault."""
|
||||
out, _ = parse_vault_graph(barbell_vault)
|
||||
betweenness_cached(out, barbell_vault)
|
||||
assert not (barbell_vault / CACHE_FILENAME).exists()
|
||||
|
||||
def test_none_vault_still_works(self, barbell_vault):
|
||||
out, _ = parse_vault_graph(barbell_vault)
|
||||
assert betweenness_cached(out, None) == betweenness_centrality(out)
|
||||
|
||||
def test_corrupt_cache_fails_open(self, barbell_vault):
|
||||
(barbell_vault / CACHE_FILENAME).write_text("{ not json")
|
||||
out, _ = parse_vault_graph(barbell_vault)
|
||||
assert betweenness_cached(out, barbell_vault) == betweenness_centrality(out)
|
||||
|
||||
def test_hit_is_used_when_present(self, barbell_vault):
|
||||
"""A planted cache entry is returned verbatim — proves the key matches."""
|
||||
out, _ = parse_vault_graph(barbell_vault)
|
||||
key = f"{graph_fingerprint(out)}:1"
|
||||
planted = {n: 0.5 for n in out}
|
||||
(barbell_vault / CACHE_FILENAME).write_text(
|
||||
json.dumps({"version": 1, "betweenness": {key: planted}})
|
||||
)
|
||||
assert betweenness_cached(out, barbell_vault) == planted
|
||||
|
||||
def test_stale_key_is_ignored(self, barbell_vault):
|
||||
out, _ = parse_vault_graph(barbell_vault)
|
||||
(barbell_vault / CACHE_FILENAME).write_text(
|
||||
json.dumps({"version": 1, "betweenness": {"someotherkey:1": {n: 9.0 for n in out}}})
|
||||
)
|
||||
assert betweenness_cached(out, barbell_vault) == betweenness_centrality(out)
|
||||
|
||||
def test_unwritable_vault_does_not_raise(self, barbell_vault, tmp_path):
|
||||
out, _ = parse_vault_graph(barbell_vault)
|
||||
assert betweenness_cached(out, tmp_path / "does" / "not" / "exist")
|
||||
|
||||
|
||||
class TestCommunityQuality:
|
||||
def test_barbell_splits_into_two(self, barbell_vault):
|
||||
"""A sequential label-propagation sweep collapses this into one group."""
|
||||
out, _ = parse_vault_graph(barbell_vault)
|
||||
comms = detect_communities_greedy(out)
|
||||
assert len(comms) == 2
|
||||
assert {"x", "y", "z"} in [set(c) for c in comms]
|
||||
|
||||
def test_deterministic_across_runs(self, barbell_vault):
|
||||
out, _ = parse_vault_graph(barbell_vault)
|
||||
sigs = {
|
||||
json.dumps(sorted(sorted(c) for c in detect_communities_greedy(out)))
|
||||
for _ in range(10)
|
||||
}
|
||||
assert len(sigs) == 1
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Links resolve the way Obsidian resolves them, across every graph consumer.
|
||||
|
||||
A vault of per-project sub-wikis has pages that share a name
|
||||
(`projects/*/index.md`, the same concept in two projects) and links written
|
||||
relative to their project. Before `obsidian_wiki/links.py`, graph-analyse,
|
||||
graph-query, and lint keyed pages by bare filename, so those pages merged into
|
||||
one node and project-relative links were reported missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from obsidian_wiki.graph_analysis import neighborhood, parse_vault_graph, shortest_path
|
||||
from obsidian_wiki.links import PageIndex, strip_code
|
||||
from obsidian_wiki.lint import lint_vault
|
||||
|
||||
|
||||
def _write(vault: Path, rel: str, body: str = "", frontmatter: str = "") -> None:
|
||||
path = vault / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
head = (
|
||||
f"---\ntitle: {path.stem}\ncategory: concepts\ntags: [a]\nsources: []\n"
|
||||
f"created: 2026-01-01\nupdated: 2026-01-01\nsummary: s\n{frontmatter}---\n\n"
|
||||
)
|
||||
path.write_text(head + body + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _two_projects(vault: Path) -> None:
|
||||
for name in ("alpha", "beta"):
|
||||
_write(vault, f"projects/{name}/index.md", f"[[concepts/plan]] [[decisions/{name}-choice]]")
|
||||
_write(vault, f"projects/{name}/concepts/plan.md", "See [[index]].")
|
||||
_write(vault, f"projects/{name}/decisions/{name}-choice.md", "[[../concepts/plan]]")
|
||||
_write(vault, "concepts/shared.md", "No collision here: [[projects/alpha/concepts/plan]]")
|
||||
|
||||
|
||||
# -- PageIndex --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unique_names_keep_their_bare_ids() -> None:
|
||||
index = PageIndex(["concepts/Vector Search.md", "entities/karpathy.md"])
|
||||
assert sorted(index.ids()) == ["karpathy", "vector-search"]
|
||||
assert index.resolve("Vector Search", "synthesis/x.md") == "vector-search"
|
||||
assert index.resolve("concepts/vector-search.md", None) == "vector-search"
|
||||
|
||||
|
||||
def test_shared_names_get_path_ids_and_resolve_to_the_nearest_page() -> None:
|
||||
index = PageIndex(["projects/a/concepts/plan.md", "projects/b/concepts/plan.md"])
|
||||
assert sorted(index.ids()) == ["projects/a/concepts/plan", "projects/b/concepts/plan"]
|
||||
assert index.resolve("plan", "projects/b/index.md") == "projects/b/concepts/plan"
|
||||
assert index.resolve("concepts/plan", "projects/a/index.md") == "projects/a/concepts/plan"
|
||||
assert index.resolve("../concepts/plan", "projects/b/decisions/d.md") == "projects/b/concepts/plan"
|
||||
assert not index.is_ambiguous("plan", "projects/a/index.md")
|
||||
assert index.is_ambiguous("plan", "synthesis/elsewhere.md") # nothing breaks the tie
|
||||
|
||||
|
||||
def test_relative_link_cannot_escape_the_vault() -> None:
|
||||
assert PageIndex(["a.md"]).resolve("../../a", "x/y.md") is None
|
||||
|
||||
|
||||
def test_strip_code_drops_fences_and_inline_spans_only() -> None:
|
||||
text = "Real [[a]]. Example `[[b]]`.\n```\n[[c]]\n```\nAfter [[d]]."
|
||||
kept = strip_code(text)
|
||||
assert "[[a]]" in kept and "[[d]]" in kept
|
||||
assert "[[b]]" not in kept and "[[c]]" not in kept
|
||||
|
||||
|
||||
# -- every consumer ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_graph_keeps_same_named_pages_apart(tmp_path: Path) -> None:
|
||||
_two_projects(tmp_path)
|
||||
outgoing, _ = parse_vault_graph(tmp_path)
|
||||
assert "projects/alpha/index" in outgoing and "projects/beta/index" in outgoing
|
||||
assert "index" not in outgoing
|
||||
assert sorted(outgoing["projects/beta/index"]) == ["beta-choice", "projects/beta/concepts/plan"]
|
||||
assert outgoing["projects/alpha/concepts/plan"] == ["projects/alpha/index"]
|
||||
assert outgoing["beta-choice"] == ["projects/beta/concepts/plan"]
|
||||
assert outgoing["shared"] == ["projects/alpha/concepts/plan"]
|
||||
|
||||
|
||||
def test_graph_queries_accept_a_bare_name_for_a_shared_page(tmp_path: Path) -> None:
|
||||
_two_projects(tmp_path)
|
||||
outgoing, _ = parse_vault_graph(tmp_path)
|
||||
assert shortest_path(outgoing, "beta-choice", "projects/beta/index") is not None
|
||||
assert neighborhood(outgoing, "plan", depth=1) # a bare shared name still finds a node
|
||||
|
||||
|
||||
|
||||
|
||||
def test_lint_resolves_project_relative_links_and_relationships(tmp_path: Path) -> None:
|
||||
_two_projects(tmp_path)
|
||||
_write(
|
||||
tmp_path,
|
||||
"projects/alpha/concepts/router.md",
|
||||
"Uses the plan.",
|
||||
frontmatter='relationships:\n - target: "[[concepts/plan]]"\n type: uses\n',
|
||||
)
|
||||
findings = lint_vault(tmp_path, require_trust_ledger=False)["findings"]
|
||||
assert findings["broken_links"] == []
|
||||
assert findings["typed_relationship_issues"] == []
|
||||
|
||||
|
||||
def test_lint_ignores_links_in_code_and_still_reports_real_breaks(tmp_path: Path) -> None:
|
||||
_write(tmp_path, "concepts/a.md", "Write `[[wikilinks]]` like this.\n\n```\n[[example]]\n```\n[[missing]]")
|
||||
broken = lint_vault(tmp_path, require_trust_ledger=False)["findings"]["broken_links"]
|
||||
assert broken == [{"page": "concepts/a.md", "target": "missing"}]
|
||||
|
||||
|
||||
def test_agent_instruction_files_are_not_pages(tmp_path: Path) -> None:
|
||||
_write(tmp_path, "concepts/a.md", "Conventions live in [[CLAUDE]].") # still a real link target
|
||||
for rel in ("AGENTS.md", "README.md", "projects/alpha/CLAUDE.md", "projects/alpha/AGENTS.md"):
|
||||
(tmp_path / rel).parent.mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / rel).write_text("# Instructions\n", encoding="utf-8")
|
||||
_write(tmp_path, "projects/alpha/README.md") # below the root a README can be a page
|
||||
|
||||
report = lint_vault(tmp_path, require_trust_ledger=False)
|
||||
assert report["stats"]["pages"] == 2
|
||||
assert "README" not in str(report["findings"]["missing_frontmatter"])
|
||||
assert report["findings"]["broken_links"] == []
|
||||
+1067
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,943 @@
|
||||
"""Tests for the shared memory writer (`obsidian_wiki.memory`).
|
||||
|
||||
These cover the guarantees the prose instructions could not make: that a
|
||||
parallel writer cannot drop an update, that the hot cache honours its word cap,
|
||||
that reconciling the index does not eat a human's own section, and that the
|
||||
profile and todo tables round-trip content containing table syntax.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from obsidian_wiki import memory as mem
|
||||
|
||||
|
||||
def _page(vault: Path, rel: str, **fields: str) -> Path:
|
||||
path = vault / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
header = "\n".join(f"{key}: {value}" for key, value in fields.items())
|
||||
path.write_text(f"---\n{header}\n---\n\n# {fields.get('title', rel)}\n\nBody.\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def vault(tmp_path: Path) -> Path:
|
||||
for name in ("concepts", "entities", "projects", "_meta"):
|
||||
(tmp_path / name).mkdir(parents=True)
|
||||
_page(tmp_path, "concepts/attention.md", title="Attention", summary="Core transformer block", tags="[ml, nlp]", updated="2026-09-01")
|
||||
_page(tmp_path, "entities/karpathy.md", title="Andrej Karpathy", summary="AI researcher", tags="[person]", updated="2026-09-10")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _cli(*args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "obsidian_wiki.cli", "memory", *args],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# frontmatter
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("indicator", [">", ">-", ">+", "|", "|-", "|+", ">2", ">-2", ">+2", "|2", "|-2", "|+2"])
|
||||
def test_frontmatter_folds_block_scalars(indicator: str) -> None:
|
||||
values = mem.parse_frontmatter(
|
||||
f"title: {indicator}\n A sample\n page title\n"
|
||||
f"summary: {indicator}\n\n First line: details\n \n second line\n\n"
|
||||
"tags: [example, test]\nupdated: 2026-09-26\n"
|
||||
)
|
||||
assert values == {
|
||||
"title": "A sample page title",
|
||||
"summary": "First line: details second line",
|
||||
"tags": ["example", "test"],
|
||||
"updated": "2026-09-26",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("indent", [" ", " ", " ", "\t"])
|
||||
def test_frontmatter_block_scalar_at_end_of_input(indent: str) -> None:
|
||||
assert mem.parse_frontmatter(f"summary: >-\n{indent}Last field") == {"summary": "Last field"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ending", ["", "\n", "\n \n\n", "\ntags: [test]\n"])
|
||||
def test_frontmatter_empty_block_scalar(ending: str) -> None:
|
||||
values = mem.parse_frontmatter("summary: >-" + ending)
|
||||
assert values["summary"] == ""
|
||||
if "tags:" in ending:
|
||||
assert values["tags"] == ["test"]
|
||||
|
||||
|
||||
def test_frontmatter_preserves_inline_scalars_and_lists() -> None:
|
||||
assert mem.parse_frontmatter(
|
||||
'title: ">-"\nsummary: > not a block scalar\n'
|
||||
'tags: [one, "two"]\nsources:\n - first.md\n - second.md\n'
|
||||
'relationships:\n - target: "[[concepts/example]]"\n type: contradicts\n'
|
||||
) == {
|
||||
"title": ">-",
|
||||
"summary": "> not a block scalar",
|
||||
"tags": ["one", "two"],
|
||||
"sources": ["first.md", "second.md"],
|
||||
"relationships": ['target: "[[concepts/example]]"', "type: contradicts"],
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# log.md
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_log_line_matches_the_documented_format(vault: Path) -> None:
|
||||
line = mem.append_log(vault, "INGEST", {"source": "papers/attention.pdf", "pages_created": 3})
|
||||
assert line.startswith("- [")
|
||||
entry = mem.parse_log_line(line)
|
||||
assert entry is not None
|
||||
assert entry.verb == "INGEST"
|
||||
assert entry.fields == {"source": "papers/attention.pdf", "pages_created": "3"}
|
||||
|
||||
|
||||
def test_log_quotes_values_containing_spaces(vault: Path) -> None:
|
||||
line = mem.append_log(vault, "QUERY", {"query": "how do transformers work", "result_pages": 4})
|
||||
assert 'query="how do transformers work"' in line
|
||||
assert mem.parse_log_line(line).fields["query"] == "how do transformers work"
|
||||
|
||||
|
||||
def test_log_rejects_a_lowercase_verb(vault: Path) -> None:
|
||||
with pytest.raises(mem.MemoryError_) as excinfo:
|
||||
mem.append_log(vault, "ingest something")
|
||||
assert excinfo.value.code == "bad_verb"
|
||||
|
||||
|
||||
def test_log_creates_the_file_and_never_rewrites_history(vault: Path) -> None:
|
||||
first = mem.append_log(vault, "INIT", {"pages": 2})
|
||||
mem.append_log(vault, "LINT", {"issues_found": 0})
|
||||
text = (vault / "log.md").read_text(encoding="utf-8")
|
||||
assert text.startswith("---\ntitle: Wiki Log\n---")
|
||||
assert first in text
|
||||
assert len(mem.read_log(vault)) == 2
|
||||
|
||||
|
||||
def test_log_filters_by_verb_and_limit(vault: Path) -> None:
|
||||
for index in range(5):
|
||||
mem.append_log(vault, "INGEST", {"n": index})
|
||||
mem.append_log(vault, "LINT", {"issues_found": 1})
|
||||
assert len(mem.read_log(vault, verbs=["INGEST"])) == 5
|
||||
assert len(mem.read_log(vault, limit=2)) == 2
|
||||
|
||||
|
||||
def test_parallel_writers_do_not_drop_a_log_line(vault: Path) -> None:
|
||||
"""The regression the lock exists for: wholesale rewrites lost updates."""
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
||||
list(pool.map(lambda n: mem.append_log(vault, "CAPTURE", {"n": n}), range(60)))
|
||||
entries = mem.read_log(vault, verbs=["CAPTURE"])
|
||||
assert len(entries) == 60
|
||||
assert {entry.fields["n"] for entry in entries} == {str(n) for n in range(60)}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# index.md
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_index_lists_every_page_under_its_category(vault: Path) -> None:
|
||||
result = mem.rebuild_index(vault)
|
||||
text = (vault / "index.md").read_text(encoding="utf-8")
|
||||
assert result.total == 2
|
||||
assert "## Concepts" in text and "## Entities" in text
|
||||
assert "[[concepts/attention]]" in text
|
||||
assert set(result.added) == {"concepts/attention", "entities/karpathy"}
|
||||
|
||||
|
||||
def test_index_keeps_the_space_after_the_opening_paren(vault: Path) -> None:
|
||||
"""The documented format rule — `(#tag)` breaks Obsidian tag parsing."""
|
||||
mem.rebuild_index(vault)
|
||||
text = (vault / "index.md").read_text(encoding="utf-8")
|
||||
assert "( #ml #nlp)" in text
|
||||
assert "(#ml" not in text
|
||||
|
||||
|
||||
def test_index_honours_the_markdown_link_format(vault: Path) -> None:
|
||||
mem.rebuild_index(vault, link_format="markdown")
|
||||
text = (vault / "index.md").read_text(encoding="utf-8")
|
||||
assert "[Attention](concepts/attention.md)" in text
|
||||
assert "[[" not in text
|
||||
|
||||
|
||||
def test_index_round_trips_names_a_wikilink_cannot_carry(vault: Path) -> None:
|
||||
"""`]`, `#` and `|` cannot appear in a wikilink target, so such entries render
|
||||
as markdown links and the parser has to read them back as themselves (#294)."""
|
||||
_page(vault, "concepts/2026-06[final].md", title="2026-06[final]")
|
||||
_page(vault, "concepts/notes#1.md", title="notes#1")
|
||||
mem.rebuild_index(vault)
|
||||
|
||||
text = (vault / "index.md").read_text(encoding="utf-8")
|
||||
assert r"[2026-06[final\]](concepts/2026-06[final].md)" in text
|
||||
assert "[notes#1](concepts/notes#1.md)" in text
|
||||
assert "[[concepts/notes#1]]" not in text
|
||||
|
||||
result = mem.rebuild_index(vault, write=False)
|
||||
|
||||
assert result.added == ()
|
||||
assert result.removed == ()
|
||||
assert result.changed is False
|
||||
|
||||
|
||||
def test_index_drops_a_deleted_page_but_keeps_a_human_section(vault: Path) -> None:
|
||||
mem.rebuild_index(vault)
|
||||
index = vault / "index.md"
|
||||
index.write_text(index.read_text(encoding="utf-8") + "\n## Reading Queue\n\n- A book I mean to read\n", encoding="utf-8")
|
||||
(vault / "concepts/attention.md").unlink()
|
||||
|
||||
result = mem.rebuild_index(vault)
|
||||
text = index.read_text(encoding="utf-8")
|
||||
assert result.removed == ("concepts/attention",)
|
||||
assert "concepts/attention" not in text
|
||||
assert "A book I mean to read" in text
|
||||
|
||||
|
||||
def test_index_drops_a_stale_section_when_its_category_empties(vault: Path) -> None:
|
||||
"""Deriving categories from pages alone stranded the emptied section."""
|
||||
mem.rebuild_index(vault)
|
||||
(vault / "concepts/attention.md").unlink()
|
||||
mem.rebuild_index(vault)
|
||||
text = (vault / "index.md").read_text(encoding="utf-8")
|
||||
assert "## Concepts" not in text
|
||||
assert "## Entities" in text
|
||||
|
||||
|
||||
def test_index_drops_a_stale_uncategorized_section_and_reports_the_removal(vault: Path) -> None:
|
||||
"""`_heading_for(None)` renders "Uncategorized", but that heading was never
|
||||
in the generated-heading set, so a root-level page's stale entry survived
|
||||
as if hand-written and the removal never showed up in `removed`."""
|
||||
_page(vault, "loose-note.md", title="Loose Note")
|
||||
mem.rebuild_index(vault)
|
||||
(vault / "loose-note.md").unlink()
|
||||
|
||||
result = mem.rebuild_index(vault)
|
||||
text = (vault / "index.md").read_text(encoding="utf-8")
|
||||
assert "## Uncategorized" not in text
|
||||
assert "loose-note" not in text
|
||||
assert result.removed == ("loose-note",)
|
||||
|
||||
|
||||
def test_index_check_mode_reports_drift_without_writing(vault: Path) -> None:
|
||||
mem.rebuild_index(vault)
|
||||
before = (vault / "index.md").read_text(encoding="utf-8")
|
||||
_page(vault, "concepts/rag.md", title="RAG", summary="Retrieval augmented generation")
|
||||
result = mem.rebuild_index(vault, write=False)
|
||||
assert result.changed is True
|
||||
assert result.added == ("concepts/rag",)
|
||||
assert (vault / "index.md").read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
def test_index_ignores_staging_and_memory_files(vault: Path) -> None:
|
||||
_page(vault, "_raw/2026-09-21-scratch.md", title="Scratch")
|
||||
_page(vault, "_staging/concepts/pending.md", title="Pending")
|
||||
_page(vault, "_meta/taxonomy.md", title="Taxonomy")
|
||||
result = mem.rebuild_index(vault)
|
||||
text = (vault / "index.md").read_text(encoding="utf-8")
|
||||
assert result.total == 2
|
||||
for excluded in ("scratch", "pending", "taxonomy"):
|
||||
assert excluded not in text
|
||||
|
||||
|
||||
def test_index_drift_ignores_links_inside_an_entry_summary(vault: Path) -> None:
|
||||
"""Only an entry's own link says what the index lists. A wikilink in the
|
||||
page's summary text was counted too, so a summary pointing at a short-form
|
||||
or not-yet-written page reported a phantom removal on every rebuild."""
|
||||
_page(vault, "concepts/rag.md", title="RAG", summary='"See [[overview|the overview]] first"')
|
||||
mem.rebuild_index(vault)
|
||||
result = mem.rebuild_index(vault)
|
||||
assert result.removed == ()
|
||||
assert result.added == ()
|
||||
assert result.changed is False
|
||||
|
||||
|
||||
def test_index_is_idempotent(vault: Path) -> None:
|
||||
mem.rebuild_index(vault)
|
||||
first = (vault / "index.md").read_text(encoding="utf-8")
|
||||
second = mem.rebuild_index(vault)
|
||||
assert second.changed is False
|
||||
assert second.text == first
|
||||
|
||||
|
||||
def _sub_wiki(vault: Path) -> None:
|
||||
"""projects/alpha is a sub-wiki: its own index.md plus pages under it."""
|
||||
hub = vault / "projects/alpha/index.md"
|
||||
hub.parent.mkdir(parents=True, exist_ok=True)
|
||||
hub.write_text("# Alpha Project\n\nEverything about alpha.\n", encoding="utf-8")
|
||||
_page(vault, "projects/alpha/concepts/plan.md", title="Plan", summary="The plan")
|
||||
_page(vault, "projects/alpha/entities/bob.md", title="Bob", summary="A person")
|
||||
_page(vault, "projects/alpha/deep/index.md", title="Deep", summary="A nested hub")
|
||||
|
||||
|
||||
def _enable_hubs(vault: Path) -> None:
|
||||
(vault / "index.md").write_text(
|
||||
f"---\ntitle: Wiki Index\n{mem.FOLDER_HUBS_KEY}: true\n"
|
||||
f"generated_by: {mem.GENERATED_MARKER} index\n---\n\n# Wiki Index\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_index_lists_sub_wiki_pages_individually_by_default(vault: Path) -> None:
|
||||
_sub_wiki(vault)
|
||||
text = mem.rebuild_index(vault).text
|
||||
assert "[[projects/alpha/concepts/plan]]" in text
|
||||
assert "(3 pages)" not in text
|
||||
|
||||
|
||||
def test_folder_hubs_link_a_sub_wiki_once_instead_of_inlining_it(vault: Path) -> None:
|
||||
"""A vault of sub-wikis would otherwise inline every page of every one of
|
||||
them into the root index, which every skill reads at startup."""
|
||||
_sub_wiki(vault)
|
||||
_enable_hubs(vault)
|
||||
|
||||
result = mem.rebuild_index(vault)
|
||||
text = (vault / "index.md").read_text(encoding="utf-8")
|
||||
assert "- [[projects/alpha/index|Alpha Project]] — Everything about alpha. (3 pages)" in text
|
||||
for inlined in ("projects/alpha/concepts/plan", "projects/alpha/entities/bob", "projects/alpha/deep"):
|
||||
assert inlined not in text # including the nested hub: the outermost wins
|
||||
assert "[[concepts/attention]]" in text # pages outside a hub are unaffected
|
||||
assert result.total == 6
|
||||
assert "projects/alpha/index" in result.added
|
||||
assert f"{mem.FOLDER_HUBS_KEY}: true" in text # the switch survives the rebuild
|
||||
assert mem.rebuild_index(vault).changed is False
|
||||
|
||||
|
||||
def test_folder_hub_title_falls_back_to_the_folder_name(vault: Path) -> None:
|
||||
hub = vault / "projects/beta-tool/index.md"
|
||||
hub.parent.mkdir(parents=True)
|
||||
hub.write_text("Just a list of links.\n", encoding="utf-8")
|
||||
_enable_hubs(vault)
|
||||
assert "[[projects/beta-tool/index|Beta Tool]]" in mem.rebuild_index(vault).text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# hot.md
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_hot_has_the_four_documented_sections(vault: Path) -> None:
|
||||
mem.rebuild_hot(vault)
|
||||
text = (vault / "hot.md").read_text(encoding="utf-8")
|
||||
for heading in ("Recent Activity", "Active Threads", "Key Takeaways", "Flagged Contradictions"):
|
||||
assert f"## {heading}" in text
|
||||
assert "generated_by: obsidian-wiki memory hot" in text
|
||||
|
||||
|
||||
def test_hot_preserves_key_takeaways_across_a_rebuild(vault: Path) -> None:
|
||||
"""The one LLM-owned slot: mechanical rebuilds must not erase it."""
|
||||
mem.rebuild_hot(vault, takeaways="Retrieval is lexical only; precision is the weak metric.")
|
||||
mem.append_log(vault, "INGEST", {"source": "x"})
|
||||
mem.rebuild_hot(vault)
|
||||
assert "Retrieval is lexical only" in (vault / "hot.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_hot_replaces_takeaways_when_given_new_text(vault: Path) -> None:
|
||||
mem.rebuild_hot(vault, takeaways="Old conclusion.")
|
||||
mem.rebuild_hot(vault, takeaways="New conclusion.")
|
||||
text = (vault / "hot.md").read_text(encoding="utf-8")
|
||||
assert "New conclusion." in text and "Old conclusion." not in text
|
||||
|
||||
|
||||
def test_hot_enforces_the_word_cap(vault: Path) -> None:
|
||||
for index in range(50):
|
||||
mem.append_log(vault, "INGEST", {"source": f"file-{index}.pdf", "note": "a fairly long note value"})
|
||||
for index in range(20):
|
||||
mem.add_todo(vault, f"Thread number {index} with a reasonably long description")
|
||||
result = mem.rebuild_hot(vault, takeaways=" ".join(["takeaway"] * 400), max_words=200, activity_limit=40)
|
||||
assert result.trimmed is True
|
||||
assert result.words <= 200
|
||||
assert mem.content_words((vault / "hot.md").read_text(encoding="utf-8")) <= 200
|
||||
|
||||
|
||||
def test_hot_word_cap_excludes_frontmatter_and_boilerplate(vault: Path) -> None:
|
||||
"""Counting the header against the cap put the floor within a few words of it."""
|
||||
text = (vault / "hot.md")
|
||||
result = mem.rebuild_hot(vault)
|
||||
raw = text.read_text(encoding="utf-8")
|
||||
assert result.words == mem.content_words(raw)
|
||||
assert result.words < mem.word_count(raw)
|
||||
|
||||
|
||||
def test_hot_respects_the_env_override(vault: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("OBSIDIAN_HOT_MAX_WORDS", "80")
|
||||
assert mem.hot_max_words() == 80
|
||||
for index in range(30):
|
||||
mem.append_log(vault, "INGEST", {"source": f"file-{index}.pdf"})
|
||||
assert mem.rebuild_hot(vault, activity_limit=30).words <= 80
|
||||
|
||||
|
||||
def test_hot_falls_back_when_the_env_override_is_junk(vault: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("OBSIDIAN_HOT_MAX_WORDS", "not-a-number")
|
||||
assert mem.hot_max_words() == mem.DEFAULT_HOT_MAX_WORDS
|
||||
|
||||
|
||||
def test_hot_lists_open_threads_and_omits_closed_ones(vault: Path) -> None:
|
||||
open_todo = mem.add_todo(vault, "Wire the systemd timer")
|
||||
closed = mem.add_todo(vault, "Already finished")
|
||||
mem.set_todo_status(vault, closed.id, "done")
|
||||
mem.rebuild_hot(vault)
|
||||
text = (vault / "hot.md").read_text(encoding="utf-8")
|
||||
assert open_todo.text in text
|
||||
assert "Already finished" not in text
|
||||
|
||||
|
||||
def test_hot_flags_disputed_pages_and_contradiction_edges(vault: Path) -> None:
|
||||
_page(vault, "concepts/scaling.md", title="Scaling", lifecycle="disputed", updated="2026-09-11")
|
||||
(vault / "concepts/rebuttal.md").write_text(
|
||||
"---\ntitle: Rebuttal\nupdated: 2026-09-12\nrelationships:\n"
|
||||
' - target: "[[concepts/scaling]]"\n type: contradicts\n---\n\nBody.\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
mem.rebuild_hot(vault)
|
||||
text = (vault / "hot.md").read_text(encoding="utf-8")
|
||||
assert "marked disputed" in text
|
||||
assert "contradicts [[concepts/scaling]]" in text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# profile
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_profile_round_trips_a_fact(vault: Path) -> None:
|
||||
mem.set_fact(vault, "stack", "Python, FastAPI", confidence=0.85, source="session:abc")
|
||||
facts = mem.load_profile(vault)
|
||||
assert len(facts) == 1
|
||||
assert facts[0].key == "stack"
|
||||
assert facts[0].value == "Python, FastAPI"
|
||||
assert facts[0].confidence == pytest.approx(0.85)
|
||||
assert facts[0].source == "session:abc"
|
||||
|
||||
|
||||
def test_profile_round_trips_a_value_containing_a_pipe(vault: Path) -> None:
|
||||
"""A raw `|` would tear the markdown row into extra cells."""
|
||||
mem.set_fact(vault, "shell", "bash | zsh")
|
||||
assert mem.load_profile(vault)[0].value == "bash | zsh"
|
||||
|
||||
|
||||
def test_profile_set_replaces_rather_than_duplicates(vault: Path) -> None:
|
||||
mem.set_fact(vault, "editor", "vim", confidence=0.5)
|
||||
mem.set_fact(vault, "EDITOR", "neovim", confidence=0.9)
|
||||
facts = mem.load_profile(vault)
|
||||
assert len(facts) == 1
|
||||
assert facts[0].value == "neovim"
|
||||
|
||||
|
||||
def test_profile_rejects_confidence_outside_the_unit_interval(vault: Path) -> None:
|
||||
for bad in (-0.1, 1.5):
|
||||
with pytest.raises(mem.MemoryError_) as excinfo:
|
||||
mem.set_fact(vault, "k", "v", confidence=bad)
|
||||
assert excinfo.value.code == "bad_confidence"
|
||||
|
||||
|
||||
def test_profile_forget_reports_whether_it_removed_anything(vault: Path) -> None:
|
||||
mem.set_fact(vault, "timezone", "Asia/Kolkata")
|
||||
assert mem.forget_fact(vault, "timezone") is True
|
||||
assert mem.forget_fact(vault, "timezone") is False
|
||||
assert mem.load_profile(vault) == []
|
||||
|
||||
|
||||
def test_profile_survives_a_hand_edit_in_obsidian(vault: Path) -> None:
|
||||
mem.set_fact(vault, "stack", "Python")
|
||||
path = vault / mem.PROFILE_REL
|
||||
path.write_text(
|
||||
path.read_text(encoding="utf-8") + "| timezone | Asia/Kolkata | 0.90 | manual | 2026-09-21 |\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert {fact.key for fact in mem.load_profile(vault)} == {"stack", "timezone"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# todos
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_todo_add_assigns_sequential_ids(vault: Path) -> None:
|
||||
assert mem.add_todo(vault, "First").id == "t1"
|
||||
assert mem.add_todo(vault, "Second").id == "t2"
|
||||
|
||||
|
||||
def test_todo_add_is_idempotent_for_an_open_thread(vault: Path) -> None:
|
||||
first = mem.add_todo(vault, "Wire the systemd timer")
|
||||
again = mem.add_todo(vault, "wire the SYSTEMD timer")
|
||||
assert first.id == again.id
|
||||
assert len(mem.load_todos(vault)) == 1
|
||||
|
||||
|
||||
def test_todo_status_transitions_and_prune(vault: Path) -> None:
|
||||
keep = mem.add_todo(vault, "Still open")
|
||||
done = mem.add_todo(vault, "Finished")
|
||||
dropped = mem.add_todo(vault, "Abandoned")
|
||||
mem.set_todo_status(vault, done.id, "done")
|
||||
mem.set_todo_status(vault, dropped.id, "dropped")
|
||||
assert mem.prune_todos(vault) == 2
|
||||
remaining = mem.load_todos(vault)
|
||||
assert [todo.id for todo in remaining] == [keep.id]
|
||||
|
||||
|
||||
def test_todo_rejects_an_unknown_id_and_status(vault: Path) -> None:
|
||||
todo = mem.add_todo(vault, "Something")
|
||||
with pytest.raises(mem.MemoryError_) as unknown:
|
||||
mem.set_todo_status(vault, "t99", "done")
|
||||
assert unknown.value.code == "no_such_todo"
|
||||
with pytest.raises(mem.MemoryError_) as bad:
|
||||
mem.set_todo_status(vault, todo.id, "maybe")
|
||||
assert bad.value.code == "bad_status"
|
||||
|
||||
|
||||
def test_todo_staleness_is_reported_not_enforced(vault: Path) -> None:
|
||||
mem.add_todo(vault, "Old thread")
|
||||
path = vault / mem.TODOS_REL
|
||||
old = (datetime.now(timezone.utc) - timedelta(days=45)).date().isoformat()
|
||||
path.write_text(path.read_text(encoding="utf-8").replace(mem.today(), old), encoding="utf-8")
|
||||
todo = mem.load_todos(vault)[0]
|
||||
assert todo.is_stale() is True
|
||||
assert todo.status == "open" # still open; staleness is a signal, not a close
|
||||
|
||||
|
||||
def test_closed_todos_are_never_stale(vault: Path) -> None:
|
||||
todo = mem.add_todo(vault, "Done long ago")
|
||||
mem.set_todo_status(vault, todo.id, "done")
|
||||
assert mem.load_todos(vault)[0].is_stale(days=0) is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# recap + status
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_recap_gathers_profile_threads_and_activity(vault: Path) -> None:
|
||||
mem.set_fact(vault, "stack", "Python")
|
||||
mem.add_todo(vault, "Persist the retrieval index")
|
||||
mem.append_log(vault, "INGEST", {"source": "papers/rag.pdf"})
|
||||
recap = mem.build_recap(vault)
|
||||
assert "**stack**: Python" in recap
|
||||
assert "Persist the retrieval index" in recap
|
||||
assert "INGEST" in recap
|
||||
|
||||
|
||||
def test_recap_drops_a_time_bound_fact_once_it_is_over(vault: Path) -> None:
|
||||
""""Exams this weekend" must not still be injected next month."""
|
||||
mem.set_fact(vault, "exams", "this weekend", until="2000-01-02")
|
||||
mem.set_fact(vault, "trip", "in Lisbon", until="2999-01-01")
|
||||
facts = {f.key: f for f in mem.load_profile(vault)}
|
||||
assert facts["exams"].until == "2000-01-02" and facts["exams"].is_expired()
|
||||
recap = mem.build_recap(vault)
|
||||
assert "exams" not in recap
|
||||
assert "**trip**: in Lisbon" in recap
|
||||
with pytest.raises(mem.MemoryError_) as excinfo:
|
||||
mem.set_fact(vault, "k", "v", until="next friday")
|
||||
assert excinfo.value.code == "bad_until"
|
||||
|
||||
|
||||
def test_recap_is_honest_about_an_empty_vault(vault: Path) -> None:
|
||||
assert "No vault memory recorded yet" in mem.build_recap(vault)
|
||||
|
||||
|
||||
def test_recap_suggests_a_first_step_until_the_vault_is_used(vault: Path) -> None:
|
||||
mem.append_log(vault, "INIT", {"vault_path": str(vault)})
|
||||
recap = mem.build_recap(vault)
|
||||
assert "## Getting started" in recap and "/wiki-capture" in recap
|
||||
mem.append_log(vault, "INGEST", {"source": "notes.md"})
|
||||
assert "Getting started" not in mem.build_recap(vault)
|
||||
assert "Getting started" not in mem.build_recap(vault, project="nomatch")
|
||||
|
||||
|
||||
def test_recap_filters_low_confidence_facts(vault: Path) -> None:
|
||||
mem.set_fact(vault, "guess", "maybe Postgres", confidence=0.2)
|
||||
mem.set_fact(vault, "certain", "Python", confidence=0.95)
|
||||
recap = mem.build_recap(vault, min_confidence=0.5)
|
||||
assert "certain" in recap and "guess" not in recap
|
||||
|
||||
|
||||
def test_recap_respects_its_word_budget(vault: Path) -> None:
|
||||
for index in range(60):
|
||||
mem.set_fact(vault, f"fact-{index}", f"a reasonably long value number {index}")
|
||||
assert mem.word_count(mem.build_recap(vault, max_words=100)) <= 101 # +1 for the ellipsis
|
||||
|
||||
|
||||
def test_status_reports_index_drift_and_hot_budget(vault: Path) -> None:
|
||||
status = mem.memory_status(vault)
|
||||
assert status["pages"] == 2
|
||||
assert status["index_drift"]["stale"] is True
|
||||
assert status["hot"]["generated"] is False
|
||||
|
||||
mem.rebuild_index(vault)
|
||||
mem.rebuild_hot(vault)
|
||||
status = mem.memory_status(vault)
|
||||
assert status["index_drift"]["stale"] is False
|
||||
assert status["hot"]["generated"] is True
|
||||
assert status["hot"]["over_budget"] is False
|
||||
|
||||
|
||||
def test_operations_refuse_a_missing_vault(tmp_path: Path) -> None:
|
||||
missing = tmp_path / "nope"
|
||||
for call in (
|
||||
lambda: mem.append_log(missing, "INGEST"),
|
||||
lambda: mem.rebuild_index(missing),
|
||||
lambda: mem.rebuild_hot(missing),
|
||||
lambda: mem.build_recap(missing),
|
||||
):
|
||||
with pytest.raises(mem.MemoryError_) as excinfo:
|
||||
call()
|
||||
assert excinfo.value.code == "vault_not_found"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CLI surface
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cli_status_and_sync_round_trip(vault: Path) -> None:
|
||||
stale = _cli("status", "--vault", str(vault), "--json")
|
||||
assert stale.returncode == 0
|
||||
assert '"stale": true' in stale.stdout
|
||||
|
||||
synced = _cli("sync", "--verb", "INGEST", "--field", "source=papers/rag.pdf", "--vault", str(vault))
|
||||
assert synced.returncode == 0
|
||||
assert "INGEST" in synced.stdout
|
||||
|
||||
after = _cli("status", "--vault", str(vault), "--json")
|
||||
assert '"stale": false' in after.stdout
|
||||
assert (vault / "index.md").is_file()
|
||||
assert (vault / "hot.md").is_file()
|
||||
|
||||
|
||||
def test_cli_index_check_exits_two_on_drift(vault: Path) -> None:
|
||||
assert _cli("index", "--vault", str(vault)).returncode == 0
|
||||
assert _cli("index", "--check", "--vault", str(vault)).returncode == 0
|
||||
_page(vault, "concepts/rag.md", title="RAG", summary="Retrieval augmented generation")
|
||||
drifted = _cli("index", "--check", "--vault", str(vault))
|
||||
assert drifted.returncode == 2
|
||||
assert "concepts/rag" in drifted.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", ["index", "sync"])
|
||||
@pytest.mark.parametrize("link_format", ["markdown", "wikilink"])
|
||||
def test_cli_indexes_block_scalar_titles_and_summaries(
|
||||
vault: Path, command: str, link_format: str, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_dir = vault / ".obsidian-wiki"
|
||||
config_dir.mkdir(parents=True)
|
||||
(config_dir / "config").write_text(f"OBSIDIAN_LINK_FORMAT={link_format}\n", encoding="utf-8")
|
||||
monkeypatch.setenv("HOME", str(vault))
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(config_dir.parent))
|
||||
_page(
|
||||
vault, "concepts/block-scalars.md",
|
||||
title=">-\n A sample\n page title",
|
||||
summary="|-\n A summary\n spanning two lines.",
|
||||
tags="[example]",
|
||||
)
|
||||
result = _cli(command, "--vault", str(vault))
|
||||
assert result.returncode == 0, result.stderr
|
||||
text = (vault / "index.md").read_text(encoding="utf-8")
|
||||
link = "[A sample page title](concepts/block-scalars.md)" if link_format == "markdown" else "[[concepts/block-scalars]]"
|
||||
assert f"- {link} — A summary spanning two lines. ( #example)" in text
|
||||
assert ">-" not in text and "|-" not in text
|
||||
assert _cli("index", "--check", "--vault", str(vault)).returncode == 0
|
||||
|
||||
|
||||
def test_cli_rejects_a_malformed_field(vault: Path) -> None:
|
||||
result = _cli("log", "INGEST", "--field", "no-equals-sign", "--vault", str(vault))
|
||||
assert result.returncode == 1
|
||||
assert "key=value" in result.stderr
|
||||
|
||||
|
||||
def test_cli_profile_and_todo_lifecycle(vault: Path) -> None:
|
||||
assert _cli("profile", "set", "stack", "Python, FastAPI", "--confidence", "0.8", "--vault", str(vault)).returncode == 0
|
||||
listed = _cli("profile", "list", "--vault", str(vault))
|
||||
assert "stack: Python, FastAPI" in listed.stdout
|
||||
|
||||
added = _cli("todo", "add", "Persist the retrieval index", "--vault", str(vault))
|
||||
assert added.returncode == 0
|
||||
assert _cli("todo", "done", "t1", "--vault", str(vault)).returncode == 0
|
||||
assert "no todos recorded" in _cli("todo", "list", "--vault", str(vault)).stdout
|
||||
assert "done" in _cli("todo", "list", "--all", "--vault", str(vault)).stdout
|
||||
|
||||
|
||||
def test_cli_forget_exits_one_when_nothing_was_removed(vault: Path) -> None:
|
||||
assert _cli("profile", "forget", "absent", "--vault", str(vault)).returncode == 1
|
||||
|
||||
|
||||
def test_cli_takeaways_can_be_piped_in(vault: Path) -> None:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "obsidian_wiki.cli", "memory", "hot", "--takeaways", "-", "--vault", str(vault)],
|
||||
input="Retrieval is lexical only.", capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "Retrieval is lexical only." in (vault / "hot.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_cli_recap_prints_the_injectable_block(vault: Path) -> None:
|
||||
_cli("profile", "set", "stack", "Python", "--vault", str(vault))
|
||||
recap = _cli("recap", "--vault", str(vault))
|
||||
assert recap.returncode == 0
|
||||
assert recap.stdout.startswith("# Vault memory")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# migration — adopting a vault that predates the writer
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _legacy(vault: Path) -> None:
|
||||
"""A hand-curated vault: custom index sections, narrative hot cache."""
|
||||
(vault / "index.md").write_text(
|
||||
"# Project Brain\n\nCentral index.\n\n"
|
||||
"## Project Index\n\n| Project | Repo |\n|---|---|\n| [[alpha]] | r |\n\n"
|
||||
"## Quick Reference\n\n```bash\ngh repo list\n```\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(vault / "hot.md").write_text(
|
||||
"---\nupdated: 2026-06-04T00:00:00Z\n---\n\n# Hot\n\n"
|
||||
"## Recent Activity (last 3 ops)\n\n1. **2026-06-04** — researched karpathy\n\n"
|
||||
"## Active Threads\n\n"
|
||||
"- **karpathy-llm-resources** — Full coverage of the educational ecosystem.\n"
|
||||
"- **tractorex** — domain-configurable document intelligence.\n\n"
|
||||
"## Key Takeaways\n\n- Build the mechanism before the abstraction.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
def test_the_guard_refuses_to_clobber_a_hand_curated_vault(vault: Path) -> None:
|
||||
_legacy(vault)
|
||||
for call in (lambda: mem.rebuild_index(vault), lambda: mem.rebuild_hot(vault)):
|
||||
with pytest.raises(mem.MemoryError_) as excinfo:
|
||||
call()
|
||||
assert excinfo.value.code == "unmigrated"
|
||||
assert "Project Brain" in (vault / "index.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_dry_runs_are_allowed_on_an_unmigrated_vault(vault: Path) -> None:
|
||||
_legacy(vault)
|
||||
assert mem.rebuild_index(vault, write=False, force=True).total == 2
|
||||
assert mem.migration_status(vault)["migrated"] is False
|
||||
|
||||
|
||||
def test_migration_preview_reports_what_would_change(vault: Path) -> None:
|
||||
_legacy(vault)
|
||||
status = mem.migration_status(vault)
|
||||
assert status["index"]["sections_preserved"] == ["Project Index", "Quick Reference"]
|
||||
assert status["hot"]["takeaways_carried"] is True
|
||||
assert set(status["hot"]["sections_regenerated"]) == {"Active Threads", "Recent Activity"}
|
||||
assert len(status["hot"]["threads_to_seed"]) == 2
|
||||
|
||||
|
||||
def test_migration_backs_up_before_writing(vault: Path) -> None:
|
||||
_legacy(vault)
|
||||
original = (vault / "index.md").read_text(encoding="utf-8")
|
||||
result = mem.migrate(vault)
|
||||
assert len(result["backup"]) == 2
|
||||
restored = Path(result["backup_dir"]) / "index.md"
|
||||
assert restored.read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
def test_migration_preserves_custom_sections_above_the_catalog(vault: Path) -> None:
|
||||
_legacy(vault)
|
||||
mem.migrate(vault)
|
||||
text = (vault / "index.md").read_text(encoding="utf-8")
|
||||
headings = re.findall(r"^##\s+(.+)$", text, re.MULTILINE)
|
||||
assert headings[:2] == ["Project Index", "Quick Reference"]
|
||||
assert "Concepts" in headings # catalog appended after
|
||||
assert "gh repo list" in text # the author's code block survives
|
||||
assert "# Project Brain" in text # and their preamble
|
||||
|
||||
|
||||
def test_migration_converts_narrative_threads_into_todos(vault: Path) -> None:
|
||||
"""The one thing the generator cannot reconstruct, so it is carried over."""
|
||||
_legacy(vault)
|
||||
result = mem.migrate(vault)
|
||||
assert len(result["threads_seeded"]) == 2
|
||||
texts = " ".join(todo.text for todo in mem.load_todos(vault))
|
||||
assert "karpathy" in texts and "tractorex" in texts
|
||||
assert "karpathy" in (vault / "hot.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_migration_never_overwrites_existing_todos(vault: Path) -> None:
|
||||
_legacy(vault)
|
||||
mem.add_todo(vault, "A thread I already had")
|
||||
result = mem.migrate(vault)
|
||||
assert result["threads_seeded"] == []
|
||||
assert [todo.text for todo in mem.load_todos(vault)] == ["A thread I already had"]
|
||||
|
||||
|
||||
def test_migration_marks_a_custom_preamble_so_it_is_not_asked_again(vault: Path) -> None:
|
||||
"""The marker has to land inside the author's own preamble, or the guard
|
||||
refuses forever and sync silently skips index and hot on every run."""
|
||||
_legacy(vault)
|
||||
mem.migrate(vault)
|
||||
assert mem.is_generated(vault / "index.md")
|
||||
mem.rebuild_index(vault) # must not raise now
|
||||
assert mem.rebuild_index(vault, write=False).changed is False
|
||||
|
||||
|
||||
def test_migration_is_idempotent(vault: Path) -> None:
|
||||
_legacy(vault)
|
||||
mem.migrate(vault)
|
||||
first = (vault / "index.md").read_text(encoding="utf-8")
|
||||
second = mem.migrate(vault)
|
||||
assert second["was_migrated"] is True
|
||||
assert (vault / "index.md").read_text(encoding="utf-8") == first
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# hot cache: log lines must not blow the budget
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_huge_log_line_is_summarised_not_pasted(vault: Path) -> None:
|
||||
"""A research ingest records every page it made in one field."""
|
||||
mem.append_log(vault, "WIKI_RESEARCH", {
|
||||
"source": "karpathy",
|
||||
"pages_created": 18,
|
||||
"note": " ".join(f"references/page-{n}" for n in range(40)),
|
||||
"extra": "dropped",
|
||||
})
|
||||
mem.rebuild_hot(vault)
|
||||
activity = mem._section_body((vault / "hot.md").read_text(encoding="utf-8"), "Recent Activity")
|
||||
assert len(activity.split()) < 25
|
||||
assert "page-39" not in activity
|
||||
assert "WIKI_RESEARCH" in activity
|
||||
|
||||
|
||||
def test_summarised_entries_keep_the_date_and_verb(vault: Path) -> None:
|
||||
entry = mem.parse_log_line(mem.format_log_line("INGEST", {"source": "a.pdf"}, timestamp="2026-09-21T10:00:00Z"))
|
||||
assert mem.summarize_log_entry(entry) == "- [2026-09-21] INGEST source=a.pdf"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# recap scoping
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_recap_scopes_threads_to_a_project(vault: Path) -> None:
|
||||
mem.add_todo(vault, "Ship the tractorex pipeline")
|
||||
mem.add_todo(vault, "Read the karpathy series")
|
||||
scoped = mem.build_recap(vault, project="tractorex")
|
||||
assert "tractorex" in scoped
|
||||
assert "karpathy" not in scoped
|
||||
|
||||
|
||||
def test_recap_matches_a_project_named_in_the_origin(vault: Path) -> None:
|
||||
mem.add_todo(vault, "Something unrelated in wording", origin="projects/tractorex.md")
|
||||
assert "Something unrelated" in mem.build_recap(vault, project="tractorex")
|
||||
|
||||
|
||||
def test_recap_falls_back_rather_than_claiming_no_history(vault: Path) -> None:
|
||||
"""An unmatched filter must not look like an empty vault."""
|
||||
mem.add_todo(vault, "Only thread")
|
||||
mem.append_log(vault, "INGEST", {"source": "a.pdf"})
|
||||
recap = mem.build_recap(vault, project="nothing-matches-this")
|
||||
assert "Only thread" in recap
|
||||
assert "INGEST" in recap
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# adoption record — survives a legacy skill rewriting hot.md
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_legacy_rewrite_of_hot_md_does_not_unadopt_the_vault(vault: Path) -> None:
|
||||
"""Sixteen skills still rewrite hot.md by hand. If that dropped the
|
||||
per-file marker and nothing else recorded adoption, the next sync would
|
||||
refuse as though the vault had never been migrated."""
|
||||
_legacy(vault)
|
||||
mem.migrate(vault)
|
||||
(vault / "hot.md").write_text(
|
||||
"---\nupdated: now\n---\n# Hot\n\n## Key Takeaways\n\nWritten by an old skill.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert not mem.is_generated(vault / "hot.md")
|
||||
assert mem.is_adopted(vault)
|
||||
result = mem.rebuild_hot(vault) # must not raise
|
||||
assert result.words > 0
|
||||
assert "Written by an old skill" in (vault / "hot.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_deleting_the_adoption_record_re_arms_the_guard(vault: Path) -> None:
|
||||
"""The documented way back: restore a curated file from the backup and
|
||||
delete the record. Both are needed — a regenerated file is legitimately
|
||||
ours and carries its own marker."""
|
||||
_legacy(vault)
|
||||
result = mem.migrate(vault)
|
||||
backup = Path(result["backup_dir"])
|
||||
(vault / "index.md").write_text((backup / "index.md").read_text(encoding="utf-8"), encoding="utf-8")
|
||||
(vault / mem.ADOPTED_REL).unlink()
|
||||
with pytest.raises(mem.MemoryError_) as excinfo:
|
||||
mem.rebuild_index(vault)
|
||||
assert excinfo.value.code == "unmigrated"
|
||||
|
||||
|
||||
|
||||
|
||||
def test_migrate_records_adoption(vault: Path) -> None:
|
||||
_legacy(vault)
|
||||
assert not mem.is_adopted(vault)
|
||||
mem.migrate(vault)
|
||||
assert mem.is_adopted(vault)
|
||||
assert mem.memory_status(vault)["migrated"] is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the short form — a verb and bare key=value pairs
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sync_takes_a_positional_verb_and_fields(vault: Path) -> None:
|
||||
"""The flag form ran to seven lines in the ingest skills."""
|
||||
result = _cli("sync", "INGEST", "source=papers/attention.pdf", "pages_created=3",
|
||||
"--vault", str(vault))
|
||||
assert result.returncode == 0, result.stderr
|
||||
entry = mem.read_log(vault, verbs=["INGEST"])[-1]
|
||||
assert entry.fields == {"source": "papers/attention.pdf", "pages_created": "3"}
|
||||
|
||||
|
||||
def test_log_takes_a_positional_verb_and_fields(vault: Path) -> None:
|
||||
assert _cli("log", "LINT", "issues_found=2", "orphans=1", "--vault", str(vault)).returncode == 0
|
||||
assert mem.read_log(vault, verbs=["LINT"])[-1].fields == {"issues_found": "2", "orphans": "1"}
|
||||
|
||||
|
||||
def test_the_old_flag_form_still_works(vault: Path) -> None:
|
||||
"""Back-compat: --verb/--field are documented as the older spelling."""
|
||||
assert _cli("log", "--verb", "DEDUP", "--field", "merged=2", "--vault", str(vault)).returncode == 0
|
||||
assert mem.read_log(vault, verbs=["DEDUP"])[-1].fields == {"merged": "2"}
|
||||
|
||||
|
||||
def test_both_forms_can_be_mixed(vault: Path) -> None:
|
||||
_cli("log", "QUERY", "query=how do transformers work", "--field", "result_pages=4",
|
||||
"--vault", str(vault))
|
||||
entry = mem.read_log(vault, verbs=["QUERY"])[-1]
|
||||
assert entry.fields == {"query": "how do transformers work", "result_pages": "4"}
|
||||
|
||||
|
||||
def test_a_value_may_contain_an_equals_sign(vault: Path) -> None:
|
||||
_cli("log", "INGEST", "source=https://x.test/a?b=c", "--vault", str(vault))
|
||||
assert mem.read_log(vault, verbs=["INGEST"])[-1].fields["source"] == "https://x.test/a?b=c"
|
||||
|
||||
|
||||
def test_sync_without_a_verb_still_reconciles(vault: Path) -> None:
|
||||
"""wiki-stage-commit's case: the log line was already written by the CLI."""
|
||||
result = _cli("sync", "--takeaways", "Committed 3 staged pages.", "--vault", str(vault))
|
||||
assert result.returncode == 0
|
||||
assert mem.read_log(vault) == []
|
||||
assert "Committed 3 staged pages." in (vault / "hot.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_a_bare_word_after_the_verb_is_a_clear_error(vault: Path) -> None:
|
||||
result = _cli("sync", "INGEST", "oops", "--vault", str(vault))
|
||||
assert result.returncode == 1
|
||||
assert "expected key=value" in result.stderr
|
||||
assert "INGEST" in result.stderr # says which verb it already has
|
||||
@@ -0,0 +1,220 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from obsidian_wiki.provenance import (
|
||||
archive_wikilink_relpath,
|
||||
expected_snapshots_for_page,
|
||||
invert_pages,
|
||||
parse_snapshots_field,
|
||||
prefer_archive_write_path,
|
||||
resolve_source_key,
|
||||
unwrap_snapshot_value,
|
||||
)
|
||||
|
||||
|
||||
def test_invert_unions_pages_produced_and_pages_created() -> None:
|
||||
sources = {
|
||||
"_raw/_archived/a.md": {
|
||||
"pages_produced": ["concepts/foo.md"],
|
||||
"pages_created": ["concepts/bar.md"],
|
||||
},
|
||||
"_raw/_archived/b.md": {"pages_produced": ["concepts/foo.md"]},
|
||||
}
|
||||
idx = invert_pages(sources)
|
||||
assert set(idx["concepts/foo.md"]) == {
|
||||
"_raw/_archived/a.md",
|
||||
"_raw/_archived/b.md",
|
||||
}
|
||||
assert idx["concepts/bar.md"] == ["_raw/_archived/a.md"]
|
||||
|
||||
|
||||
def test_invert_list_shaped_manifest() -> None:
|
||||
sources = [
|
||||
{
|
||||
"path": "_raw/_archived/a.md",
|
||||
"pages_produced": ["concepts/foo.md"],
|
||||
}
|
||||
]
|
||||
idx = invert_pages(sources)
|
||||
assert idx["concepts/foo.md"] == ["_raw/_archived/a.md"]
|
||||
|
||||
|
||||
def test_unwrap_wikilink_and_quotes() -> None:
|
||||
assert unwrap_snapshot_value("[[_raw/_archived/Old English]]") == (
|
||||
"_raw/_archived/Old English.md"
|
||||
)
|
||||
assert unwrap_snapshot_value('"_raw/_archived/a.md"') == "_raw/_archived/a.md"
|
||||
assert unwrap_snapshot_value(
|
||||
'"[a](_raw/_archived/a.md)"'
|
||||
) == "_raw/_archived/a.md"
|
||||
assert unwrap_snapshot_value(
|
||||
"[clip](_raw/_archived/topic/clip.md)"
|
||||
) == "_raw/_archived/topic/clip.md"
|
||||
assert unwrap_snapshot_value(
|
||||
"[[_raw/_archived/foo|foo]]"
|
||||
) == "_raw/_archived/foo.md"
|
||||
assert unwrap_snapshot_value("_raw/_archived/foo|foo") == (
|
||||
"_raw/_archived/foo.md"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_stale_raw_to_archived(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
archived = vault / "_raw" / "_archived" / "notes.md"
|
||||
archived.parent.mkdir(parents=True)
|
||||
archived.write_text("# notes\n", encoding="utf-8")
|
||||
assert resolve_source_key(vault, "_raw/notes.md") == "_raw/_archived/notes.md"
|
||||
|
||||
|
||||
def test_resolve_url_unique_clip(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
clip = vault / "_raw" / "_archived" / "clip.md"
|
||||
clip.parent.mkdir(parents=True)
|
||||
clip.write_text("---\nurl: https://example.com/x\n---\n", encoding="utf-8")
|
||||
assert resolve_source_key(vault, "url:https://example.com/x") == (
|
||||
"_raw/_archived/clip.md"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_url_ambiguous_returns_none(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
arch = vault / "_raw" / "_archived"
|
||||
arch.mkdir(parents=True)
|
||||
(arch / "a.md").write_text("---\nurl: https://dup.example/\n---\n", encoding="utf-8")
|
||||
(arch / "b.md").write_text("---\nurl: https://dup.example/\n---\n", encoding="utf-8")
|
||||
assert resolve_source_key(vault, "url:https://dup.example/") is None
|
||||
|
||||
|
||||
def test_resolve_agent_key_is_none(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
assert resolve_source_key(vault, "agent:claude/abc") is None
|
||||
|
||||
|
||||
def test_expected_snapshots_skips_unresolvable_keys(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
(vault / "_raw" / "_archived").mkdir(parents=True)
|
||||
(vault / "_raw" / "_archived" / "a.md").write_text("x\n", encoding="utf-8")
|
||||
sources = {
|
||||
"_raw/_archived/a.md": {"pages_produced": ["concepts/foo.md"]},
|
||||
"url:https://no-clip.example/": {"pages_produced": ["concepts/foo.md"]},
|
||||
"agent:claude/abc": {"pages_produced": ["concepts/foo.md"]},
|
||||
}
|
||||
assert expected_snapshots_for_page(vault, "concepts/foo.md", sources) == [
|
||||
"_raw/_archived/a.md"
|
||||
]
|
||||
|
||||
|
||||
def test_parse_snapshots_block_list() -> None:
|
||||
raw = (
|
||||
"\n - [[_raw/_archived/a.md]]\n"
|
||||
" - _raw/_archived/b.md\n"
|
||||
' - "[c](_raw/_archived/c.md)"\n'
|
||||
)
|
||||
assert parse_snapshots_field(raw) == [
|
||||
"_raw/_archived/a.md",
|
||||
"_raw/_archived/b.md",
|
||||
"_raw/_archived/c.md",
|
||||
]
|
||||
|
||||
|
||||
def test_invert_ten_thousand_keys() -> None:
|
||||
sources = {
|
||||
f"_raw/_archived/doc-{i}.md": {
|
||||
"pages_produced": [f"concepts/p{i % 50}.md"]
|
||||
}
|
||||
for i in range(10_000)
|
||||
}
|
||||
idx = invert_pages(sources)
|
||||
assert len(idx) == 50
|
||||
assert len(idx["concepts/p0.md"]) == 200
|
||||
|
||||
|
||||
def test_prefer_keeps_nested_archived_when_flat_namesake_exists(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
nested = vault / "_raw" / "_archived" / "topic" / "clip.md"
|
||||
flat = vault / "_raw" / "_archived" / "clip.md"
|
||||
nested.parent.mkdir(parents=True)
|
||||
nested.write_text("nested\n", encoding="utf-8")
|
||||
flat.write_text("flat\n", encoding="utf-8")
|
||||
assert prefer_archive_write_path(vault, "_raw/_archived/topic/clip.md") == (
|
||||
"_raw/_archived/topic/clip.md"
|
||||
)
|
||||
|
||||
|
||||
def test_prefer_stale_raw_uses_flat_archived_basename(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
archived = vault / "_raw" / "_archived" / "notes.md"
|
||||
archived.parent.mkdir(parents=True)
|
||||
archived.write_text("x\n", encoding="utf-8")
|
||||
assert prefer_archive_write_path(vault, "_raw/notes.md") == "_raw/_archived/notes.md"
|
||||
|
||||
|
||||
def test_prefer_live_draft_over_same_named_archive(tmp_path: Path) -> None:
|
||||
# A new _raw/notes.md next to an old archived notes.md is a new draft.
|
||||
vault = tmp_path / "vault"
|
||||
archived = vault / "_raw" / "_archived" / "notes.md"
|
||||
archived.parent.mkdir(parents=True)
|
||||
archived.write_text("x\n", encoding="utf-8")
|
||||
(vault / "_raw" / "notes.md").write_text("staging\n", encoding="utf-8")
|
||||
assert prefer_archive_write_path(vault, "_raw/notes.md") == "_raw/notes.md"
|
||||
|
||||
|
||||
def test_archive_basename_only_matches_raw_keys(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
archived = vault / "_raw" / "_archived" / "README.md"
|
||||
archived.parent.mkdir(parents=True)
|
||||
archived.write_text("x\n", encoding="utf-8")
|
||||
(vault / "docs").mkdir()
|
||||
(vault / "docs" / "README.md").write_text("doc\n", encoding="utf-8")
|
||||
assert prefer_archive_write_path(vault, "docs/README.md") == "docs/README.md"
|
||||
assert resolve_source_key(vault, "docs/README.md") is None
|
||||
assert resolve_source_key(vault, "projects/x/README.md") is None
|
||||
|
||||
|
||||
def test_prefer_missing_returns_none(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
assert prefer_archive_write_path(vault, "_raw/_archived/ghost.md") is None
|
||||
|
||||
|
||||
def test_archive_wikilink_relpath_existing_and_missing(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
clip = vault / "_raw" / "_archived" / "foo.md"
|
||||
clip.parent.mkdir(parents=True)
|
||||
clip.write_text("x\n", encoding="utf-8")
|
||||
assert archive_wikilink_relpath(vault, "_raw/_archived/foo") == (
|
||||
"_raw/_archived/foo.md"
|
||||
)
|
||||
assert archive_wikilink_relpath(vault, "_raw/_archived/foo.md") == (
|
||||
"_raw/_archived/foo.md"
|
||||
)
|
||||
assert archive_wikilink_relpath(vault, "concepts/foo") is None
|
||||
missing = archive_wikilink_relpath(vault, "_raw/_archived/missing")
|
||||
assert missing == "_raw/_archived/missing.md"
|
||||
|
||||
|
||||
def test_prefer_live_staging_without_archive_returns_staging(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
staging = vault / "_raw" / "notes.md"
|
||||
staging.parent.mkdir(parents=True)
|
||||
staging.write_text("staging\n", encoding="utf-8")
|
||||
assert prefer_archive_write_path(vault, "_raw/notes.md") == "_raw/notes.md"
|
||||
|
||||
|
||||
def test_prefer_archive_write_path_rejects_traversal_and_absolute(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
outside = tmp_path / "outside.md"
|
||||
outside.write_text("secret\n", encoding="utf-8")
|
||||
assert prefer_archive_write_path(vault, "../outside.md") is None
|
||||
assert prefer_archive_write_path(vault, outside.as_posix()) is None
|
||||
|
||||
|
||||
def test_archive_wikilink_relpath_rejects_parent_traversal(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
assert archive_wikilink_relpath(vault, "_raw/_archived/../attention") is None
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Staged-write promotion: inventory, promote, discard, and the conflict path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from obsidian_wiki.staging import (
|
||||
StagingConflict,
|
||||
StagingError,
|
||||
discard,
|
||||
list_staged,
|
||||
log_decisions,
|
||||
promote,
|
||||
revision,
|
||||
)
|
||||
|
||||
|
||||
def write(vault, rel, body):
|
||||
path = vault / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(body, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vault(tmp_path):
|
||||
write(tmp_path, "concepts/live.md", "---\ntitle: Live\n---\n\noriginal\n")
|
||||
write(tmp_path, "_staging/concepts/live.md", "---\ntitle: Live\n---\n\nrevised\n")
|
||||
write(tmp_path, "_staging/concepts/fresh.md", "---\ntitle: Fresh\n---\n\nbrand new\n")
|
||||
write(tmp_path, "_staging/concepts/live.patch.md", "+ added\n- removed\n")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_listing_classifies_new_update_and_patch(vault):
|
||||
by_staged = {e.staged_path: e for e in list_staged(vault)}
|
||||
assert by_staged["_staging/concepts/fresh.md"].kind == "new"
|
||||
assert by_staged["_staging/concepts/live.md"].kind == "update"
|
||||
|
||||
patch = by_staged["_staging/concepts/live.patch.md"]
|
||||
assert patch.kind == "patch"
|
||||
# A patch targets the page it is named after, with the .patch infix dropped.
|
||||
assert patch.live_path == "concepts/live.md"
|
||||
|
||||
|
||||
def test_listing_pins_both_revisions(vault):
|
||||
entries = {e.staged_path: e for e in list_staged(vault)}
|
||||
update = entries["_staging/concepts/live.md"]
|
||||
assert update.staged_revision == revision(vault / "_staging/concepts/live.md")
|
||||
assert update.live_revision == revision(vault / "concepts/live.md")
|
||||
# There is no live page for a new one, and that absence is itself the revision.
|
||||
assert entries["_staging/concepts/fresh.md"].live_revision is None
|
||||
|
||||
|
||||
def test_empty_staging_lists_nothing(tmp_path):
|
||||
assert list_staged(tmp_path) == []
|
||||
|
||||
|
||||
def test_promoting_a_new_page_moves_it_live(vault):
|
||||
result = promote(vault, "concepts/fresh.md")
|
||||
assert result["kind"] == "new"
|
||||
assert (vault / "concepts/fresh.md").read_text(encoding="utf-8") == (
|
||||
"---\ntitle: Fresh\n---\n\nbrand new\n"
|
||||
)
|
||||
assert not (vault / "_staging/concepts/fresh.md").exists()
|
||||
|
||||
|
||||
def test_promoting_an_update_replaces_the_live_page(vault):
|
||||
assert promote(vault, "concepts/live.md")["kind"] == "update"
|
||||
assert "revised" in (vault / "concepts/live.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_promotion_preserves_arbitrary_frontmatter_byte_for_byte(vault):
|
||||
staged = write(
|
||||
vault,
|
||||
"_staging/references/odd.md",
|
||||
"---\ntitle: Odd\nhouse_style: {a: 1}\ntags: [x, y]\n---\n\nbody with spacing\n",
|
||||
)
|
||||
original = staged.read_bytes()
|
||||
promote(vault, "references/odd.md")
|
||||
# A rename, not a rewrite: nothing normalises frontmatter, prose or links.
|
||||
assert (vault / "references/odd.md").read_bytes() == original
|
||||
|
||||
|
||||
def test_promotion_accepts_either_path_spelling(vault):
|
||||
promote(vault, "_staging/concepts/fresh.md")
|
||||
assert (vault / "concepts/fresh.md").is_file()
|
||||
|
||||
|
||||
def test_promotion_refuses_a_patch(vault):
|
||||
with pytest.raises(StagingError, match="is a patch"):
|
||||
promote(vault, "concepts/live.patch.md")
|
||||
assert (vault / "_staging/concepts/live.patch.md").is_file()
|
||||
|
||||
|
||||
def test_promotion_refuses_when_the_live_page_moved_on(vault):
|
||||
reviewed = revision(vault / "concepts/live.md")
|
||||
# An agent rewrites the live page while a human is still reading the diff.
|
||||
write(vault, "concepts/live.md", "---\ntitle: Live\n---\n\nagent wrote this\n")
|
||||
|
||||
with pytest.raises(StagingConflict, match="changed since it was reviewed"):
|
||||
promote(vault, "concepts/live.md", expected_live_revision=reviewed)
|
||||
|
||||
assert "agent wrote this" in (vault / "concepts/live.md").read_text(encoding="utf-8")
|
||||
assert (vault / "_staging/concepts/live.md").is_file()
|
||||
|
||||
|
||||
def test_promotion_refuses_when_the_staged_file_moved_on(vault):
|
||||
reviewed = revision(vault / "_staging/concepts/fresh.md")
|
||||
write(vault, "_staging/concepts/fresh.md", "---\ntitle: Fresh\n---\n\nrestaged\n")
|
||||
|
||||
with pytest.raises(StagingConflict):
|
||||
promote(vault, "concepts/fresh.md", expected_staged_revision=reviewed)
|
||||
assert not (vault / "concepts/fresh.md").exists()
|
||||
|
||||
|
||||
def test_pin_live_absent_catches_a_page_that_appeared(vault):
|
||||
"""A bare expected_live_revision=None cannot say "there was no page"."""
|
||||
write(vault, "concepts/fresh.md", "---\ntitle: Fresh\n---\n\nsomeone got there first\n")
|
||||
|
||||
with pytest.raises(StagingConflict, match="was created since"):
|
||||
promote(vault, "concepts/fresh.md", pin_live_absent=True)
|
||||
assert "got there first" in (vault / "concepts/fresh.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_matching_revisions_promote_normally(vault):
|
||||
# Keyed by staged path: a page and its patch share one live_path.
|
||||
entry = {e.staged_path: e for e in list_staged(vault)}["_staging/concepts/live.md"]
|
||||
promote(
|
||||
vault,
|
||||
entry.staged_path,
|
||||
expected_staged_revision=entry.staged_revision,
|
||||
expected_live_revision=entry.live_revision,
|
||||
)
|
||||
assert "revised" in (vault / "concepts/live.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_discard_moves_the_file_to_raw(vault):
|
||||
result = discard(vault, "concepts/fresh.md")
|
||||
assert result["raw_path"] == "_raw/rejected-concepts-fresh.md"
|
||||
assert "brand new" in (vault / result["raw_path"]).read_text(encoding="utf-8")
|
||||
assert not (vault / "_staging/concepts/fresh.md").exists()
|
||||
|
||||
|
||||
def test_discarding_a_patch_is_labelled_as_one(vault):
|
||||
assert discard(vault, "concepts/live.patch.md")["raw_path"] == (
|
||||
"_raw/rejected-patch-concepts-live.md"
|
||||
)
|
||||
|
||||
|
||||
def test_a_second_rejection_does_not_clobber_the_first(vault):
|
||||
discard(vault, "concepts/fresh.md")
|
||||
write(vault, "_staging/concepts/fresh.md", "---\ntitle: Fresh\n---\n\nsecond attempt\n")
|
||||
|
||||
assert discard(vault, "concepts/fresh.md")["raw_path"] == "_raw/rejected-concepts-fresh-2.md"
|
||||
assert "brand new" in (vault / "_raw/rejected-concepts-fresh.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_emptied_staging_directories_are_pruned(vault):
|
||||
for rel in ("concepts/live.md", "concepts/fresh.md"):
|
||||
promote(vault, rel)
|
||||
discard(vault, "concepts/live.patch.md")
|
||||
assert not (vault / "_staging/concepts").exists()
|
||||
|
||||
|
||||
def test_a_traversing_path_is_refused_outright(vault):
|
||||
with pytest.raises(StagingError, match="escapes the vault"):
|
||||
promote(vault, "../../etc/passwd")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rel", ["/etc/passwd", "concepts/absent.md"])
|
||||
def test_odd_but_contained_paths_fail_as_missing(vault, rel):
|
||||
"""Anchoring under _staging/ and stripping a leading slash contains these."""
|
||||
with pytest.raises(StagingError, match="no staged file"):
|
||||
promote(vault, rel)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rel",
|
||||
["../concepts/live.md", "_staging/../concepts/live.md", "concepts/../../escape.md"],
|
||||
)
|
||||
def test_a_live_page_cannot_be_promoted_as_if_it_were_staged(vault, rel):
|
||||
"""Inside the vault is not enough — `..` reaches live pages from _staging/."""
|
||||
with pytest.raises(StagingError, match="not under _staging/"):
|
||||
promote(vault, rel)
|
||||
with pytest.raises(StagingError, match="not under _staging/"):
|
||||
discard(vault, rel)
|
||||
assert (vault / "concepts/live.md").read_text(encoding="utf-8").endswith("original\n")
|
||||
|
||||
|
||||
def test_missing_staged_file_is_reported(vault):
|
||||
with pytest.raises(StagingError, match="no staged file"):
|
||||
promote(vault, "concepts/absent.md")
|
||||
|
||||
|
||||
def test_log_records_one_line_per_batch(vault):
|
||||
log_decisions(vault, [promote(vault, "concepts/fresh.md"), discard(vault, "concepts/live.md")])
|
||||
assert "STAGE_COMMIT accepted=1 rejected=1" in (vault / "log.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_log_is_silent_when_nothing_happened(vault):
|
||||
log_decisions(vault, [])
|
||||
assert not (vault / "log.md").exists()
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Tests for bi-temporal page validity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from obsidian_wiki.lint import lint_vault
|
||||
from obsidian_wiki.temporal import (
|
||||
is_current,
|
||||
parse_date,
|
||||
superseded_target,
|
||||
validity_window,
|
||||
)
|
||||
|
||||
|
||||
def _page(vault: Path, relpath: str, *, extra: str = "", summary: str = "A page.") -> Path:
|
||||
path = vault / relpath
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = [
|
||||
"---",
|
||||
f"title: {path.stem}",
|
||||
"category: references",
|
||||
"tags: [test]",
|
||||
"sources: [manual]",
|
||||
"created: 2026-01-01",
|
||||
"updated: 2026-01-01",
|
||||
f"summary: {summary}",
|
||||
]
|
||||
if extra:
|
||||
lines.append(extra)
|
||||
lines += ["---", f"# {path.stem}"]
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
# --- parsing ---------------------------------------------------------------
|
||||
|
||||
def test_parse_date_accepts_dates_timestamps_and_empty() -> None:
|
||||
assert parse_date("2026-04-01") == date(2026, 4, 1)
|
||||
assert parse_date("2026-04-01T09:30:00+00:00") == date(2026, 4, 1)
|
||||
assert parse_date("2026-04-01T09:30:00Z") == date(2026, 4, 1)
|
||||
assert parse_date(' "2026-04-01" ') == date(2026, 4, 1)
|
||||
assert parse_date("") is None
|
||||
assert parse_date(None) is None
|
||||
|
||||
|
||||
def test_parse_date_rejects_nonsense() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
parse_date("last tuesday")
|
||||
|
||||
|
||||
def test_validity_window_rejects_inverted_window() -> None:
|
||||
with pytest.raises(ValueError, match="precedes valid_from"):
|
||||
validity_window({"valid_from": "2026-05-01", "valid_until": "2026-04-01"})
|
||||
|
||||
|
||||
def test_superseded_target_unwraps_wikilinks_aliases_and_paths() -> None:
|
||||
assert superseded_target("[[gateway-envoy]]") == "gateway-envoy"
|
||||
assert superseded_target('"[[references/gateway-envoy.md|Envoy]]"') == "gateway-envoy"
|
||||
assert superseded_target("[[gateway-envoy#Routes]]") == "gateway-envoy"
|
||||
assert superseded_target("") == ""
|
||||
|
||||
|
||||
# --- currency --------------------------------------------------------------
|
||||
|
||||
def test_page_without_temporal_fields_is_always_current() -> None:
|
||||
assert is_current({}, date(1999, 1, 1)) is True
|
||||
|
||||
|
||||
def test_valid_until_is_inclusive_and_valid_from_is_a_floor() -> None:
|
||||
window = {"valid_from": "2026-04-01", "valid_until": "2026-06-30"}
|
||||
assert is_current(window, date(2026, 3, 31)) is False # not yet true
|
||||
assert is_current(window, date(2026, 4, 1)) is True
|
||||
assert is_current(window, date(2026, 6, 30)) is True # last day counts
|
||||
assert is_current(window, date(2026, 7, 1)) is False # no longer true
|
||||
|
||||
|
||||
def test_malformed_dates_count_as_current_so_retrieval_never_silently_drops() -> None:
|
||||
assert is_current({"valid_until": "whenever"}, date(2026, 6, 1)) is True
|
||||
|
||||
|
||||
# --- retrieval -------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# --- lint ------------------------------------------------------------------
|
||||
|
||||
def test_lint_fails_on_malformed_temporal_metadata(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "references/inverted.md",
|
||||
extra="valid_from: 2026-05-01\nvalid_until: 2026-04-01")
|
||||
_page(vault, "references/garbage.md", extra="valid_until: soon")
|
||||
|
||||
report = lint_vault(vault, require_trust_ledger=False)
|
||||
|
||||
issues = {item["page"] for item in report["findings"]["temporal_errors"]}
|
||||
assert issues == {"references/inverted.md", "references/garbage.md"}
|
||||
assert report["status"] == "fail"
|
||||
|
||||
|
||||
def test_lint_reports_dangling_superseded_by(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "references/old.md", extra="superseded_by: \"[[does-not-exist]]\"")
|
||||
_page(vault, "references/self.md", extra="superseded_by: \"[[self]]\"")
|
||||
|
||||
report = lint_vault(vault, require_trust_ledger=False)
|
||||
|
||||
assert report["findings"]["temporal_errors"] == []
|
||||
assert report["findings"]["superseded_dangling"] == [
|
||||
{"page": "references/old.md", "target": "does-not-exist", "issue": "missing_target"},
|
||||
{"page": "references/self.md", "target": "self", "issue": "self_reference"},
|
||||
]
|
||||
# A bracketed value is a wikilink like any other, so the pre-existing
|
||||
# broken_links check fires too and the vault fails — same as a typed
|
||||
# `relationships:` target pointing at nothing.
|
||||
assert report["findings"]["broken_links"] == [
|
||||
{"page": "references/old.md", "target": "does-not-exist"}
|
||||
]
|
||||
assert report["status"] == "fail"
|
||||
|
||||
|
||||
def test_lint_warns_on_a_dangling_unbracketed_superseded_by(tmp_path: Path) -> None:
|
||||
"""Written as a plain name it isn't a wikilink, so only the specific
|
||||
finding fires and the vault warns rather than fails."""
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "references/old.md", extra="superseded_by: does-not-exist")
|
||||
|
||||
report = lint_vault(vault, require_trust_ledger=False)
|
||||
|
||||
assert report["findings"]["broken_links"] == []
|
||||
assert report["findings"]["superseded_dangling"] == [
|
||||
{"page": "references/old.md", "target": "does-not-exist", "issue": "missing_target"}
|
||||
]
|
||||
assert report["status"] == "warn"
|
||||
|
||||
|
||||
def test_lint_passes_a_valid_supersession_chain(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "references/old.md",
|
||||
extra="valid_until: 2026-03-31\nsuperseded_by: \"[[new]]\"")
|
||||
_page(vault, "references/new.md", extra="valid_from: 2026-04-01")
|
||||
|
||||
report = lint_vault(vault, require_trust_ledger=False)
|
||||
|
||||
assert report["findings"]["temporal_errors"] == []
|
||||
assert report["findings"]["superseded_dangling"] == []
|
||||
|
||||
|
||||
# --- CLI -------------------------------------------------------------------
|
||||
|
||||
def _run(*args: str) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(Path(__file__).parents[1])
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "obsidian_wiki.cli", *args],
|
||||
capture_output=True, check=False, text=True, env=env,
|
||||
)
|
||||
@@ -0,0 +1,883 @@
|
||||
"""Regression tests for manually reviewed confidence and relationship linting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from obsidian_wiki.lint import lint_vault
|
||||
from obsidian_wiki.trust import (
|
||||
build_trust_ledger,
|
||||
check_trust_ledger,
|
||||
iter_trust_pages,
|
||||
page_fingerprint,
|
||||
update_trust_ledger,
|
||||
write_trust_ledger,
|
||||
)
|
||||
|
||||
|
||||
def _page(
|
||||
vault: Path,
|
||||
relpath: str,
|
||||
*,
|
||||
confidence: float = 0.80,
|
||||
updated: str = "2026-07-12T17:38:39+07:00",
|
||||
body: str = "Reviewed material claim.",
|
||||
relationships: list[tuple[str, str]] | None = None,
|
||||
) -> Path:
|
||||
path = vault / relpath
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = [
|
||||
"---",
|
||||
f"title: {path.stem}",
|
||||
f"category: {path.parts[-2] if len(path.parts) > 1 else 'concepts'}",
|
||||
"tags: [test]",
|
||||
"sources:",
|
||||
" - github.com/example/repo@0123456789abcdef0123456789abcdef01234567",
|
||||
"created: 2026-07-01",
|
||||
f"updated: {updated}",
|
||||
f"base_confidence: {confidence:.2f}",
|
||||
"lifecycle: reviewed",
|
||||
]
|
||||
if relationships:
|
||||
lines.append("relationships:")
|
||||
for relation_type, target in relationships:
|
||||
lines.extend([f" - type: {relation_type}", f' target: "[[{target}]]"'])
|
||||
lines.extend(["---", f"# {path.stem}", "", body, ""])
|
||||
path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _write_ledger(vault: Path) -> Path:
|
||||
ledger = build_trust_ledger(vault, reviewed_at="2026-07-12T17:38:39+07:00")
|
||||
path = vault / "_meta" / "trust-ledger.json"
|
||||
write_trust_ledger(path, ledger)
|
||||
return path
|
||||
|
||||
|
||||
def _run_cli(home: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = str(home)
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "obsidian_wiki.cli", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def test_reviewed_ledger_is_authoritative_instead_of_reclassifying_sources(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md", confidence=0.53)
|
||||
_page(vault, "skills/beta.md", confidence=0.80)
|
||||
ledger_path = _write_ledger(vault)
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "pass"
|
||||
assert report["counts"] == {
|
||||
"reviewed": 2,
|
||||
"not_applicable": 0,
|
||||
"stale": 0,
|
||||
"unreviewed": 0,
|
||||
"score_mismatches": 0,
|
||||
"missing_pages": 0,
|
||||
"errors": 0,
|
||||
}
|
||||
assert {item["page"] for item in report["reviewed"]} == {
|
||||
"concepts/alpha.md",
|
||||
"skills/beta.md",
|
||||
}
|
||||
|
||||
|
||||
def test_claim_change_invalidates_review_but_updated_timestamp_does_not(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md", confidence=0.53)
|
||||
ledger_path = _write_ledger(vault)
|
||||
|
||||
page.write_text(page.read_text().replace("updated: 2026-07-12T17:38:39+07:00", "updated: 2026-07-13T09:00:00+07:00"))
|
||||
assert check_trust_ledger(vault, ledger_path)["counts"]["reviewed"] == 1
|
||||
|
||||
page.write_text(page.read_text().replace("Reviewed material claim.", "Changed material claim."))
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
assert report["status"] == "warn"
|
||||
assert report["stale"] == [{"page": "concepts/alpha.md", "reason": "material_fingerprint_changed"}]
|
||||
|
||||
|
||||
def test_material_change_marks_review_stale(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md", confidence=0.53)
|
||||
ledger_path = _write_ledger(vault)
|
||||
page.write_text(page.read_text().replace("Reviewed material claim.", "Changed material claim."))
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "warn"
|
||||
assert report["stale"] == [
|
||||
{"page": "concepts/alpha.md", "reason": "material_fingerprint_changed"}
|
||||
]
|
||||
|
||||
|
||||
def test_duplicate_confidence_field_fails_instead_of_bypassing_fingerprint(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md", confidence=0.53)
|
||||
ledger_path = _write_ledger(vault)
|
||||
page.write_text(page.read_text().replace("lifecycle: reviewed", "base_confidence: 0.99\nlifecycle: reviewed"))
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert report["errors"] == [
|
||||
{"page": "concepts/alpha.md", "issue": "duplicate top-level field: base_confidence"}
|
||||
]
|
||||
|
||||
|
||||
def test_volatile_field_with_nested_content_fails_closed(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md")
|
||||
ledger_path = _write_ledger(vault)
|
||||
page.write_text(page.read_text().replace("updated: 2026-07-12T17:38:39+07:00", "updated: 2026-07-12T17:38:39+07:00\n hidden: material"))
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert report["errors"] == [
|
||||
{"page": "concepts/alpha.md", "issue": "updated must be a scalar"}
|
||||
]
|
||||
|
||||
|
||||
def test_invalid_confidence_is_checked_before_stale_short_circuit(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md")
|
||||
ledger_path = _write_ledger(vault)
|
||||
changed = page.read_text().replace("Reviewed material claim.", "Changed material claim.")
|
||||
page.write_text(changed.replace("base_confidence: 0.80", "base_confidence: nan"))
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert report["stale"] == []
|
||||
assert report["errors"] == [
|
||||
{"page": "concepts/alpha.md", "issue": "base_confidence is not finite"}
|
||||
]
|
||||
|
||||
|
||||
def test_invalid_lifecycle_fails_closed(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md")
|
||||
ledger_path = _write_ledger(vault)
|
||||
page.write_text(page.read_text().replace("lifecycle: reviewed", "lifecycle: totally-invalid"))
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert report["errors"] == [
|
||||
{"page": "concepts/alpha.md", "issue": "invalid lifecycle: totally-invalid"}
|
||||
]
|
||||
|
||||
|
||||
def test_owner_trust_schema_accepts_lifecycle_and_optional_fields(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md")
|
||||
ledger_path = _write_ledger(vault)
|
||||
page.write_text(page.read_text().replace("base_confidence: 0.80\n", "").replace("lifecycle: reviewed", "lifecycle: active"))
|
||||
|
||||
report = check_trust_ledger(
|
||||
vault,
|
||||
ledger_path,
|
||||
allowed_lifecycles={"active", "confirmed", "stub"},
|
||||
required_trust_keys=("updated",),
|
||||
schema_source="wiki/AGENTS.md",
|
||||
)
|
||||
|
||||
assert report["status"] == "warn"
|
||||
assert report["errors"] == []
|
||||
assert report["not_applicable"] == [
|
||||
{
|
||||
"page": "concepts/alpha.md",
|
||||
"reason": "base_confidence_absent_by_owner_schema",
|
||||
}
|
||||
]
|
||||
assert report["stale"] == [
|
||||
{
|
||||
"page": "concepts/alpha.md",
|
||||
"reason": "confidence_not_applicable_but_ledger_entry_exists",
|
||||
}
|
||||
]
|
||||
assert report["schema"] == {
|
||||
"source": "wiki/AGENTS.md",
|
||||
"allowed_lifecycles": ["active", "confirmed", "stub"],
|
||||
"required_trust_fields": ["updated"],
|
||||
}
|
||||
|
||||
|
||||
def test_owner_schema_build_excludes_no_confidence_page_as_not_applicable(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md")
|
||||
page.write_text(page.read_text().replace("base_confidence: 0.80\n", ""))
|
||||
|
||||
ledger = build_trust_ledger(
|
||||
vault,
|
||||
reviewed_at="2026-08-05T12:00:00+09:00",
|
||||
required_trust_keys=("updated",),
|
||||
)
|
||||
|
||||
assert ledger["pages"] == {}
|
||||
assert ledger["not_applicable"] == ["concepts/alpha.md"]
|
||||
assert page_fingerprint(page, required_trust_keys=("updated",)).startswith("sha256:")
|
||||
|
||||
|
||||
def test_owner_schema_update_removes_stale_no_confidence_entry(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md")
|
||||
ledger_path = _write_ledger(vault)
|
||||
page.write_text(page.read_text().replace("base_confidence: 0.80\n", ""))
|
||||
|
||||
before = check_trust_ledger(
|
||||
vault,
|
||||
ledger_path,
|
||||
required_trust_keys=("updated",),
|
||||
)
|
||||
assert before["status"] == "warn"
|
||||
assert before["stale"] == [
|
||||
{
|
||||
"page": "concepts/alpha.md",
|
||||
"reason": "confidence_not_applicable_but_ledger_entry_exists",
|
||||
}
|
||||
]
|
||||
|
||||
ledger = update_trust_ledger(
|
||||
vault,
|
||||
ledger_path,
|
||||
reviewed_at="2026-08-05T13:00:00+09:00",
|
||||
page_paths=["concepts/alpha.md"],
|
||||
required_trust_keys=("updated",),
|
||||
)
|
||||
assert ledger["pages"] == {}
|
||||
assert ledger["not_applicable"] == ["concepts/alpha.md"]
|
||||
assert ledger["removed_not_applicable"] == ["concepts/alpha.md"]
|
||||
write_trust_ledger(ledger_path, ledger, vault=vault)
|
||||
|
||||
after = check_trust_ledger(
|
||||
vault,
|
||||
ledger_path,
|
||||
required_trust_keys=("updated",),
|
||||
)
|
||||
assert after["status"] == "pass"
|
||||
assert after["counts"]["not_applicable"] == 1
|
||||
assert after["stale"] == []
|
||||
|
||||
|
||||
def test_owner_trust_schema_rejects_unknown_lifecycle_typo(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md")
|
||||
ledger_path = _write_ledger(vault)
|
||||
page.write_text(page.read_text().replace("lifecycle: reviewed", "lifecycle: activ"))
|
||||
|
||||
report = check_trust_ledger(
|
||||
vault,
|
||||
ledger_path,
|
||||
allowed_lifecycles={"active", "confirmed", "stub"},
|
||||
required_trust_keys=("updated",),
|
||||
schema_source="wiki/AGENTS.md",
|
||||
)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert report["errors"] == [
|
||||
{"page": "concepts/alpha.md", "issue": "invalid lifecycle: activ"}
|
||||
]
|
||||
|
||||
|
||||
def test_owner_lifecycle_propagates_through_record_update_and_check(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md")
|
||||
page.write_text(page.read_text().replace("lifecycle: reviewed", "lifecycle: active"))
|
||||
schema = {
|
||||
"allowed_lifecycles": {"active", "confirmed", "stub"},
|
||||
"required_trust_keys": ("base_confidence", "lifecycle", "updated"),
|
||||
}
|
||||
|
||||
ledger = build_trust_ledger(
|
||||
vault,
|
||||
reviewed_at="2026-08-05T12:00:00+09:00",
|
||||
**schema,
|
||||
)
|
||||
ledger_path = vault / "_meta" / "trust-ledger.json"
|
||||
write_trust_ledger(ledger_path, ledger, vault=vault)
|
||||
assert check_trust_ledger(vault, ledger_path, schema_source="wiki/AGENTS.md", **schema)["status"] == "pass"
|
||||
|
||||
page.write_text(page.read_text().replace("Reviewed material claim.", "Reviewed updated claim."))
|
||||
updated = update_trust_ledger(
|
||||
vault,
|
||||
ledger_path,
|
||||
reviewed_at="2026-08-05T13:00:00+09:00",
|
||||
page_paths=["concepts/alpha.md"],
|
||||
**schema,
|
||||
)
|
||||
write_trust_ledger(ledger_path, updated, vault=vault)
|
||||
assert check_trust_ledger(vault, ledger_path, schema_source="wiki/AGENTS.md", **schema)["status"] == "pass"
|
||||
|
||||
|
||||
|
||||
|
||||
def test_mismatched_scalar_quotes_fail_closed(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md")
|
||||
ledger_path = _write_ledger(vault)
|
||||
page.write_text(page.read_text().replace("lifecycle: reviewed", 'lifecycle: "reviewed\''))
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert report["errors"] == [
|
||||
{"page": "concepts/alpha.md", "issue": 'invalid lifecycle: "reviewed\''}
|
||||
]
|
||||
|
||||
|
||||
def test_invalid_updated_timestamp_fails_before_fingerprint_match(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md")
|
||||
ledger_path = _write_ledger(vault)
|
||||
page.write_text(
|
||||
page.read_text().replace(
|
||||
"updated: 2026-07-12T17:38:39+07:00",
|
||||
"updated: definitely-not-a-timestamp",
|
||||
)
|
||||
)
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert report["errors"] == [
|
||||
{"page": "concepts/alpha.md", "issue": "updated is not an ISO-8601 date or timestamp"}
|
||||
]
|
||||
|
||||
|
||||
def test_inline_comments_on_scalar_trust_fields_remain_valid(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md")
|
||||
page.write_text(
|
||||
page.read_text()
|
||||
.replace("base_confidence: 0.80", "base_confidence: 0.80 # reviewed score")
|
||||
.replace("lifecycle: reviewed", "lifecycle: reviewed # human-reviewed")
|
||||
)
|
||||
|
||||
ledger_path = _write_ledger(vault)
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "pass"
|
||||
assert report["counts"]["reviewed"] == 1
|
||||
|
||||
|
||||
def test_score_only_change_is_a_failing_mismatch_not_stale(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md", confidence=0.53)
|
||||
ledger_path = _write_ledger(vault)
|
||||
page.write_text(page.read_text().replace("base_confidence: 0.53", "base_confidence: 0.88"))
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert report["stale"] == []
|
||||
assert report["score_mismatches"] == [
|
||||
{"page": "concepts/alpha.md", "stored": 0.88, "reviewed": 0.53}
|
||||
]
|
||||
|
||||
|
||||
def test_new_page_requires_manual_review_instead_of_formula_guess(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md")
|
||||
ledger_path = _write_ledger(vault)
|
||||
_page(vault, "concepts/new.md", confidence=0.95)
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "warn"
|
||||
assert report["unreviewed"] == [{"page": "concepts/new.md", "reason": "not_in_manual_ledger"}]
|
||||
|
||||
|
||||
def test_standalone_trust_check_fails_for_page_missing_trust_fields(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md")
|
||||
page.write_text(
|
||||
"\n".join(
|
||||
line
|
||||
for line in page.read_text().splitlines()
|
||||
if not line.startswith(("base_confidence:", "lifecycle:"))
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
ledger_path = vault / "_meta" / "trust-ledger.json"
|
||||
write_trust_ledger(
|
||||
ledger_path,
|
||||
{
|
||||
"schema_version": 1,
|
||||
"method": "manual-lineage-and-claim-coverage-v1",
|
||||
"reviewed_at": "2026-07-12T17:38:39+07:00",
|
||||
"pages": {},
|
||||
},
|
||||
vault=vault,
|
||||
)
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert report["errors"] == [
|
||||
{"page": "concepts/alpha.md", "issue": "missing base_confidence"}
|
||||
]
|
||||
|
||||
|
||||
def test_lint_vault_requires_trust_ledger_by_default(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md")
|
||||
|
||||
report = lint_vault(vault)
|
||||
|
||||
# Staged migration (#28, #146): a missing ledger only warns until the
|
||||
# vault owner opts into strict_trust.
|
||||
assert report["status"] == "warn"
|
||||
assert report["findings"]["confidence_ledger_errors"] == [
|
||||
{"issue": "ledger_missing", "path": str(vault / "_meta" / "trust-ledger.json")}
|
||||
]
|
||||
|
||||
strict_report = lint_vault(vault, strict_trust=True)
|
||||
assert strict_report["status"] == "fail"
|
||||
|
||||
|
||||
def test_non_object_ledger_fails_cleanly(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md")
|
||||
ledger_path = vault / "_meta" / "trust-ledger.json"
|
||||
ledger_path.parent.mkdir(parents=True)
|
||||
ledger_path.write_text("[]\n")
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert report["errors"] == [{"issue": "ledger_must_be_an_object"}]
|
||||
|
||||
|
||||
def test_duplicate_json_keys_are_rejected(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md")
|
||||
ledger_path = _write_ledger(vault)
|
||||
text = ledger_path.read_text()
|
||||
ledger_path.write_text(text.replace('"schema_version": 1', '"schema_version": 1, "schema_version": 1'))
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert report["errors"][0]["issue"] == "ledger_unreadable"
|
||||
assert "duplicate JSON key" in report["errors"][0]["detail"]
|
||||
|
||||
|
||||
def test_boolean_schema_version_fails_closed(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md")
|
||||
ledger_path = _write_ledger(vault)
|
||||
ledger = json.loads(ledger_path.read_text())
|
||||
ledger["schema_version"] = True
|
||||
ledger_path.write_text(json.dumps(ledger))
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert report["errors"][0]["issue"] == "unsupported_schema_version"
|
||||
|
||||
|
||||
def test_invalid_utf8_ledger_returns_structured_failure(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md")
|
||||
ledger_path = vault / "_meta" / "trust-ledger.json"
|
||||
ledger_path.parent.mkdir(parents=True)
|
||||
ledger_path.write_bytes(b"\xff\xfe")
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert report["errors"][0]["issue"] == "ledger_unreadable"
|
||||
|
||||
|
||||
|
||||
|
||||
def test_invalid_ledger_review_timestamp_fails_closed(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md")
|
||||
ledger_path = _write_ledger(vault)
|
||||
ledger = json.loads(ledger_path.read_text())
|
||||
ledger["reviewed_at"] = "yesterday"
|
||||
ledger_path.write_text(json.dumps(ledger))
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert report["errors"] == [{"issue": "invalid_reviewed_at"}]
|
||||
|
||||
|
||||
def test_trust_writer_rejects_symlinked_meta_directory(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
outside = tmp_path / "outside"
|
||||
vault.mkdir()
|
||||
outside.mkdir()
|
||||
(vault / "_meta").symlink_to(outside, target_is_directory=True)
|
||||
ledger = {"schema_version": 1, "method": "manual-lineage-and-claim-coverage-v1", "pages": {}}
|
||||
|
||||
try:
|
||||
write_trust_ledger(vault / "_meta" / "trust-ledger.json", ledger, vault=vault)
|
||||
except RuntimeError as exc:
|
||||
assert "outside the resolved vault" in str(exc) or "symlink" in str(exc)
|
||||
else:
|
||||
raise AssertionError("symlinked _meta directory was accepted")
|
||||
|
||||
assert not (outside / "trust-ledger.json").exists()
|
||||
|
||||
|
||||
def test_trust_writer_never_follows_predictable_temp_symlink(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
meta = vault / "_meta"
|
||||
meta.mkdir(parents=True)
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("sentinel\n")
|
||||
(meta / "trust-ledger.json.tmp").symlink_to(outside)
|
||||
ledger = {"schema_version": 1, "method": "manual-lineage-and-claim-coverage-v1", "pages": {}}
|
||||
|
||||
write_trust_ledger(meta / "trust-ledger.json", ledger, vault=vault)
|
||||
|
||||
assert outside.read_text() == "sentinel\n"
|
||||
assert json.loads((meta / "trust-ledger.json").read_text())["schema_version"] == 1
|
||||
|
||||
|
||||
def test_trust_writer_rejects_symlinked_ledger_destination(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
meta = vault / "_meta"
|
||||
meta.mkdir(parents=True)
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("sentinel\n")
|
||||
destination = meta / "trust-ledger.json"
|
||||
destination.symlink_to(outside)
|
||||
ledger = {"schema_version": 1, "method": "manual-lineage-and-claim-coverage-v1", "pages": {}}
|
||||
|
||||
try:
|
||||
write_trust_ledger(destination, ledger, vault=vault)
|
||||
except RuntimeError as exc:
|
||||
assert "outside the resolved vault" in str(exc) or "symlink" in str(exc)
|
||||
else:
|
||||
raise AssertionError("symlinked ledger destination was accepted")
|
||||
|
||||
assert outside.read_text() == "sentinel\n"
|
||||
|
||||
|
||||
def test_out_of_range_reviewed_confidence_is_invalid_ledger_data(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md")
|
||||
ledger_path = _write_ledger(vault)
|
||||
ledger = json.loads(ledger_path.read_text())
|
||||
ledger["pages"]["concepts/alpha.md"]["reviewed_confidence"] = 2.0
|
||||
ledger_path.write_text(json.dumps(ledger))
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert report["errors"] == [{"page": "concepts/alpha.md", "issue": "invalid_ledger_entry"}]
|
||||
|
||||
|
||||
def test_boolean_reviewed_confidence_is_invalid_ledger_data(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md")
|
||||
ledger_path = _write_ledger(vault)
|
||||
ledger = json.loads(ledger_path.read_text())
|
||||
ledger["pages"]["concepts/alpha.md"]["reviewed_confidence"] = True
|
||||
ledger_path.write_text(json.dumps(ledger))
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert report["errors"] == [{"page": "concepts/alpha.md", "issue": "invalid_ledger_entry"}]
|
||||
|
||||
|
||||
def test_non_standard_json_number_is_unreadable_not_approved(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md")
|
||||
ledger_path = _write_ledger(vault)
|
||||
ledger = json.loads(ledger_path.read_text())
|
||||
ledger["pages"]["concepts/alpha.md"]["reviewed_confidence"] = float("nan")
|
||||
ledger_path.write_text(json.dumps(ledger))
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert report["errors"][0]["issue"] == "ledger_unreadable"
|
||||
|
||||
|
||||
def test_malformed_fingerprint_and_entry_timestamp_fail_closed(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md")
|
||||
ledger_path = _write_ledger(vault)
|
||||
ledger = json.loads(ledger_path.read_text())
|
||||
entry = ledger["pages"]["concepts/alpha.md"]
|
||||
entry["material_fingerprint"] = "garbage"
|
||||
entry["reviewed_at"] = "yesterday"
|
||||
ledger_path.write_text(json.dumps(ledger))
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert report["errors"] == [{"page": "concepts/alpha.md", "issue": "invalid_ledger_entry"}]
|
||||
|
||||
|
||||
def test_non_object_page_entry_is_invalid_not_unreviewed(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md")
|
||||
ledger_path = _write_ledger(vault)
|
||||
ledger = json.loads(ledger_path.read_text())
|
||||
ledger["pages"]["concepts/alpha.md"] = []
|
||||
ledger_path.write_text(json.dumps(ledger))
|
||||
|
||||
report = check_trust_ledger(vault, ledger_path)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert report["unreviewed"] == []
|
||||
assert report["errors"] == [{"page": "concepts/alpha.md", "issue": "invalid_ledger_entry"}]
|
||||
|
||||
|
||||
def test_lint_vault_surfaces_manual_trust_status_without_formula_drift(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md", confidence=0.53, relationships=[("related_to", "skills/beta")])
|
||||
_page(vault, "skills/beta.md", confidence=0.80, relationships=[("related_to", "concepts/alpha")])
|
||||
_write_ledger(vault)
|
||||
|
||||
report = lint_vault(vault)
|
||||
|
||||
assert report["findings"]["confidence_review_stale"] == []
|
||||
assert report["findings"]["confidence_unreviewed"] == []
|
||||
assert report["findings"]["confidence_mismatches"] == []
|
||||
assert report["findings"]["typed_relationship_issues"] == []
|
||||
assert report["stats"]["trust"]["reviewed"] == 2
|
||||
|
||||
|
||||
def test_typed_relationship_resolver_uses_exact_vault_paths(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "projects/demo/alpha.md", relationships=[("implements", "projects/demo/beta")])
|
||||
_page(vault, "projects/demo/beta.md", relationships=[("related_to", "projects/demo/alpha")])
|
||||
_page(vault, "concepts/broken.md", relationships=[("related_to", "skills/missing")])
|
||||
|
||||
report = lint_vault(vault, require_trust_ledger=False)
|
||||
|
||||
assert report["findings"]["typed_relationship_issues"] == [
|
||||
{
|
||||
"page": "concepts/broken.md",
|
||||
"index": 0,
|
||||
"issue": "missing_target",
|
||||
"target": "skills/missing",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_target_first_relationship_order_is_parsed_and_validated(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md")
|
||||
_page(vault, "skills/beta.md")
|
||||
page.write_text(
|
||||
page.read_text().replace(
|
||||
"lifecycle: reviewed",
|
||||
'lifecycle: reviewed\nrelationships:\n - target: "[[skills/missing]]"\n type: related_to',
|
||||
)
|
||||
)
|
||||
|
||||
report = lint_vault(vault, require_trust_ledger=False)
|
||||
|
||||
assert report["findings"]["typed_relationship_issues"] == [
|
||||
{
|
||||
"page": "concepts/alpha.md",
|
||||
"index": 0,
|
||||
"issue": "missing_target",
|
||||
"target": "skills/missing",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_dash_only_relationship_item_is_parsed_and_validated(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md")
|
||||
page.write_text(
|
||||
page.read_text().replace(
|
||||
"lifecycle: reviewed",
|
||||
'lifecycle: reviewed\nrelationships:\n -\n target: "[[skills/missing]]"\n type: related_to',
|
||||
)
|
||||
)
|
||||
|
||||
report = lint_vault(vault, require_trust_ledger=False)
|
||||
|
||||
assert report["findings"]["typed_relationship_issues"] == [
|
||||
{
|
||||
"page": "concepts/alpha.md",
|
||||
"index": 0,
|
||||
"issue": "missing_target",
|
||||
"target": "skills/missing",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_unknown_relationship_field_fails_closed(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md")
|
||||
_page(vault, "skills/beta.md")
|
||||
page.write_text(
|
||||
page.read_text().replace(
|
||||
"lifecycle: reviewed",
|
||||
'lifecycle: reviewed\nrelationships:\n - target: "[[skills/beta]]"\n type: related_to\n weight: 5',
|
||||
)
|
||||
)
|
||||
|
||||
report = lint_vault(vault, require_trust_ledger=False)
|
||||
|
||||
assert report["findings"]["typed_relationship_issues"] == [
|
||||
{
|
||||
"page": "concepts/alpha.md",
|
||||
"index": 0,
|
||||
"issue": "malformed_relationship_entry",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_basename_only_relationship_is_rejected_when_ambiguous(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md", relationships=[("related_to", "beta")])
|
||||
_page(vault, "skills/beta.md")
|
||||
_page(vault, "projects/demo/beta.md")
|
||||
|
||||
report = lint_vault(vault, require_trust_ledger=False)
|
||||
|
||||
assert report["findings"]["typed_relationship_issues"] == [
|
||||
{
|
||||
"page": "concepts/alpha.md",
|
||||
"index": 0,
|
||||
"issue": "ambiguous_target",
|
||||
"target": "beta",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_qualified_relationship_is_ambiguous_when_normalized_paths_collide(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md", relationships=[("related_to", "skills/foo-bar")])
|
||||
_page(vault, "skills/Foo Bar.md")
|
||||
_page(vault, "skills/foo-bar.md")
|
||||
|
||||
report = lint_vault(vault, require_trust_ledger=False)
|
||||
|
||||
assert report["findings"]["typed_relationship_issues"] == [
|
||||
{
|
||||
"page": "concepts/alpha.md",
|
||||
"index": 0,
|
||||
"issue": "ambiguous_target",
|
||||
"target": "skills/foo-bar",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_empty_inline_relationship_list_is_valid(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md")
|
||||
page.write_text(page.read_text().replace("lifecycle: reviewed", "lifecycle: reviewed\nrelationships: []"))
|
||||
|
||||
report = lint_vault(vault, require_trust_ledger=False)
|
||||
|
||||
assert report["findings"]["typed_relationship_issues"] == []
|
||||
|
||||
|
||||
def test_lint_excludes_bootstrap_scaffolding_from_page_health(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md", relationships=[("related_to", "concepts/beta")])
|
||||
_page(vault, "concepts/beta.md", relationships=[("related_to", "concepts/alpha")])
|
||||
bootstrap = vault / "_bootstrap" / "README.md"
|
||||
bootstrap.parent.mkdir(parents=True)
|
||||
bootstrap.write_text("# Setup instructions\n")
|
||||
|
||||
report = lint_vault(vault)
|
||||
|
||||
serialized = json.dumps(report)
|
||||
assert "_bootstrap/README.md" not in serialized
|
||||
|
||||
|
||||
def test_iter_trust_pages_skips_tool_owned_directories(tmp_path: Path) -> None:
|
||||
"""A `.venv` inside a project-vault must not enter the ledger's page set —
|
||||
the ledger would otherwise record reviews for dependency READMEs, and each
|
||||
dependency upgrade would churn its entries."""
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md")
|
||||
junk = (
|
||||
".venv/lib/python3.12/site-packages/somepkg/README.md",
|
||||
".trash/draft.md",
|
||||
"node_modules/pkg/README.md",
|
||||
"venv/readme.md",
|
||||
"__pycache__/x.md",
|
||||
)
|
||||
for relative in junk:
|
||||
path = vault / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("# dependency readme\nno frontmatter\n", encoding="utf-8")
|
||||
|
||||
assert [page.relative_to(vault).as_posix() for page in iter_trust_pages(vault)] == [
|
||||
"concepts/alpha.md"
|
||||
]
|
||||
|
||||
|
||||
def test_iter_trust_pages_honors_okignore(tmp_path: Path) -> None:
|
||||
"""A vault-configured quarantine dir (`_excluded/`) stays out of the ledger."""
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md")
|
||||
(vault / ".okignore").write_text("_excluded/\n", encoding="utf-8")
|
||||
excluded = vault / "_excluded" / "pii.md"
|
||||
excluded.parent.mkdir(parents=True, exist_ok=True)
|
||||
excluded.write_text("plain text, no frontmatter\n", encoding="utf-8")
|
||||
|
||||
assert [page.relative_to(vault).as_posix() for page in iter_trust_pages(vault)] == [
|
||||
"concepts/alpha.md"
|
||||
]
|
||||
|
||||
|
||||
def test_lint_flags_content_page_missing_trust_schema(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
page = _page(vault, "concepts/alpha.md")
|
||||
page.write_text(
|
||||
"\n".join(
|
||||
line
|
||||
for line in page.read_text().splitlines()
|
||||
if not line.startswith(("base_confidence:", "lifecycle:"))
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
report = lint_vault(vault)
|
||||
|
||||
# Staged migration (#28, #146): legacy pages predating the trust schema
|
||||
# warn instead of failing lint until the owner opts into strict_trust.
|
||||
assert report["status"] == "warn"
|
||||
assert report["findings"]["missing_frontmatter"] == []
|
||||
assert report["findings"]["confidence_missing_fields"] == [
|
||||
{"page": "concepts/alpha.md", "missing": ["base_confidence", "lifecycle"]}
|
||||
]
|
||||
|
||||
strict_report = lint_vault(vault, strict_trust=True)
|
||||
assert strict_report["status"] == "fail"
|
||||
|
||||
|
||||
def test_required_trust_ledger_fails_closed_when_missing(tmp_path: Path) -> None:
|
||||
vault = tmp_path / "vault"
|
||||
_page(vault, "concepts/alpha.md")
|
||||
|
||||
report = lint_vault(vault, require_trust_ledger=True)
|
||||
|
||||
# require_trust_ledger controls whether the ledger check runs at all (and
|
||||
# is thus reported); strict_trust controls whether its findings fail lint.
|
||||
assert report["status"] == "warn"
|
||||
assert report["findings"]["confidence_ledger_errors"] == [
|
||||
{"issue": "ledger_missing", "path": str(vault / "_meta" / "trust-ledger.json")}
|
||||
]
|
||||
|
||||
strict_report = lint_vault(vault, require_trust_ledger=True, strict_trust=True)
|
||||
assert strict_report["status"] == "fail"
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Local data contracts that the selected upstream port must preserve."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from obsidian_wiki.cli import main
|
||||
from obsidian_wiki.graph_analysis import parse_vault_graph
|
||||
from obsidian_wiki.lint import lint_vault
|
||||
from obsidian_wiki.provenance import archive_wikilink_relpath, resolve_source_key
|
||||
from obsidian_wiki.vault import iter_md
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", ["dict", "list"])
|
||||
def test_cache_cli_preserves_curated_metadata_and_page_associations(tmp_path, shape):
|
||||
source = tmp_path / "source.pdf"
|
||||
source.write_bytes(b"new revision")
|
||||
entry = {
|
||||
"path": "source.pdf", "pages_produced": ["concepts/a.md"],
|
||||
"archive_path": "_sources/archive/a.pdf", "content_hash": "old",
|
||||
"bjd_part_numbers": ["BJD-123"], "competitor_part_numbers": ["OTHER-456"],
|
||||
"series": ["WTB"], "pin_count": [6], "pitch": [2.0],
|
||||
"catalog_paths": ["_sources/catalog/a.md"], "visibility": "internal",
|
||||
}
|
||||
sources = {"source.pdf": entry} if shape == "dict" else [entry]
|
||||
manifest = tmp_path / ".manifest.json"
|
||||
manifest.write_text(json.dumps({"sources": sources, "local_revision": 7}))
|
||||
assert main(["cache-update", str(tmp_path), str(source)]) == 0
|
||||
updated = json.loads(manifest.read_text())
|
||||
actual = updated["sources"]["source.pdf"] if shape == "dict" else updated["sources"][0]
|
||||
assert updated["local_revision"] == 7
|
||||
for key, value in entry.items():
|
||||
if key != "content_hash":
|
||||
assert actual[key] == value
|
||||
assert actual["content_hash"] != "old"
|
||||
|
||||
|
||||
def test_local_sources_are_citable_but_not_knowledge_pages(tmp_path):
|
||||
refs = ["_sources/archive/doc.pdf", "_sources/catalog/products.md"]
|
||||
for rel in [*refs, "_sources/archive/raw.md", "_meta/taxonomy.md", "_inbox/draft.md", "_bot_exports/copy.md"]:
|
||||
p = tmp_path / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text("Raw source, not a knowledge page")
|
||||
page = tmp_path / "concepts" / "a.md"
|
||||
page.parent.mkdir()
|
||||
page.write_text(
|
||||
"---\ntitle: A\ncategory: concepts\ntags: [a]\nsources: []\n"
|
||||
"created: 2026-10-01\nupdated: 2026-10-01\nsummary: Example\n---\n"
|
||||
+ " ".join(f"[[{rel}]]" for rel in refs)
|
||||
)
|
||||
assert iter_md(tmp_path) == [page]
|
||||
graph, _ = parse_vault_graph(tmp_path)
|
||||
assert set(graph) == {"a"}
|
||||
assert graph["a"] == []
|
||||
findings = lint_vault(tmp_path, require_trust_ledger=False)["findings"]
|
||||
assert findings["broken_links"] == []
|
||||
assert resolve_source_key(tmp_path, refs[0]) == refs[0]
|
||||
assert archive_wikilink_relpath(tmp_path, refs[0] + "|PDF") == refs[0]
|
||||
assert archive_wikilink_relpath(tmp_path, "_sources/catalog/products") == refs[1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["../_sources/archive/a.pdf", "/tmp/_sources/archive/a.pdf"])
|
||||
def test_local_source_resolution_refuses_paths_outside_vault(tmp_path, target):
|
||||
assert resolve_source_key(tmp_path, target) is None
|
||||
assert archive_wikilink_relpath(tmp_path, target) is None
|
||||
|
||||
|
||||
def test_staging_cli_conflict_keeps_reviewed_and_live_files(tmp_path, capsys):
|
||||
staged = tmp_path / "_staging/concepts/a.md"
|
||||
staged.parent.mkdir(parents=True)
|
||||
staged.write_text("Reviewed proposal")
|
||||
assert main(["staging", "list", "--vault", str(tmp_path), "--json"]) == 0
|
||||
inventory = json.loads(capsys.readouterr().out)
|
||||
# The runtime schema is asserted here so the skill can pin listed revisions.
|
||||
entry = inventory[0]
|
||||
live = tmp_path / "concepts/a.md"
|
||||
live.parent.mkdir()
|
||||
live.write_text("Concurrent live update")
|
||||
assert main([
|
||||
"staging", "promote", entry["staged_path"], "--vault", str(tmp_path),
|
||||
"--expect-staged", entry["staged_revision"], "--expect-new",
|
||||
]) == 9
|
||||
assert live.read_text() == "Concurrent live update"
|
||||
assert staged.read_text() == "Reviewed proposal"
|
||||
Reference in New Issue
Block a user