Files
obsidian-wiki/obsidian_wiki/provenance.py
T
Codex e943bb329e
setup / install-leaves-tree-clean (push) Waiting to run
feat(wiki): port upstream knowledge quality improvements
Selectively port source coverage and redirect lint, ignore-aware provenance, staging and trust checks from upstream 055d720. Preserve local archive metadata and adapt source-date and defuddle skill guidance.

Keep the existing skills directory, file index and MCP interfaces. Record per-module upstream baselines and exclusions without migrating vault data or changing services.

Validation: 625 tests passed; git diff --check passed.
2026-11-07 21:49:41 +00:00

200 lines
6.8 KiB
Python

"""Ledger invert and snapshot-path compare for lint (ADR 0001)."""
from __future__ import annotations
import re
from collections import defaultdict
from pathlib import Path
from typing import Any
from obsidian_wiki.cache import _is_file_key, _iter_entries
from obsidian_wiki.vault import iter_md_under, split_frontmatter
_PAGE_LIST_KEYS = ("pages_produced", "pages_created")
_URL_FIELDS = ("url", "source", "source_url")
_MARKDOWN_LINK_RE = re.compile(r"^\[([^\]]*)\]\(([^)]+)\)$")
def unwrap_snapshot_value(raw: str) -> str:
value = raw.strip().strip("'\"").strip()
markdown = _MARKDOWN_LINK_RE.match(value)
if markdown:
value = markdown.group(2).strip()
elif value.startswith("[[") and "]]" in value:
inner = value[2 : value.index("]]")]
inner = inner.split("|", 1)[0].split("#", 1)[0].strip()
value = inner
elif "|" in value:
# _WIKILINK_RE captures inners as path|display without [[ ]].
value = value.split("|", 1)[0].split("#", 1)[0].strip()
if value and _is_file_key(value) and Path(value).suffix == "":
value = f"{value}.md"
return value
def invert_pages(sources: Any) -> dict[str, list[str]]:
"""Map wiki page path -> unique ledger keys, first-seen order."""
by_page: dict[str, list[str]] = defaultdict(list)
seen: dict[str, set[str]] = defaultdict(set)
for key, entry in _iter_entries(sources):
if not key or not isinstance(entry, dict):
continue
pages: list[str] = []
for field in _PAGE_LIST_KEYS:
raw = entry.get(field) or []
if isinstance(raw, list):
pages.extend(str(p) for p in raw if p)
for page in pages:
if key in seen[page]:
continue
seen[page].add(key)
by_page[page].append(key)
return dict(by_page)
def _archived_dir(vault: Path) -> Path:
return vault / "_raw" / "_archived"
def clip_url_index(vault: Path) -> dict[str, list[str]]:
"""Map clip YAML url -> archived relative paths (may be 0, 1, or many)."""
index: dict[str, list[str]] = defaultdict(list)
root = _archived_dir(vault)
if not root.is_dir():
return {}
for path in iter_md_under(vault, root):
text = path.read_text(encoding="utf-8", errors="replace")
frontmatter = split_frontmatter(text)[0]
rel = path.relative_to(vault).as_posix()
for line in frontmatter.splitlines():
if ":" not in line or line.startswith((" ", "\t")):
continue
key, raw = line.split(":", 1)
if key.strip() not in _URL_FIELDS:
continue
url = raw.strip().strip("'\"")
if url:
index[url].append(rel)
break # first matching URL field wins per file
return dict(index)
def resolve_source_key(
vault: Path,
key: str,
*,
url_index: dict[str, list[str]] | None = None,
) -> str | None:
"""Return a vault-relative archived path, or None if not a unique snapshot file."""
if key.startswith("url:"):
url = key[4:]
index = url_index if url_index is not None else clip_url_index(vault)
matches = index.get(url) or []
if len(matches) == 1:
return matches[0]
return None
if not _is_file_key(key):
return None
candidate = Path(key)
if candidate.is_absolute() or ".." in candidate.parts:
return None
if "_archived" in candidate.parts or candidate.parts[:2] == ("_sources", "archive"):
return candidate.as_posix() if (vault / candidate).is_file() else None
return _stale_raw_archive(vault, candidate)
def _stale_raw_archive(vault: Path, candidate: Path) -> str | None:
"""`_raw/<name>` that ingest already moved to `_raw/_archived/<name>`.
Only `_raw/` drafts qualify, and only once the draft itself is gone: a live
file, or a same-named file elsewhere in the vault, is never an archive.
"""
if candidate.parts[:1] != ("_raw",) or (vault / candidate).is_file():
return None
archived_rel = Path("_raw") / "_archived" / candidate.name
return archived_rel.as_posix() if (vault / archived_rel).is_file() else None
def parse_snapshots_field(raw: str) -> list[str]:
if not raw.strip():
return []
lines = raw.splitlines()
inline = lines[0].strip()
entries: list[str] = []
if inline.startswith("["):
entries.extend(inline.strip("[]").split(","))
elif inline:
entries.append(inline)
entries.extend(
line.strip()[1:] for line in lines[1:] if line.strip().startswith("-")
)
result: list[str] = []
seen: set[str] = set()
for entry in entries:
value = unwrap_snapshot_value(entry)
if not value or value in seen:
continue
seen.add(value)
result.append(value)
return result
def expected_snapshots_for_page(
vault: Path,
page: str,
sources: Any,
*,
inverted: dict[str, list[str]] | None = None,
url_index: dict[str, list[str]] | None = None,
) -> list[str]:
mapping = inverted if inverted is not None else invert_pages(sources)
keys = mapping.get(page) or []
need_urls = any(k.startswith("url:") for k in keys)
index = url_index
if index is None and need_urls:
index = clip_url_index(vault)
expected: list[str] = []
seen: set[str] = set()
for key in keys:
resolved = resolve_source_key(vault, key, url_index=index)
if not resolved or resolved in seen:
continue
seen.add(resolved)
expected.append(resolved)
return expected
def prefer_archive_write_path(vault: Path, rel: str) -> str | None:
"""Vault-relative archive path to store, or None if nothing exists on disk."""
rel = rel.strip().replace("\\", "/")
candidate = Path(rel)
if candidate.is_absolute() or ".." in candidate.parts:
return None
if "_archived" in candidate.parts:
if (vault / candidate).is_file():
return candidate.as_posix()
return None
if (vault / candidate).is_file():
return candidate.as_posix()
return _stale_raw_archive(vault, candidate)
def archive_wikilink_relpath(_vault: Path, inner: str) -> str | None:
"""Normalise upstream snapshots and local source archive/catalog links.
Does not require the file to exist. Returns None when the inner is not
under a supported source directory. Preserve explicit file extensions.
"""
value = unwrap_snapshot_value(inner)
if not value:
return None
path = Path(value.replace("\\", "/"))
if path.is_absolute() or ".." in path.parts:
return None
parts = path.parts
if len(parts) < 3 or parts[:2] not in {
("_raw", "_archived"), ("_sources", "archive"), ("_sources", "catalog"),
}:
return None
return value if path.suffix else f"{value}.md"