Files
obsidian-wiki/tests/test_links.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

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"] == []