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.
121 lines
5.5 KiB
Python
121 lines
5.5 KiB
Python
"""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"] == []
|