fix: a wikilink wrapped across a line break is its own lint finding; rename and rm see it (#115)
CI / verify (push) Successful in 5m15s
CI / pwsh (push) Successful in 2m1s
Release / release (push) Successful in 35s

Files changed:
- CHANGES.md
- VERSION
- instructions/wiki-lint/SKILL.md
- kb/CONTRACT.md
- tools/CONTRACT.md
- tools/chemenu/commands/lint.py
- tools/chemenu/commands/page_ops.py
- tools/chemenu/kb_scan.py
- tools/chemenu/lint_core.py
- tools/chemenu/tests/test_kb_scan.py
- tools/chemenu/tests/test_lint.py
- tools/chemenu/tests/test_page_ops.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Opus 5.5 committed 2026-10-03 11:19:10 +02:00
1 parent b3022b8ffb
commit c261b8f4ca
12 files changed
+204 -13

No files matched your search

+15 -2
View File
@@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse.
--- ---
## 8.0.0-beta.29 - 2026-10-03 - stack-close no longer records which model and effort ran each phase ## 8.0.0-beta.30 - 2026-10-03 - lint: a wikilink wrapped across a line break is its own finding; rename and rm see it
**Author:** Torben Nehmer **Author:** Torben Nehmer
@@ -140,9 +140,22 @@ concern - readable here, never shipped as something to parse.
- kb/CONTRACT.md: an external article's raw_files point under raw/, a raw fetch capture names both files - kb/CONTRACT.md: an external article's raw_files point under raw/, a raw fetch capture names both files
- raw fetch --html record and kb/CONTRACT.md path budget follow the folder accept - raw fetch --html record and kb/CONTRACT.md path budget follow the folder accept
- stack-close no longer records which model and effort ran each phase - stack-close no longer records which model and effort ran each phase
- lint: a wikilink wrapped across a line break is its own finding; rename and rm see it
<!-- /wikitool:bumps --> <!-- /wikitool:bumps -->
### stack-close no longer records which model and effort ran each phase ### lint: a wikilink wrapped across a line break is its own finding; rename and rm see it
A `[[...]]` written across a line break - prose wrapped at a fixed column, with the break
landing inside the link - was read with the break as part of its title. `lint` reported the
target as a missing page, with the line break in the printed name; `rm`'s inbound check did not
count it, so a page referenced only that way was deleted without the `--yes` confirmation; and
`rename` left it pointing at the old title. A link target is now read through one function,
`kb_scan.normalize_link_target()`, which folds a line break and its indentation to one space -
used by the link graph, the index check, `rename` and `rm` alike. That such a link is wrapped
is still a defect: `lint` reports it in a new hard section, *Wrapped Wikilinks*, and reports it
as broken as well only if the folded title is missing. No instance's lint gets redder: every
link the new section names was a hard broken-link finding before. `kb/CONTRACT.md` § Titles are
identifiers states the rule (Gitea #115).
`stack-close`'s closing comment no longer carries a per-phase table of model, effort, session `stack-close`'s closing comment no longer carries a per-phase table of model, effort, session
shape and compaction, nor the `size/` label beside it; the comment is the changelog line, then shape and compaction, nor the `size/` label beside it; the comment is the changelog line, then
+1 -1
View File
@@ -1 +1 @@
8.0.0-beta.29 8.0.0-beta.30
+2 -1
View File
@@ -41,7 +41,8 @@ mechanical half looks exactly like a complete one.
No flags: prints the sections that found something, writes the full report to No flags: prints the sections that found something, writes the full report to
`reports/Lint Report <YYYY-MM-DD>.md`, and names that path. This deterministically finds `reports/Lint Report <YYYY-MM-DD>.md`, and names that path. This deterministically finds
unreadable frontmatter, broken wikilinks, dangling frontmatter references, orphan pages, unreadable frontmatter, broken wikilinks, wikilinks wrapped across a line break, dangling
frontmatter references, orphan pages,
catalog drift, missing fields, duplicate titles, titles that are not valid, unique file catalog drift, missing fields, duplicate titles, titles that are not valid, unique file
names on Windows and macOS, filename/title mismatches, broken names on Windows and macOS, filename/title mismatches, broken
`raw_files:` references, raw files claimed by more than one source page, invalid type paths, `raw_files:` references, raw files claimed by more than one source page, invalid type paths,
+6
View File
@@ -116,6 +116,12 @@ not a naming preference; it is the wiki's only way to address a page. `wikitool
H1 that stops matching its title, `rename`/`rm` rewrite every reference to a stem, and a H1 that stops matching its title, `rename`/`rm` rewrite every reference to a stem, and a
`[^cite-id]` resolves through one. `[^cite-id]` resolves through one.
**A wikilink is one token and is never wrapped across lines.** When prose is broken at a fixed
column, the break goes before or after `[[...]]`, never inside it: a renderer does not reliably
read a link with a line break in it as a link. `wikitool lint` reports one as a *Wrapped
Wikilinks* hard error, naming the title it folds to - the graph, `rename` and `rm` already read
it as that title, so the fix is only to put it back on one line.
Which *form* those titles take - spaces or kebab-case, singular or plural, what prefixes a Which *form* those titles take - spaces or kebab-case, singular or plural, what prefixes a
decision record - is the instance's, in decision record - is the instance's, in
[kb/CONVENTIONS.md § Naming](CONVENTIONS.md#naming). [kb/CONVENTIONS.md § Naming](CONVENTIONS.md#naming).
+2 -1
View File
@@ -1120,8 +1120,9 @@ Run structural lint checks against kb/.
**NOTES** **NOTES**
- Structural and provenance checks over `kb/`: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, and unbalanced generated-region markers. - Structural and provenance checks over `kb/`: broken wikilinks, wikilinks wrapped across a line break, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, and unbalanced generated-region markers.
- Unportable Titles is a hard finding, and hard at every `kb_version`: a page whose title is not a valid file name on Windows and macOS (forbidden character, reserved name, trailing dot or space), or that collides with another page by case or Unicode normalization. `wikitool rename` is the fix. - Unportable Titles is a hard finding, and hard at every `kb_version`: a page whose title is not a valid file name on Windows and macOS (forbidden character, reserved name, trailing dot or space), or that collides with another page by case or Unicode normalization. `wikitool rename` is the fix.
- Wrapped Wikilinks is a hard finding: a `[[...]]` with a line break inside it. The link graph reads it as the title it folds to (the break and its indentation become one space), so it is not also a broken link unless that title is missing; the fix is to put it back on one line.
- Pages nested more than one directory below their collection are a hard finding - the generated catalog folds these into their area silently rather than merely reading it. - Pages nested more than one directory below their collection are a hard finding - the generated catalog folds these into their area silently rather than merely reading it.
- Edges whose label is missing or not authorised by the source collection's `outbound:` are both hard once `kb_version` has reached the release that introduced labelled edges, and advisory below it. - Edges whose label is missing or not authorised by the source collection's `outbound:` are both hard once `kb_version` has reached the release that introduced labelled edges, and advisory below it.
- Advisory only: `see-also` edges whose reverse direction already carries a specific label - never migration-gated. - Advisory only: `see-also` edges whose reverse direction already carries a specific label - never migration-gated.
+6 -1
View File
@@ -59,7 +59,8 @@ __all__ = [
budget=cli_contract.Budget.COUNTED, budget=cli_contract.Budget.COUNTED,
), ),
notes=( notes=(
"Structural and provenance checks over `kb/`: broken wikilinks, dangling frontmatter " "Structural and provenance checks over `kb/`: broken wikilinks, wikilinks wrapped "
"across a line break, dangling frontmatter "
"references, orphan pages, index drift, schema gaps, duplicate titles, title " "references, orphan pages, index drift, schema gaps, duplicate titles, title "
"mismatches, uncovered raw files, broken `raw_files:` refs, raw files claimed by more " "mismatches, uncovered raw files, broken `raw_files:` refs, raw files claimed by more "
"than one source page, unmarked provenance, citation/frontmatter drift, and unbalanced " "than one source page, unmarked provenance, citation/frontmatter drift, and unbalanced "
@@ -68,6 +69,10 @@ __all__ = [
"is not a valid file name on Windows and macOS (forbidden character, reserved name, " "is not a valid file name on Windows and macOS (forbidden character, reserved name, "
"trailing dot or space), or that collides with another page by case or Unicode " "trailing dot or space), or that collides with another page by case or Unicode "
"normalization. `wikitool rename` is the fix.", "normalization. `wikitool rename` is the fix.",
"Wrapped Wikilinks is a hard finding: a `[[...]]` with a line break inside it. The link "
"graph reads it as the title it folds to (the break and its indentation become one "
"space), so it is not also a broken link unless that title is missing; the fix is to put "
"it back on one line.",
"Pages nested more than one directory below their collection are a hard finding - the " "Pages nested more than one directory below their collection are a hard finding - the "
"generated catalog folds these into their area silently rather than merely reading it.", "generated catalog folds these into their area silently rather than merely reading it.",
"Edges whose label is missing or not authorised by the source collection's `outbound:` " "Edges whose label is missing or not authorised by the source collection's `outbound:` "
+7 -3
View File
@@ -40,7 +40,7 @@ from chemenu.commands._util import (
from chemenu.frontmatter_io import write_page from chemenu.frontmatter_io import write_page
from chemenu.lint_core import find_misplaced from chemenu.lint_core import find_misplaced
from chemenu.page import Page from chemenu.page import Page
from chemenu.kb_scan import load_kb_pages from chemenu.kb_scan import load_kb_pages, normalize_link_target
from chemenu.provenance import ( from chemenu.provenance import (
CITE_REF_RE, CITE_REF_RE,
cite_id, cite_id,
@@ -55,6 +55,8 @@ from chemenu.type_resolver import resolver
# is exactly what lets retarget_body() repoint a citation's link target on a # is exactly what lets retarget_body() repoint a citation's link target on a
# rename. Group 1 is the target title; group 2 keeps any alias/anchor suffix # rename. Group 1 is the target title; group 2 keeps any alias/anchor suffix
# untouched. The id itself is a separate concern - see retarget_cite_ids(). # untouched. The id itself is a separate concern - see retarget_cite_ids().
# Group 1 may span a line break; it is compared through
# kb_scan.normalize_link_target(), the same reading `lint` gives it.
LINK_RE = re.compile(r"\[\[([^\[\]|#]+)((?:[|#][^\[\]]*)?)\]\]") LINK_RE = re.compile(r"\[\[([^\[\]|#]+)((?:[|#][^\[\]]*)?)\]\]")
@@ -76,7 +78,7 @@ def retarget_body(body: str, old: str, new: str) -> str:
def replace(match: re.Match) -> str: def replace(match: re.Match) -> str:
target, suffix = match.group(1), match.group(2) target, suffix = match.group(1), match.group(2)
if target.strip() != old: if normalize_link_target(target) != old:
return match.group(0) return match.group(0)
return f"[[{new}{suffix}]]" return f"[[{new}{suffix}]]"
@@ -202,7 +204,9 @@ def strip_link_bullets(body: str, title: str) -> str:
def body_references(body: str, title: str) -> int: def body_references(body: str, title: str) -> int:
"""How many wikilinks in `body` still point at `title`.""" """How many wikilinks in `body` still point at `title`."""
return sum(1 for match in LINK_RE.finditer(body) if match.group(1).strip() == title) return sum(
1 for match in LINK_RE.finditer(body) if normalize_link_target(match.group(1)) == title
)
def inbound_pages(pages: dict[str, Page], title: str) -> list[str]: def inbound_pages(pages: dict[str, Page], title: str) -> list[str]:
+40 -2
View File
@@ -12,6 +12,25 @@ from chemenu.page import Page
WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)") WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)")
# A line break inside `[[...]]`, with the indentation around it. The target
# class above admits a newline, so a link someone wrapped at a fixed column -
# `[[Foo Bar\n Target]]` - captures the break as part of the title, matches no
# page, and read as a missing one to `lint`, as no reference at all to `rm`'s
# inbound check, and as nothing to repoint to `rename`.
_LINE_BREAK_RUN = re.compile(r"[ \t]*(?:\r?\n[ \t]*)+")
def normalize_link_target(raw: str) -> str:
"""The title a captured wikilink target names: every whitespace run that
contains a line break folded to one space, then stripped.
Every reader of a body wikilink goes through this, so `lint`, the link
graph, `rename` and `rm` agree on what a wrapped link points at. That the
link is wrapped at all is still a finding - `wrapped_wikilinks` - because
folding it here would otherwise make it invisible.
"""
return _LINE_BREAK_RUN.sub(" ", raw).strip()
# Root-level files under kb/ that are not pages: the generated catalog map, log # Root-level files under kb/ that are not pages: the generated catalog map, log
# and provenance index, plus the two documents that constrain the tree rather # and provenance index, plus the two documents that constrain the tree rather
@@ -105,7 +124,24 @@ def extract_wikilinks(body: str) -> set[str]:
notation, and counting it made a page that documents the wiki look like it notation, and counting it made a page that documents the wiki look like it
linked to something that need not exist. linked to something that need not exist.
""" """
return {m.group(1).strip() for m in WIKILINK_RE.finditer(strip_code_spans(body))} return {
normalize_link_target(m.group(1)) for m in WIKILINK_RE.finditer(strip_code_spans(body))
}
def wrapped_wikilinks(body: str) -> list[str]:
"""The normalized targets of every wikilink in this body written across a
line break, in order of appearance, code masked out as everywhere else.
A renderer does not reliably read such a link as one, and the title rule
(`kb/CONTRACT.md` § Titles are identifiers) has no room for it - so it is
reported on its own, whether or not the folded title names a page.
"""
return [
normalize_link_target(m.group(1))
for m in WIKILINK_RE.finditer(strip_code_spans(body))
if "\n" in m.group(1)
]
def count_wikilinks(body: str) -> Counter[str]: def count_wikilinks(body: str) -> Counter[str]:
@@ -117,7 +153,9 @@ def count_wikilinks(body: str) -> Counter[str]:
the 248-page German translation were exactly that shape, and a set-based the 248-page German translation were exactly that shape, and a set-based
comparison reported all three as clean. comparison reported all three as clean.
""" """
return Counter(m.group(1).strip() for m in WIKILINK_RE.finditer(strip_code_spans(body))) return Counter(
normalize_link_target(m.group(1)) for m in WIKILINK_RE.finditer(strip_code_spans(body))
)
def find_nested_pages(kb_dir: Path, pages: dict[str, Page]) -> list[tuple[str, Page, int]]: def find_nested_pages(kb_dir: Path, pages: dict[str, Page]) -> list[tuple[str, Page, int]]:
+22 -1
View File
@@ -42,6 +42,8 @@ from chemenu.kb_scan import (
inbound_links, inbound_links,
iter_kb_pages, iter_kb_pages,
load_kb_pages, load_kb_pages,
normalize_link_target,
wrapped_wikilinks,
) )
from chemenu.titles import PATH_BUDGET, collision_key, path_length, title_problems from chemenu.titles import PATH_BUDGET, collision_key, path_length, title_problems
from chemenu.type_resolver import resolver from chemenu.type_resolver import resolver
@@ -323,6 +325,14 @@ def run_lint(kb_dir: Path) -> dict:
for target in sorted(targets) for target in sorted(targets)
if target not in pages if target not in pages
] ]
# The graph above already reads a wrapped link as the title it folds to,
# so a wrapped link to an existing page is no longer a broken one. It is
# still a defect of its own - see kb_scan.wrapped_wikilinks().
wrapped_links = [
{"page": title, "target": target}
for title, page in sorted(pages.items())
for target in sorted(set(wrapped_wikilinks(page.body)))
]
inbound = inbound_links({t: v for t, v in graph.items() if t != "index"}) inbound = inbound_links({t: v for t, v in graph.items() if t != "index"})
orphan_pages = sorted( orphan_pages = sorted(
@@ -352,7 +362,7 @@ def run_lint(kb_dir: Path) -> dict:
for path in [kb_dir / "index.md", *sorted(kb_dir.rglob(GENERATED_INDEX))] for path in [kb_dir / "index.md", *sorted(kb_dir.rglob(GENERATED_INDEX))]
if path.exists() if path.exists()
) )
index_links = {m.group(1).strip() for m in WIKILINK_RE.finditer(index_text)} index_links = {normalize_link_target(m.group(1)) for m in WIKILINK_RE.finditer(index_text)}
missing_from_index = sorted(set(pages) - index_links - {"index", "log"}) missing_from_index = sorted(set(pages) - index_links - {"index", "log"})
dangling_index_entries = sorted(index_links - set(pages)) dangling_index_entries = sorted(index_links - set(pages))
@@ -539,6 +549,7 @@ def run_lint(kb_dir: Path) -> dict:
"page_count": len(pages), "page_count": len(pages),
"frontmatter_errors": frontmatter_errors, "frontmatter_errors": frontmatter_errors,
"broken_links": broken_links, "broken_links": broken_links,
"wrapped_wikilinks": wrapped_links,
"orphan_pages": orphan_pages, "orphan_pages": orphan_pages,
"most_linked": most_linked, "most_linked": most_linked,
"inbound_counts": inbound_counts, "inbound_counts": inbound_counts,
@@ -600,6 +611,10 @@ def render_markdown(report: dict) -> str:
lines, "Broken Wikilinks", report["broken_links"], lines, "Broken Wikilinks", report["broken_links"],
lambda i: f"[[{i['page']}]] links to missing [[{i['target']}]]", lambda i: f"[[{i['page']}]] links to missing [[{i['target']}]]",
) )
_section(
lines, "Wrapped Wikilinks", report.get("wrapped_wikilinks", []),
lambda i: f"[[{i['page']}]] wraps a wikilink across lines - write it on one: [[{i['target']}]]",
)
_section(lines, "Orphan Pages (no inbound links)", report["orphan_pages"], lambda i: f"[[{i}]]") _section(lines, "Orphan Pages (no inbound links)", report["orphan_pages"], lambda i: f"[[{i}]]")
_section( _section(
lines, f"Most-Linked Pages (top {MOST_LINKED_COUNT} hubs)", report["most_linked"], lines, f"Most-Linked Pages (top {MOST_LINKED_COUNT} hubs)", report["most_linked"],
@@ -849,11 +864,17 @@ def default_report_path(report: dict) -> Path:
# it needs no migration - so failing a lint run on it would penalise an instance # it needs no migration - so failing a lint run on it would penalise an instance
# that never asked for that platform. # that never asked for that platform.
# #
# `wrapped_wikilinks` is hard from the start without tightening anything: every
# link it names was a hard `broken_links` finding before the link graph learned
# to fold a wrapped target, so an instance's lint is exactly as red as it was -
# only the finding now says what is actually wrong.
#
# One definition, used by `lint --fail-on-error` and by the eval scorecard: if # One definition, used by `lint --fail-on-error` and by the eval scorecard: if
# the two disagreed, a run could pass its score while lint refused it. # the two disagreed, a run could pass its score while lint refused it.
HARD_ERROR_KEYS = ( HARD_ERROR_KEYS = (
"frontmatter_errors", "frontmatter_errors",
"broken_links", "broken_links",
"wrapped_wikilinks",
"dangling_index_entries", "dangling_index_entries",
"duplicate_titles", "duplicate_titles",
"unportable_titles", "unportable_titles",
+29 -1
View File
@@ -1,5 +1,12 @@
from chemenu.frontmatter_io import write_page from chemenu.frontmatter_io import write_page
from chemenu.kb_scan import find_nested_pages, iter_kb_pages, load_kb_pages from chemenu.kb_scan import (
count_wikilinks,
extract_wikilinks,
find_nested_pages,
iter_kb_pages,
load_kb_pages,
wrapped_wikilinks,
)
def _names(kb_dir): def _names(kb_dir):
@@ -62,3 +69,24 @@ def test_find_nested_pages_flags_a_page_below_its_area(kb_dir):
def test_find_nested_pages_is_silent_for_pages_directly_in_an_area(kb_dir): def test_find_nested_pages_is_silent_for_pages_directly_in_an_area(kb_dir):
assert find_nested_pages(kb_dir, load_kb_pages(kb_dir)) == [] assert find_nested_pages(kb_dir, load_kb_pages(kb_dir)) == []
def test_a_wrapped_wikilink_reads_as_the_title_it_folds_to():
"""A link wrapped at a fixed column captured the break as part of the
title, so it matched no page and `lint` reported the page as missing."""
body = "See [[Foo Bar\n Target]] and [[Foo Bar\r\nTarget|alias]] and [[Other]]."
assert extract_wikilinks(body) == {"Foo Bar Target", "Other"}
assert count_wikilinks(body) == {"Foo Bar Target": 2, "Other": 1}
def test_wrapped_wikilinks_names_only_the_wrapped_ones():
body = "One [[Foo Bar\n Target]], one [[Foo Bar Target]], one [[A\n\nB]]."
assert wrapped_wikilinks(body) == ["Foo Bar Target", "A B"]
def test_a_wrapped_wikilink_inside_a_fence_is_notation():
# Only a fence can hold one: an inline code span is masked within one line
# (see markdown_code), so a span wrapped mid-link is read as prose.
body = "Example:\n\n```\n[[Foo Bar\n Target]]\n```\n"
assert wrapped_wikilinks(body) == []
assert extract_wikilinks(body) == set()
+40
View File
@@ -63,6 +63,46 @@ def test_lint_detects_broken_wikilink(kb_dir):
assert {"page": "gdeploy", "target": "Nonexistent Page"} in report["broken_links"] assert {"page": "gdeploy", "target": "Nonexistent Page"} in report["broken_links"]
def _gdeploy_saying(kb_dir, prose: str) -> None:
write_page(
kb_dir / "entities/tools/gdeploy.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": []},
f"\n# gdeploy\n\n## Description\n\n{prose}\n",
)
def test_a_wrapped_link_to_an_existing_page_is_wrapped_not_broken(kb_dir):
"""The reported case: the target exists under its one-line title, and the
finding used to read as a missing page with the line break in its name."""
_gdeploy_saying(kb_dir, "Notes in [[Source -\n Aurora]] for details.")
report = run_lint(kb_dir)
assert report["broken_links"] == []
assert report["wrapped_wikilinks"] == [{"page": "gdeploy", "target": "Source - Aurora"}]
assert has_hard_errors(report)
assert "wraps a wikilink across lines" in render_markdown(report)
def test_a_wrapped_link_to_a_missing_page_is_both_findings(kb_dir):
_gdeploy_saying(kb_dir, "See [[Nonexistent\n Page]] for details.")
report = run_lint(kb_dir)
assert {"page": "gdeploy", "target": "Nonexistent Page"} in report["broken_links"]
assert report["wrapped_wikilinks"] == [{"page": "gdeploy", "target": "Nonexistent Page"}]
def test_the_target_of_a_wrapped_link_is_not_an_orphan(kb_dir):
assert "Source - Aurora" in run_lint(kb_dir)["orphan_pages"] # the precondition
_gdeploy_saying(kb_dir, "Notes in [[Source -\n Aurora]] for details.")
assert "Source - Aurora" not in run_lint(kb_dir)["orphan_pages"]
def test_a_wrapped_link_inside_code_is_no_finding(kb_dir):
_gdeploy_saying(kb_dir, "Example:\n\n```\n[[Nonexistent\n Page]]\n```")
report = run_lint(kb_dir)
assert report["wrapped_wikilinks"] == []
assert report["broken_links"] == []
def test_lint_fixture_has_no_dangling_frontmatter_refs(kb_dir): def test_lint_fixture_has_no_dangling_frontmatter_refs(kb_dir):
assert run_lint(kb_dir)["dangling_frontmatter_refs"] == [] assert run_lint(kb_dir)["dangling_frontmatter_refs"] == []
+34
View File
@@ -28,6 +28,13 @@ def test_retarget_body_ignores_partial_title_matches():
assert page_ops.retarget_body("[[Old Thing]]", "Old", "New") == "[[Old Thing]]" assert page_ops.retarget_body("[[Old Thing]]", "Old", "New") == "[[Old Thing]]"
def test_retarget_body_repoints_a_wrapped_link_onto_one_line():
body = "See [[Old\n Title|the old one]] and [[Old\nTitle#Setup]]."
result = page_ops.retarget_body(body, "Old Title", "New")
assert result == "See [[New|the old one]] and [[New#Setup]]."
assert page_ops.body_references(body, "Old Title") == 2
def test_strip_link_bullets_removes_only_bare_bullets(): def test_strip_link_bullets_removes_only_bare_bullets():
body = ( body = (
"## Relationships\n\n" "## Relationships\n\n"
@@ -214,6 +221,33 @@ def test_rm_refuses_referenced_page_without_yes(patched_wiki):
page_ops.rm_command(page_title="Borealis", yes=False, dry_run=False) page_ops.rm_command(page_title="Borealis", yes=False, dry_run=False)
assert (patched_wiki / "entities/systems/Borealis.md").exists() assert (patched_wiki / "entities/systems/Borealis.md").exists()
def _gdeploy_wrapping_a_link_to_source_aurora(kb) -> None:
write_page(
kb / "entities/tools/gdeploy.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
"created": "2026-07-25", "modified": "2026-07-25", "related": [],
"sources": []},
"\n# gdeploy\n\n## Description\n\nNotes in [[Source -\n Aurora]] nightly.\n",
)
def test_rm_refuses_a_page_referenced_only_by_a_wrapped_link(patched_wiki, capsys):
"""Before the fold, the wrapped link was no reference at all, and the page
went without the `--yes` the inbound list is there to require."""
_gdeploy_wrapping_a_link_to_source_aurora(patched_wiki)
with pytest.raises(typer.Exit):
page_ops.rm_command(page_title="Source - Aurora", yes=False, dry_run=False)
assert (patched_wiki / "sources/notes/Source - Aurora.md").exists()
assert "- gdeploy" in capsys.readouterr().out
def test_rename_repoints_a_wrapped_link(patched_wiki):
_gdeploy_wrapping_a_link_to_source_aurora(patched_wiki)
page_ops.rename_command(old="Source - Aurora", new="Source - Aurora Notes", dry_run=False)
_frontmatter, body = read_page(patched_wiki / "entities/tools/gdeploy.md")
assert "Notes in [[Source - Aurora Notes]] nightly." in body
def test_rm_deletes_and_delinks_with_yes(patched_wiki): def test_rm_deletes_and_delinks_with_yes(patched_wiki):
page_ops.rm_command(page_title="Borealis", yes=True, dry_run=False) page_ops.rm_command(page_title="Borealis", yes=True, dry_run=False)