Files
obsidian-wiki/tests/test_provenance.py
T
Codex 82890606aa 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.
2026-10-31 12:39:48 +00:00

221 lines
7.8 KiB
Python

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