feat(wiki): sync upstream v2026.10.4 core maintenance
Selectively port source hashing, locked manifest/memory writes, path-aware link lint and graph analysis, and revision-checked staging from upstream 4f457e154653756b04802d5aeb25c20f42b4770c. Record source hashes and exclusions. Preserve local archive/catalog citations, curated manifest metadata, config location, file-index and MCP behavior. Wire maintenance guidance into four skills without migrating vault contents or changing running services. Validation: 609 tests passed, including existing MCP/file-asset regressions and imported upstream maintenance tests. Read-only vault status left shared metadata hashes unchanged. Wiki MCP remains inactive; orders MCP is active.
This commit is contained in:
@@ -0,0 +1,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,
|
||||
)
|
||||
Reference in New Issue
Block a user