Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ All notable changes to vouch are documented here. Format follows
per-prompt block, the session banner, `vouch status` and the opt-in
question all say so rather than calling it "this repo's" knowledge.

### Fixed
- **wiki render now resolves a `[[link]]` to the page actually titled that,
not to whichever page happens to list the name as an alias first**
(`wiki_render`). the link index registered each page's title, id and
aliases in one pass, so `setdefault` made resolution order-dependent: if a
page carrying "Retry Policy" only as an alias sorted ahead of the page
titled "Retry Policy", `[[Retry Policy]]` resolved to the wrong page and its
backlink landed on the alias holder in the `render-wiki` map-of-content —
contradicting the module's own promise that an alias never shadows a title.
the index is now built in tiers (every title, then every id/slug, then every
alias), so a real title always wins regardless of `list_pages()` order.

## [1.5.0] — 2026-07-20

### Added
Expand Down
25 changes: 19 additions & 6 deletions src/vouch/wiki_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,28 @@
def _link_index(pages: list[Page]) -> dict[str, Page]:
"""Map every resolvable name (title, id/slug, alias) to its page.

Keys are lowercased. Earlier pages win a name collision (``setdefault``),
so a later page's alias never shadows an existing page's title.
Keys are lowercased. Resolution is tiered so a stronger handle always beats
a weaker one regardless of page order: every title is registered first,
then every id/slug, then every alias, each with ``setdefault``. A page
titled ``Retry Policy`` therefore owns that name even when an earlier page
in the list carries it only as an alias — otherwise the resolved target
depended on ``list_pages()`` ordering. Within a single tier the earlier
page still wins a genuine collision (two pages sharing a title).
"""
index: dict[str, Page] = {}

def register(name: object, page: Page) -> None:
key = str(name).strip().lower()
if key:
index.setdefault(key, page)

for page in pages:
register(page.title, page)
for page in pages:
register(page.id, page)
for page in pages:
for name in (page.title, page.id, *(page.metadata.get("aliases") or [])):
key = str(name).strip().lower()
if key:
index.setdefault(key, page)
for alias in page.metadata.get("aliases") or []:
register(alias, page)
return index


Expand Down
20 changes: 20 additions & 0 deletions tests/test_wiki_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,23 @@ def test_render_moc_ranks_by_inbound_links() -> None:
# Gamma has 2 inbound links; it must rank above the 0-inbound pages.
assert out.index("Gamma") < out.index("Alpha")
assert out.index("Gamma") < out.index("Beta")


def test_title_beats_an_earlier_pages_alias_regardless_of_order() -> None:
# A real page is TITLED "Retry Policy"; an unrelated page carries the same
# string only as an alias and sorts earlier in the list. Resolution must
# follow the title, not the list order — the module docstring promises an
# alias never shadows an existing page's title.
aliaser = _page("Backoff Notes", aliases=["Retry Policy"], pid="backoff-notes")
titled = _page("Retry Policy", pid="retry-policy")
pages = [aliaser, titled]

resolved = wiki_render.resolve_link("Retry Policy", pages)
assert resolved is titled

# …and the backlink for a [[Retry Policy]] reference lands on the titled
# page, not the alias holder.
caller = _page("Caller", body="we follow [[Retry Policy]] here", pid="caller")
bl = wiki_render.backlinks([aliaser, titled, caller])
assert bl.get("retry-policy") == ["Caller"]
assert "backoff-notes" not in bl
Loading