From c261b8f4caa566249e44483eea75c653218f7d1c Mon Sep 17 00:00:00 2001 From: Torben Nehmer Date: Sat, 3 Oct 2026 11:19:10 +0200 Subject: [PATCH] fix: a wikilink wrapped across a line break is its own lint finding; rename and rm see it (#115) 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 Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2 --- CHANGES.md | 17 +++++++++-- VERSION | 2 +- instructions/wiki-lint/SKILL.md | 3 +- kb/CONTRACT.md | 6 ++++ tools/CONTRACT.md | 3 +- tools/chemenu/commands/lint.py | 7 ++++- tools/chemenu/commands/page_ops.py | 10 +++++-- tools/chemenu/kb_scan.py | 42 ++++++++++++++++++++++++++-- tools/chemenu/lint_core.py | 23 ++++++++++++++- tools/chemenu/tests/test_kb_scan.py | 30 +++++++++++++++++++- tools/chemenu/tests/test_lint.py | 40 ++++++++++++++++++++++++++ tools/chemenu/tests/test_page_ops.py | 34 ++++++++++++++++++++++ 12 files changed, 204 insertions(+), 13 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 975d8eb..2b63faa 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 -### 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 diff --git a/VERSION b/VERSION index 268c2ea..0bad119 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -8.0.0-beta.29 +8.0.0-beta.30 diff --git a/instructions/wiki-lint/SKILL.md b/instructions/wiki-lint/SKILL.md index fea47d7..64c8e9e 100644 --- a/instructions/wiki-lint/SKILL.md +++ b/instructions/wiki-lint/SKILL.md @@ -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 .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, diff --git a/kb/CONTRACT.md b/kb/CONTRACT.md index 22f33dc..7de3c5b 100644 --- a/kb/CONTRACT.md +++ b/kb/CONTRACT.md @@ -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). diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index 9a4ad5c..dae8ac2 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -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. diff --git a/tools/chemenu/commands/lint.py b/tools/chemenu/commands/lint.py index 953bcee..51c00f4 100644 --- a/tools/chemenu/commands/lint.py +++ b/tools/chemenu/commands/lint.py @@ -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:` " diff --git a/tools/chemenu/commands/page_ops.py b/tools/chemenu/commands/page_ops.py index ec6f886..cd3a435 100644 --- a/tools/chemenu/commands/page_ops.py +++ b/tools/chemenu/commands/page_ops.py @@ -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]: diff --git a/tools/chemenu/kb_scan.py b/tools/chemenu/kb_scan.py index 8baa983..f017dd3 100644 --- a/tools/chemenu/kb_scan.py +++ b/tools/chemenu/kb_scan.py @@ -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]]: diff --git a/tools/chemenu/lint_core.py b/tools/chemenu/lint_core.py index 4bb54b4..d8b77f4 100644 --- a/tools/chemenu/lint_core.py +++ b/tools/chemenu/lint_core.py @@ -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", diff --git a/tools/chemenu/tests/test_kb_scan.py b/tools/chemenu/tests/test_kb_scan.py index 373bcec..c9bcdd2 100644 --- a/tools/chemenu/tests/test_kb_scan.py +++ b/tools/chemenu/tests/test_kb_scan.py @@ -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() diff --git a/tools/chemenu/tests/test_lint.py b/tools/chemenu/tests/test_lint.py index 5f740da..a9afe0e 100644 --- a/tools/chemenu/tests/test_lint.py +++ b/tools/chemenu/tests/test_lint.py @@ -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"] == [] diff --git a/tools/chemenu/tests/test_page_ops.py b/tools/chemenu/tests/test_page_ops.py index 5180756..9e373bc 100644 --- a/tools/chemenu/tests/test_page_ops.py +++ b/tools/chemenu/tests/test_page_ops.py @@ -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)