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:
Codex
2026-10-31 12:39:48 +00:00
parent 1dd5d8d753
commit 82890606aa
35 changed files with 12632 additions and 44 deletions
+536
View File
@@ -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"))
+172
View File
@@ -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"] == []
+293
View File
@@ -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
+716
View File
@@ -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
+120
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+943
View File
@@ -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
+220
View File
@@ -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
+202
View File
@@ -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()
+172
View File
@@ -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,
)
+883
View File
@@ -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"
+85
View File
@@ -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"