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
@@ -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
- 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
- lint: a wikilink wrapped across a line break is its own finding; rename and rm see it
<!-- /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
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
`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
names on Windows and macOS, filename/title mismatches, broken
`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
`[^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
decision record - is the instance's, in
[kb/CONVENTIONS.md § Naming](CONVENTIONS.md#naming).
+2 -1
View File
@@ -1120,8 +1120,9 @@ Run structural lint checks against kb/.
**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.
- 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.
- 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.
+6 -1
View File
@@ -59,7 +59,8 @@ __all__ = [
budget=cli_contract.Budget.COUNTED,
),
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 "
"mismatches, uncovered raw files, broken `raw_files:` refs, raw files claimed by more "
"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, "
"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.",
"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.lint_core import find_misplaced
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 (
CITE_REF_RE,
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
# 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().
# 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"\[\[([^\[\]|#]+)((?:[|#][^\[\]]*)?)\]\]")
@@ -76,7 +78,7 @@ def retarget_body(body: str, old: str, new: str) -> str:
def replace(match: re.Match) -> str:
target, suffix = match.group(1), match.group(2)
if target.strip() != old:
if normalize_link_target(target) != old:
return match.group(0)
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:
"""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]:
+40 -2
View File
@@ -12,6 +12,25 @@ from chemenu.page import Page
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
# 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
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]:
@@ -117,7 +153,9 @@ def count_wikilinks(body: str) -> Counter[str]:
the 248-page German translation were exactly that shape, and a set-based
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]]:
+22 -1
View File
@@ -42,6 +42,8 @@ from chemenu.kb_scan import (
inbound_links,
iter_kb_pages,
load_kb_pages,
normalize_link_target,
wrapped_wikilinks,
)
from chemenu.titles import PATH_BUDGET, collision_key, path_length, title_problems
from chemenu.type_resolver import resolver
@@ -323,6 +325,14 @@ def run_lint(kb_dir: Path) -> dict:
for target in sorted(targets)
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"})
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))]
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"})
dangling_index_entries = sorted(index_links - set(pages))
@@ -539,6 +549,7 @@ def run_lint(kb_dir: Path) -> dict:
"page_count": len(pages),
"frontmatter_errors": frontmatter_errors,
"broken_links": broken_links,
"wrapped_wikilinks": wrapped_links,
"orphan_pages": orphan_pages,
"most_linked": most_linked,
"inbound_counts": inbound_counts,
@@ -600,6 +611,10 @@ def render_markdown(report: dict) -> str:
lines, "Broken Wikilinks", report["broken_links"],
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, 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
# 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
# the two disagreed, a run could pass its score while lint refused it.
HARD_ERROR_KEYS = (
"frontmatter_errors",
"broken_links",
"wrapped_wikilinks",
"dangling_index_entries",
"duplicate_titles",
"unportable_titles",
+29 -1
View File
@@ -1,5 +1,12 @@
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):
@@ -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):
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"]
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):
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]]"
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():
body = (
"## 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)
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):
page_ops.rm_command(page_title="Borealis", yes=True, dry_run=False)