feat: page titles must be valid, unique file names on Windows and macOS - new/rename/move refuse, lint reports Unportable Titles, new never overwrites (#155)
CI / verify (push) Successful in 1m37s
Release / release (push) Successful in 37s

Files changed:
- CHANGES.md
- README.md
- VERSION
- instructions/page-lifecycle.md
- instructions/wiki-lint/SKILL.md
- kb/CONTRACT.md
- kb/CONVENTIONS.md
- kb/CONVENTIONS.md.template
- tools/CONTRACT.md
- tools/chemenu/commands/_util.py
- tools/chemenu/commands/lint.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/page_ops.py
- tools/chemenu/lint_core.py
- tools/chemenu/tests/test_lint.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_page_ops.py
- tools/chemenu/tests/test_titles.py
- tools/chemenu/titles.py
This commit is contained in:
torben committed 2026-09-30 06:29:28 +02:00
1 parent 94deccb18d
commit 8be5e6e5f3
19 files changed
+654 -33

No files matched your search

+47 -1
View File
@@ -59,14 +59,19 @@ concern - readable here, never shipped as something to parse.
---
## 7.1.0-beta.32 - 2026-09-29 - dist upgrade --latest: one-command update from the release feed
## 8.0.0-beta.1 - 2026-09-30 - Page titles must form valid, unique file names on Windows and macOS
**Author:** Torben Nehmer
**Breaking Change:** Page titles must form valid, unique file names on Windows and macOS: new and rename refuse forbidden characters, reserved names (including INDEX and COLLECTION), a trailing dot or space, and titles that collide with another page by case or Unicode normalization; lint reports existing violations as hard errors - rename each affected page with tools/wikitool rename
**Migration:** none required - No page format changes; the rule only refuses titles, and each affected page is renamed individually with tools/wikitool rename
<!-- wikitool:bumps -->
**High impact**
- wikitool: one data record per command - `-h`, index and CONTRACT.md render from cli_contract (Gitea #121 Phase 1)
- dist upgrade --latest: one-command update from the release feed
- Page titles must form valid, unique file names on Windows and macOS
**Medium impact**
- CalDAV task-tracker provider (Nextcloud Tasks, iOS Reminders); review reports unknown-value findings instead of skipping them
@@ -103,6 +108,47 @@ concern - readable here, never shipped as something to parse.
- new_page/type_resolver comments no longer claim only entities declare a layout:
<!-- /wikitool:bumps -->
### Page titles must form valid, unique file names on Windows and macOS (Gitea #155)
A title is the wiki's only identifier for a page and becomes the file name one to one, but nothing
checked that the name was usable outside Linux. A corpus written on Linux could not be checked out
on Windows (`CON.md`, `A: B.md`, a trailing dot) or collapsed two pages into one on macOS and
Windows (`Foo.md` and `FOO.md`, or the same accented title in NFC and NFD). The rule is now stated
once, in `kb/CONTRACT.md` § "Titles are identifiers", implemented as pure functions in
`chemenu/titles.py`, and enforced on every platform - a corpus written on Linux is read on the
others.
A title is refused when it is empty, contains one of `< > : " / \ | ? *` or a control character,
ends with a dot or a space, or starts - before its first dot, ignoring case and trailing spaces -
with a Windows device name (`CON`, `PRN`, `AUX`, `NUL`, `COM0`-`COM9`, `LPT0`-`LPT9`, including the
superscript digits) or with `INDEX` or `COLLECTION`, the two names the stack owns next to a page.
Two titles collide when their NFC-normalized, case-folded forms are equal. The full title,
including a type's `title_prefix`, is what is checked.
- `new` checks the title for every type and every root, then the collision against the corpus for
`kb/` pages, then that the target file does not exist - all before anything is created, the
tracker project included. This last check also fixes a data-loss bug found on the way: for the
`root: repo` types, `new instruction --name gates` silently overwrote `instructions/gates.md`.
`new` never overwrites an existing file now.
- `rename --to` is checked the same way, also under `--dry-run`, with the page itself excluded so
a case-only rename (`Foo` to `FOO`) still works. `rename --from` is deliberately never checked:
it is how a page that is already invalid gets fixed.
- `move` refuses a target that an existing entry claims under another case or normalization, for
a single move and in `--reconcile` alike.
- `lint` reports existing violations under **Unportable Titles**, with the colliding paths named
and a `wikitool rename` remedy. The finding is a hard error at every `kb_version` and is
deliberately not migration-gated: there is no migration for it, so `kb_version` never advances
on its account, and each affected page is renamed individually.
The bump is `--major` because a corpus that carries such a title stops passing `lint --fail-on-error`
after the upgrade; a demo/testbed corpus and the shipped instructions are clean. Uncertain and
refused conservatively: whether `COM0`, `LPT0` and the superscript forms are device names on every
Windows version differs, so all of them are refused.
`tools/CONTRACT.md` is regenerated, and `kb/CONTRACT.md`, `kb/CONVENTIONS.md` and its template,
`instructions/page-lifecycle.md`, `instructions/wiki-lint/SKILL.md` and `README.md` carry the
rule.
### dist upgrade --latest: one-command update from the release feed (Gitea #161)
Updating a tarball instance took a manual detour: `version notes`, then fetching the `.tar.gz` and
+4
View File
@@ -322,6 +322,10 @@ Ingest incoming/my-notes.md
- Use human-readable titles with spaces for files: `Hybrid Search.md`, not kebab-case
- Use singular for entities: `HA Integration.md` (not `HA Integrations.md`)
- Use wikilinks matching the file name exactly: `[[Entity Name]]`
- A title is a file name, so it has to work on Windows and macOS as well: no `< > : " / \ | ? *`,
no reserved names such as `CON` or `Index`, no trailing dot, and no second page whose title
differs only by case. `wikitool new` and `wikitool rename` refuse such titles, `wikitool lint`
reports existing ones, and `kb/CONTRACT.md` § Titles are identifiers has the full rule
- **Titles follow the subject's own established name, not the wiki's language.** `Act Runner` and
`GitOps Ownership Model` keep theirs. A title is the only identifier a page has - it also lives
in every wikilink and citation id pointing at it - so translating one is a rename, never an
+1 -1
View File
@@ -1 +1 @@
7.1.0-beta.32
8.0.0-beta.1
+20 -3
View File
@@ -14,6 +14,17 @@ frontmatter reference arrays (`related:`, `sources:`, `entities:`, `concepts:`).
hand.** Each of the commands below rewrites all three places at once; hand-editing rewrites
one and leaves the others pointing at nothing.
<!-- wikitool:toc -->
## Contents
- [Rename](#rename)
- [Delete](#delete)
- [Move](#move)
- [Drop a single reference](#drop-a-single-reference)
- [Afterwards](#afterwards)
- [Scope](#scope)
<!-- /wikitool:toc -->
## Rename
```bash
@@ -25,6 +36,11 @@ Repoints body wikilinks (aliases and anchors preserved), a citation id derived f
title (both its Footnotes definition and every `[^cite-id]` reference to it), the page's own
H1, and every frontmatter reference array the type declares in `page_ref_fields:`.
`--to` has to be a valid, unique file name on every platform, and `--dry-run` refuses it the
same way the real run does. The rule is in `kb/CONTRACT.md` § Titles are identifiers. Only `--to`
is checked, so this is also the fix for `lint`'s **Unportable Titles** finding: rename the page
away from the title that breaks the rule. A change of case alone (`Foo` to `FOO`) is allowed.
**If `--from` is not a page but is referenced**, rename instead repoints those references onto
the existing `--to` page and moves nothing. That is the fix for a reference spelled
`act_runner` when the page is `Act Runner`.
@@ -62,9 +78,10 @@ reference anywhere in the wiki needs updating.
to do. `wikitool lint`'s **Misplaced Pages** finding is the advisory this fixes - it is not a
hard error, so an unreconciled corpus is not a broken one, only one `move` would tidy.
A destination that already holds a file with the page's name is refused, not silently
overwritten - that only happens on a pre-existing duplicate-title collision, which `lint`'s
**Duplicate Titles** finding reports separately.
A destination that already holds a file with the page's name - or one that differs from it only
in case or Unicode normalization - is refused, not silently overwritten. That only happens on a
pre-existing duplicate-title collision, which `lint`'s **Duplicate Titles** and **Unportable
Titles** findings report separately.
## Drop a single reference
+2 -1
View File
@@ -42,7 +42,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,
catalog drift, missing fields, duplicate titles, filename/title mismatches, broken
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,
schema failures, citation/frontmatter drift, and edges whose label is missing, not authorised
by the source collection, or redundant beside a specific label on the reverse direction.
+18
View File
@@ -120,6 +120,24 @@ Which *form* those titles take - spaces or kebab-case, singular or plural, what
decision record - is the instance's, in
[kb/CONVENTIONS.md § Naming](CONVENTIONS.md#naming).
**A title is also a file name, so it must be one on every platform** - Windows and macOS as
well as Linux, checked wherever the command runs: a corpus written on Linux is checked out on
the others, and a title that Linux accepts and Windows refuses breaks every clone there. The full
title counts, after any `title_prefix`. A title must not:
- be empty, or end with a dot or a space
- contain `<` `>` `:` `"` `/` `\` `|` `?` `*` or a control character
- start, before its first dot and regardless of case, with a Windows device name (`CON`, `PRN`,
`AUX`, `NUL`, `COM0`-`COM9`, `LPT0`-`LPT9`, and the superscript forms `COM¹`-`COM³`,
`LPT¹`-`LPT³`) or with a name the stack itself keeps beside a page (`INDEX`, `COLLECTION`)
- collide with another page once both are normalized to NFC and compared by `casefold` - NTFS and
APFS fold case, and APFS folds NFC and NFD as well
`wikitool new` and `wikitool rename` refuse such a title (`new` for every type, whatever root it
writes to; `rename` only for `--to`, so a page that already breaks the rule can always be renamed
away from it), and never write over an existing file. `wikitool lint` reports existing pages that
break the rule as Unportable Titles, a hard error at every `kb_version`.
## Every page should
- [ ] Carry a clear, descriptive title and a summary near the top
+3 -2
View File
@@ -96,8 +96,9 @@ What to name a thing: projects use their repository or common name; systems a de
name; tools the tool's own name; technologies their standard spelling and capitalization;
people a full name or common handle.
The one naming fact that is *not* a choice, and therefore lives in the contract: the filename
stem is the page title, and `[[wikilinks]]` must match it exactly.
The naming facts that are *not* a choice, and therefore live in the contract: the filename
stem is the page title, `[[wikilinks]]` must match it exactly, and the title must be a valid,
unique file name on every platform (`kb/CONTRACT.md` § Titles are identifiers).
## Tone
+3 -2
View File
@@ -81,8 +81,9 @@ them - they are rebuilt from frontmatter on every write. Any *other* heading is
- {The ADR prefix, if this instance files decisions as pages.}
- {What to name a thing: projects, systems, tools, technologies, people.}
The one naming fact that is *not* a choice, and therefore lives in the contract: the filename
stem is the page title, and `[[wikilinks]]` must match it exactly.
The naming facts that are *not* a choice, and therefore live in the contract: the filename
stem is the page title, `[[wikilinks]]` must match it exactly, and the title must be a valid,
unique file name on every platform (`kb/CONTRACT.md` § Titles are identifiers).
## Tone
+11 -4
View File
@@ -180,6 +180,7 @@ Scaffold a new wiki page of any type.
- 0 success
- 1 A page with this title already exists, the type is unknown, or a `--set` value is invalid
- 1 The title is not a valid file name (forbidden character, control character, reserved name such as `CON` or `Index`, trailing dot or space, empty), collides with another page by case or Unicode normalization, or the target file already exists
- 1 A `raw_files` path does not exist
- 1 A capture field the type-spec requires is missing, or set to `unknown`
- 1 `--resume` with a type other than `project`
@@ -191,6 +192,7 @@ Scaffold a new wiki page of any type.
**ON FAILURE**
- A page with this title already exists, the type is unknown, or a `--set` value is invalid -> Not transient - fix the argument and retry once
- The title is not a valid file name (forbidden character, control character, reserved name such as `CON` or `Index`, trailing dot or space, empty), collides with another page by case or Unicode normalization, or the target file already exists -> Not transient - choose another title and retry once. Nothing was created, and for `new project` no tracker project either
- A `raw_files` path does not exist -> Not transient - fix the path and retry once
- A capture field the type-spec requires is missing, or set to `unknown` -> Pass it explicitly (e.g. `--set fidelity=verbatim --set authority=reporting`), then retry once
- `--resume` with a type other than `project` -> Drop `--resume` and retry once
@@ -205,6 +207,8 @@ Scaffold a new wiki page of any type.
**NOTES**
- A title becomes a file name, so it must be valid and unique on Windows and macOS as well as Linux, whichever platform runs the command and whichever root the type writes to. The rule is `kb/CONTRACT.md` § Titles are identifiers; it is checked on the full title, after `title_prefix`.
- `new` never overwrites: a file already at the target - or one a case-insensitive file system would treat as the same file - is refused for every root, `instructions/` included.
- The type-spec drives everything: fields, directory (`base_dir`/`layout`), title prefix, and template. `types list`/`types describe` show what a type requires.
- A schema `default:` is materialized only for a field the schema also lists in `required:`.
- `--set` is repeatable, and comma-separated values fill array fields. An element that itself contains a comma is written `\,`, or passed as its own repeated `--set` for that field - repeating an array field appends.
@@ -463,14 +467,14 @@ Rename a page, or repoint references that name a page that never existed.
- 0 success
- 1 `--from` equals `--to`
- 1 Neither `--from` nor `--to` is a page
- 1 The `--to` title is already taken
- 1 The `--to` title is already taken - also by a page that differs only in case or Unicode normalization, or by a file in the page's directory - or is not a valid file name (see `kb/CONTRACT.md` § Titles are identifiers)
- 1 A page write failed partway; nothing was renamed on disk
**ON FAILURE**
- `--from` equals `--to` -> Fix the arguments and retry once
- Neither `--from` nor `--to` is a page -> Create the page first with `wikitool new`, or drop the reference with `wikitool xref remove`
- The `--to` title is already taken -> Choose another title and retry once
- The `--to` title is already taken - also by a page that differs only in case or Unicode normalization, or by a file in the page's directory - or is not a valid file name (see `kb/CONTRACT.md` § Titles are identifiers) -> Choose another title and retry once. Checked under `--dry-run` too
- A page write failed partway; nothing was renamed on disk -> Check `git status`, resolve the write failure (permissions/disk), then re-run the full command - safe, since each page's rewrite is idempotent
**NEVER**
@@ -483,6 +487,7 @@ Rename a page, or repoint references that name a page that never existed.
- If `--from` is *not* a page but is referenced, it instead repoints those references onto the existing `--to` page and moves nothing - the fix for a reference spelled `act_runner` when the page is `Act Runner`.
- Each page's rewrite is idempotent, so a re-run as-is is safe. If a write fails midway, nothing is renamed on disk and the error lists what was updated.
- `--dry-run` lists every page it would change; run it first to see the blast radius.
- Only `--to` is checked against the title rule. A page whose current title breaks it (`lint`'s Unportable Titles) can always be renamed away from it, and a title that differs from the page's own only by case (`Foo` to `FOO`) is allowed.
**SEE ALSO**
@@ -570,14 +575,14 @@ Move a page (or every misplaced page) to the directory its type-spec computes.
- 0 success
- 1 Neither or both of `--page`/`--reconcile` given
- 1 The named page is not found, or has no `type:` to compute a placement from
- 1 The destination already exists (a pre-existing duplicate-stem collision) - refused rather than silently skipped
- 1 The destination already holds an entry with the same name, or one that differs only in case or Unicode normalization (a pre-existing duplicate-stem collision) - refused rather than silently skipped
- 1 `--reconcile` failed partway
**ON FAILURE**
- Neither or both of `--page`/`--reconcile` given -> Fix the arguments and retry once
- The named page is not found, or has no `type:` to compute a placement from -> Fix the title, or give the page its `type:`, then retry once
- The destination already exists (a pre-existing duplicate-stem collision) - refused rather than silently skipped -> Resolve the collision, then retry
- The destination already holds an entry with the same name, or one that differs only in case or Unicode normalization (a pre-existing duplicate-stem collision) - refused rather than silently skipped -> Resolve the collision, then retry
- `--reconcile` failed partway -> Safe to retry as-is - `--reconcile` only re-moves what is still misplaced
**NEVER**
@@ -1097,6 +1102,7 @@ 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.
- 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.
- 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.
@@ -1110,6 +1116,7 @@ Run structural lint checks against kb/.
- `wiki-lint` skill - the procedure that runs this
- `wikitool move --reconcile` - fixes Misplaced and Nested Pages
- `wikitool rename` - fixes Unportable Titles
- `wikitool log status` - whether a full lint is due
#### `search`
+64 -6
View File
@@ -9,6 +9,7 @@ from typing import Any, Dict, Optional
import typer
from rich.console import Console
from rich.markup import escape
console = Console()
@@ -217,15 +218,72 @@ def rel_path(path: Path) -> str:
return str(path)
def check_collision(name: str) -> None:
"""Fail if any page under kb/ already has `name` as its filename stem.
def check_title(name: str) -> None:
"""Fail if `name` cannot be a page title (see `chemenu.titles`).
A title is a file name, so this runs on every platform and for every type,
whether or not the page lands under `kb/`.
"""
from chemenu.titles import title_problems
problems = title_problems(name)
if problems:
fail(escape(f"'{name}' cannot be a page title: " + "; ".join(problems) + "."))
def check_collision(name: str, *, ignore: Path | None = None) -> None:
"""Fail if a page under kb/ already has a title that collides with `name`.
The stem *is* the page title and wikilinks resolve by title alone, so two
files sharing a stem in different directories are indistinguishable to
every link in the wiki. Shared by `new` and `rename`.
every link in the wiki. Titles that differ only by case or Unicode
normalization collide as well, because NTFS and APFS fold them into one
file. `ignore` is the page being renamed, which may only change its case.
Shared by `new` and `rename`.
"""
from chemenu import config
from chemenu.kb_scan import iter_kb_pages
from chemenu.titles import collision_key
for path in config.KB_DIR.rglob("*.md"):
if path.stem == name:
fail(f"A page titled '{name}' already exists at {rel_path(path)}")
wanted = collision_key(name)
for path in iter_kb_pages(config.KB_DIR):
if path == ignore or collision_key(path.stem) != wanted:
continue
note = "" if path.stem == name else " (titles are compared without regard to case or Unicode normalization)"
fail(escape(
f"A page titled '{path.stem}' already exists at {rel_path(path)}, which collides "
f"with '{name}'{note}"
))
def target_conflict(path: Path, *, ignore: Path | None = None) -> Path | None:
"""The existing entry in `path`'s directory that `path` would clash with,
or None. Compared by `collision_key`, so it does not depend on the file
system the check happens to run on."""
from chemenu.titles import collision_key
if not path.parent.is_dir():
return None
wanted = collision_key(path.name)
for entry in sorted(path.parent.iterdir()):
if entry == ignore:
continue
if collision_key(entry.name) == wanted:
return entry
return None
def check_target_free(path: Path, *, ignore: Path | None = None) -> None:
"""Fail if writing `path` would overwrite, or land beside, an existing entry
that a case-insensitive file system would treat as the same file.
Holds for every root: `check_collision` only sees pages under kb/, so it
could not stop `new instruction --name gates` from overwriting
`instructions/gates.md`.
"""
clash = target_conflict(path, ignore=ignore)
if clash is not None:
fail(escape(
f"Cannot write {rel_path(path)}: {rel_path(clash)} already exists there "
"(names are compared without regard to case or Unicode normalization)."
))
+5
View File
@@ -64,6 +64,10 @@ __all__ = [
"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.",
"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:` "
@@ -97,6 +101,7 @@ __all__ = [
see_also=(
"`wiki-lint` skill - the procedure that runs this",
"`wikitool move --reconcile` - fixes Misplaced and Nested Pages",
"`wikitool rename` - fixes Unportable Titles",
"`wikitool log status` - whether a full lint is due",
),
))
+21
View File
@@ -32,6 +32,8 @@ from chemenu import cli_contract, config, tasks
from chemenu.commands._util import (
check_collision,
check_raw_files_exist,
check_target_free,
check_title,
fail,
needs_clearance,
parse_set_fields,
@@ -375,6 +377,13 @@ def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]:
gates=("human-intervention-required (`new project` only)",),
),
notes=(
"A title becomes a file name, so it must be valid and unique on Windows and macOS as "
"well as Linux, whichever platform runs the command and whichever root the type writes "
"to. The rule is `kb/CONTRACT.md` § Titles are identifiers; it is checked on the full "
"title, after `title_prefix`.",
"`new` never overwrites: a file already at the target - or one a case-insensitive file "
"system would treat as the same file - is refused for every root, `instructions/` "
"included.",
"The type-spec drives everything: fields, directory (`base_dir`/`layout`), title "
"prefix, and template. `types list`/`types describe` show what a type requires.",
"A schema `default:` is materialized only for a field the schema also lists in "
@@ -417,6 +426,14 @@ def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]:
"value is invalid",
reaction="Not transient - fix the argument and retry once",
),
cli_contract.Failure(
cause="The title is not a valid file name (forbidden character, control "
"character, reserved name such as `CON` or `Index`, trailing dot or space, empty), "
"collides with another page by case or Unicode normalization, or the target file "
"already exists",
reaction="Not transient - choose another title and retry once. Nothing was created, "
"and for `new project` no tracker project either",
),
cli_contract.Failure(
cause="A `raw_files` path does not exist",
reaction="Not transient - fix the path and retry once",
@@ -533,6 +550,9 @@ def new_page_command(
except ValueError as exc:
fail(str(exc))
page_title = f"{title_prefix}{name}"
# The title becomes a file name wherever the type writes, so the rule holds
# for every root - a page under `instructions/` is checked out on Windows too.
check_title(page_title)
if root == "kb":
# Title collisions matter because wikilinks resolve by title alone, so
# two pages sharing a stem are indistinguishable to every link in the
@@ -589,6 +609,7 @@ def new_page_command(
check_raw_files_exist(frontmatter["raw_files"])
path = target_dir / f"{page_title}.md"
check_target_free(path)
body = _apply_template_variables(
template,
{
+32 -13
View File
@@ -26,7 +26,15 @@ from typing import Optional
import typer
from chemenu import cli_contract, config, links
from chemenu.commands._util import check_collision, fail, rel_path, success
from chemenu.commands._util import (
check_collision,
check_target_free,
check_title,
fail,
rel_path,
success,
target_conflict,
)
from chemenu.frontmatter_io import write_page
from chemenu.lint_core import find_misplaced
from chemenu.page import Page
@@ -232,6 +240,9 @@ def inbound_pages(pages: dict[str, Page], title: str) -> list[str]:
"Each page's rewrite is idempotent, so a re-run as-is is safe. If a write fails "
"midway, nothing is renamed on disk and the error lists what was updated.",
"`--dry-run` lists every page it would change; run it first to see the blast radius.",
"Only `--to` is checked against the title rule. A page whose current title breaks it "
"(`lint`'s Unportable Titles) can always be renamed away from it, and a title that "
"differs from the page's own only by case (`Foo` to `FOO`) is allowed.",
),
failures=(
cli_contract.Failure(
@@ -244,8 +255,10 @@ def inbound_pages(pages: dict[str, Page], title: str) -> list[str]:
"`wikitool xref remove`",
),
cli_contract.Failure(
cause="The `--to` title is already taken",
reaction="Choose another title and retry once",
cause="The `--to` title is already taken - also by a page that differs only in case "
"or Unicode normalization, or by a file in the page's directory - or is not a valid "
"file name (see `kb/CONTRACT.md` § Titles are identifiers)",
reaction="Choose another title and retry once. Checked under `--dry-run` too",
),
cli_contract.Failure(
cause="A page write failed partway; nothing was renamed on disk",
@@ -296,10 +309,15 @@ def rename_command(
f"to '{new}' would just move the dangling reference; create the page first "
"with `wikitool new ...`, or drop the reference with `wikitool xref remove`."
)
elif not dry_run:
check_collision(new)
elif new in pages:
fail(f"A page titled '{new}' already exists at {rel_path(pages[new].path)}")
else:
# `--from` is never checked: a page whose title breaks the rule has to
# stay renamable, or `lint`'s finding would have no remedy. The page
# itself is excluded from the collision checks so `Foo` -> `FOO` works.
# All three run under `--dry-run` too - a dry run that promises a
# rename the real run refuses is worse than none.
check_title(new)
check_collision(new, ignore=target.path)
check_target_free(target.path.parent / f"{new}.md", ignore=target.path)
touched: list[str] = []
failed: list[str] = []
@@ -543,8 +561,9 @@ def _rmdir_if_emptied(directory: Path) -> bool:
reaction="Fix the title, or give the page its `type:`, then retry once",
),
cli_contract.Failure(
cause="The destination already exists (a pre-existing duplicate-stem collision) - "
"refused rather than silently skipped",
cause="The destination already holds an entry with the same name, or one that "
"differs only in case or Unicode normalization (a pre-existing duplicate-stem "
"collision) - refused rather than silently skipped",
reaction="Resolve the collision, then retry",
),
cli_contract.Failure(
@@ -598,8 +617,9 @@ def move_command(
collisions: list[str] = []
for title, page, target_dir in candidates:
new_path = target_dir / f"{title}.md"
if new_path.exists():
collisions.append(f"{title} (target {rel_path(new_path)} already exists)")
clash = target_conflict(new_path)
if clash is not None:
collisions.append(f"{title} (target {rel_path(clash)} already exists)")
continue
planned.append((title, page, target_dir, new_path))
@@ -657,8 +677,7 @@ def move_command(
return
new_path = target_dir / f"{page_title}.md"
if new_path.exists():
fail(f"Cannot move '{page_title}': {rel_path(new_path)} already exists.")
check_target_free(new_path)
if dry_run:
typer.echo(f"[dry-run] would move {rel_path(target.path)} -> {rel_path(new_path)}")
+47
View File
@@ -39,8 +39,10 @@ from chemenu.kb_scan import (
find_duplicate_title_paths,
find_nested_pages,
inbound_links,
iter_kb_pages,
load_kb_pages,
)
from chemenu.titles import collision_key, title_problems
from chemenu.type_resolver import resolver
# Style guide's one mechanically-checkable rule (hard oracle: a plain count).
@@ -242,6 +244,39 @@ def unsharded_collections(kb_dir: Path, pages: dict[str, Page]) -> list[dict]:
return findings
def unportable_titles(kb_dir: Path) -> list[dict]:
"""Pages whose title cannot be a file name on every platform, or collides
with another page by case or Unicode normalization.
Reported as `{title, path, problem}`. Exact stem duplicates are left to
`duplicate_titles`; here only names that differ yet fold together count.
"""
entries: list[dict] = []
by_key: dict[str, list[Path]] = {}
for path in iter_kb_pages(kb_dir):
by_key.setdefault(collision_key(path.stem), []).append(path)
for problem in title_problems(path.stem):
entries.append({"title": path.stem, "path": _repo_relative(path, kb_dir), "problem": problem})
for paths in by_key.values():
for path in paths:
others = [other for other in paths if other.stem != path.stem]
if others:
named = ", ".join(f"`{_repo_relative(other, kb_dir)}`" for other in others)
entries.append({
"title": path.stem,
"path": _repo_relative(path, kb_dir),
"problem": f"collides with {named} on a case-insensitive or normalizing file system",
})
return sorted(entries, key=lambda e: (e["path"], e["problem"]))
def _repo_relative(path: Path, kb_dir: Path) -> str:
try:
return str(path.relative_to(config.ROOT))
except ValueError:
return str(path.relative_to(kb_dir.parent))
def run_lint(kb_dir: Path) -> dict:
pages = load_kb_pages(kb_dir)
duplicate_titles = find_duplicate_title_paths(kb_dir, config.ROOT)
@@ -487,6 +522,7 @@ def run_lint(kb_dir: Path) -> dict:
"dangling_index_entries": dangling_index_entries,
"title_mismatches": title_mismatches,
"duplicate_titles": duplicate_titles,
"unportable_titles": unportable_titles(kb_dir),
"misplaced_pages": misplaced,
"nested_pages": nested,
"unsharded_collections": unsharded_collections(kb_dir, pages),
@@ -550,6 +586,12 @@ def render_markdown(report: dict) -> str:
lines, "Duplicate Titles (naming collisions)", report["duplicate_titles"],
lambda i: f"`{i['stem']}` -> {', '.join(f'`{p}`' for p in i['paths'])}",
)
_section(
lines, "Unportable Titles (not a valid, unique file name on Windows and macOS)",
report.get("unportable_titles", []),
lambda i: f"[[{i['title']}]] at `{i['path']}` - {i['problem']}; "
f"`wikitool rename --from \"{i['title']}\" --to \"<new title>\"` fixes it",
)
_section(
lines, "Filename / H1 Title Mismatches", report["title_mismatches"],
lambda i: f"[[{i['page']}]] H1 is '{i['h1']}'",
@@ -766,6 +808,10 @@ def default_report_path(report: dict) -> Path:
# generated catalog (`index rebuild`) silently mis-describes today, on every
# instance, at every version - see `nested_pages()` above.
#
# `unportable_titles` is hard from the start and deliberately absent from
# `MIGRATION_GATED_KEYS`: the rule needs no migration, so `kb_version` never
# advances for it and a gate would keep the finding advisory forever.
#
# 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 = (
@@ -773,6 +819,7 @@ HARD_ERROR_KEYS = (
"broken_links",
"dangling_index_entries",
"duplicate_titles",
"unportable_titles",
"nested_pages",
"broken_raw_refs",
"duplicate_raw_file_owners",
+68
View File
@@ -883,3 +883,71 @@ def test_redundant_see_also_reaches_the_rendered_report_and_the_summary(kb_dir):
summary = render_summary(report)
assert "Redundant see-also" in summary
assert "[[nearside]]" in summary and "depends-on" in summary
def _plain_page(path):
write_page(
path,
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": []},
f"\n# {path.stem}\n",
)
def test_lint_reports_a_reserved_device_name_as_a_hard_error(kb_dir):
_plain_page(kb_dir / "entities/tools/CON.md")
report = run_lint(kb_dir)
entry = next(i for i in report["unportable_titles"] if i["title"] == "CON")
assert entry["path"].endswith("entities/tools/CON.md")
assert "reserved device name" in entry["problem"]
assert has_hard_errors(report)
def test_lint_reports_case_variants_with_both_paths(kb_dir):
_plain_page(kb_dir / "entities/tools/Foo.md")
_plain_page(kb_dir / "concepts/foo.md")
report = run_lint(kb_dir)
entries = [i for i in report["unportable_titles"] if i["title"].casefold() == "foo"]
assert {i["title"] for i in entries} == {"Foo", "foo"}
by_title = {i["title"]: i for i in entries}
assert "concepts/foo.md" in by_title["Foo"]["problem"]
assert "entities/tools/Foo.md" in by_title["foo"]["problem"]
assert report["duplicate_titles"] == []
def test_lint_reports_an_nfd_title_against_an_nfc_title(kb_dir):
import unicodedata
_plain_page(kb_dir / "entities/tools" / f"{unicodedata.normalize('NFC', 'Café')}.md")
_plain_page(kb_dir / "concepts" / f"{unicodedata.normalize('NFD', 'Café')}.md")
report = run_lint(kb_dir)
assert len(report["unportable_titles"]) == 2
def test_lint_reports_a_page_named_index_below_a_collection(kb_dir):
_plain_page(kb_dir / "entities/tools/Index.md")
report = run_lint(kb_dir)
assert any(i["title"] == "Index" for i in report["unportable_titles"])
def test_lint_does_not_mistake_the_stacks_own_files_for_titles(kb_dir):
"""`COLLECTION.md` files and the kb root's meta files are not pages."""
report = run_lint(kb_dir)
assert report["unportable_titles"] == []
def test_unportable_titles_is_hard_whatever_the_corpus_version(kb_dir):
"""No migration exists for the rule, so `kb_version` never advances for it;
a gate would keep the finding advisory forever."""
from chemenu.lint_core import MIGRATION_GATED_KEYS
assert "unportable_titles" in HARD_ERROR_KEYS
assert "unportable_titles" not in MIGRATION_GATED_KEYS
assert "unportable_titles" in hard_error_keys(Version(1, 0, 0))
def test_rendered_report_names_the_remedy(kb_dir):
_plain_page(kb_dir / "entities/tools/CON.md")
text = render_markdown(run_lint(kb_dir))
assert "## Unportable Titles" in text
assert 'wikitool rename --from "CON"' in text
+113
View File
@@ -920,3 +920,116 @@ def test_source_page_accepts_a_raw_file_whose_name_has_a_comma(monkeypatch, kb_d
assert result.exit_code == 0, result.output
frontmatter, _ = read_page(kb_dir / "sources/notes/Source - Comma Source.md")
assert frontmatter["raw_files"] == ["raw/notes/Versioning, CI-CD.md"]
def _tree_snapshot(root: Path) -> dict[str, bytes]:
return {str(p.relative_to(root)): p.read_bytes() for p in sorted(root.rglob("*")) if p.is_file()}
def test_new_refuses_a_forbidden_character_and_writes_nothing(monkeypatch, kb_dir):
before = _tree_snapshot(kb_dir)
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", "A: B", "--set", "entity_type=system",
])
assert result.exit_code == 1
assert "':'" in result.output
assert _tree_snapshot(kb_dir) == before
def test_new_refuses_a_slash_and_creates_no_directory(monkeypatch, kb_dir):
before = sorted(p for p in kb_dir.rglob("*"))
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", "A/B", "--set", "entity_type=system",
])
assert result.exit_code == 1
assert sorted(kb_dir.rglob("*")) == before
@pytest.mark.parametrize("name", ["Index", "index", "collection", "COM¹", "CON", "Trailing.", ""])
def test_new_refuses_reserved_and_malformed_names(monkeypatch, kb_dir, name):
before = _tree_snapshot(kb_dir)
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", name, "--set", "entity_type=system",
])
assert result.exit_code == 1, result.output
assert "cannot be a page title" in result.output
assert _tree_snapshot(kb_dir) == before
def test_new_checks_the_prefixed_title_not_the_bare_name(monkeypatch, kb_dir):
"""`CON` is a device name, `Source - CON` is not."""
monkeypatch.setenv("WIKI_AUTHOR", "Torben")
_fixture_raw_file(monkeypatch, kb_dir, "raw/notes/con.md")
result = _invoke_new(monkeypatch, kb_dir, [
"new", "source", "--name", "CON",
"--set", "source_type=notes", "--set", "raw_files=raw/notes/con.md",
"--set", "fidelity=verbatim", "--set", "authority=reporting",
])
assert result.exit_code == 0, result.output
assert (kb_dir / "sources/notes/Source - CON.md").exists()
def test_new_refuses_a_title_that_differs_only_by_case(monkeypatch, kb_dir):
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", "AURORA", "--set", "entity_type=tool",
])
assert result.exit_code == 1
assert "aurora" in result.output
assert not list(kb_dir.rglob("AURORA.md"))
def test_new_refuses_an_nfd_title_against_an_nfc_page(monkeypatch, kb_dir):
import unicodedata
from chemenu.frontmatter_io import write_page
nfc = unicodedata.normalize("NFC", "Café")
write_page(
kb_dir / "entities/tools" / f"{nfc}.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": []},
f"\n# {nfc}\n",
)
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", unicodedata.normalize("NFD", "Café"),
"--set", "entity_type=system",
])
assert result.exit_code == 1
assert "already exists" in result.output
def test_new_project_with_a_bad_title_never_reaches_the_tracker(monkeypatch, kb_dir):
"""The tracker project is created before the page, so the title has to be
refused first - or a refused page would leave a project behind."""
root = kb_dir.parent
with _api_server([]) as (server, handler_cls):
_write_tasks_config(root, _base_url(server))
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "A: B", "--set", "responsibility=haus",
])
assert result.exit_code == 1, result.output
assert "cannot be a page title" in result.output
assert handler_cls.posted is False
assert not list(kb_dir.rglob("A: B.md"))
@pytest.mark.parametrize("name, existing", [("gates", "gates.md"), ("contract", "CONTRACT.md")])
def test_new_instruction_never_overwrites_an_existing_file(monkeypatch, tmp_path, name, existing):
"""`root: repo` types are outside the title namespace of kb/, but writing
over a file there is the same loss (found by reproducing #155)."""
(tmp_path / "instructions").mkdir()
original = "# original, hand-written\n"
(tmp_path / "instructions" / existing).write_text(original, encoding="utf-8")
result = _invoke_new_instruction(monkeypatch, tmp_path, ["new", "instruction", "--name", name])
assert result.exit_code == 1, result.output
assert "already exists" in result.output
assert (tmp_path / "instructions" / existing).read_text(encoding="utf-8") == original
assert sorted(p.name for p in (tmp_path / "instructions").iterdir()) == [existing]
def test_new_instruction_applies_the_title_rule_too(monkeypatch, tmp_path):
result = _invoke_new_instruction(monkeypatch, tmp_path, ["new", "instruction", "--name", "a:b"])
assert result.exit_code == 1
assert list((tmp_path / "instructions").iterdir()) == []
+61
View File
@@ -409,3 +409,64 @@ def test_move_reconcile_removes_every_directory_it_empties(patched_wiki):
assert not (patched_wiki / "entities/projects/kfchou").exists()
assert not (patched_wiki / "entities/projects/vanillaflava").exists()
assert not (patched_wiki / "entities/tools/misplaced-tool.md").exists()
def _page_at(kb, relative, title):
_write_misplaced(kb, relative, title, "tool")
@pytest.mark.parametrize("dry_run", [False, True])
@pytest.mark.parametrize("new", ["A: B", "A/B", "Index", "COM¹", "Trailing.", "AURORA", "Aurora"])
def test_rename_refuses_an_unportable_or_colliding_target(patched_wiki, dry_run, new):
"""Also under `--dry-run`: a dry run must not promise a rename the real
run refuses."""
before = {p: p.read_bytes() for p in patched_wiki.rglob("*.md")}
with pytest.raises(typer.Exit):
page_ops.rename_command(old="Borealis", new=new, dry_run=dry_run)
assert {p: p.read_bytes() for p in patched_wiki.rglob("*.md")} == before
def test_rename_may_change_only_the_case_of_the_page_itself(patched_wiki):
page_ops.rename_command(old="Borealis", new="BOREALIS", dry_run=False)
assert (patched_wiki / "entities/systems/BOREALIS.md").exists()
assert not (patched_wiki / "entities/systems/Borealis.md").exists()
def test_rename_dry_run_accepts_a_case_only_change(patched_wiki):
page_ops.rename_command(old="Borealis", new="BOREALIS", dry_run=True)
assert (patched_wiki / "entities/systems/Borealis.md").exists()
def test_rename_is_the_remedy_for_a_page_that_breaks_the_rule(patched_wiki):
"""`--from` is never checked, or lint's finding would have no fix."""
_page_at(patched_wiki, "entities/tools", "CON")
page_ops.rename_command(old="CON", new="Console", dry_run=False)
assert (patched_wiki / "entities/tools/Console.md").exists()
assert not (patched_wiki / "entities/tools/CON.md").exists()
def test_rename_refuses_a_target_file_that_is_not_a_page(patched_wiki):
"""A stack file beside the page is a collision even though it is no page."""
(patched_wiki / "entities/systems/COLLECTION.md").write_text("contract", encoding="utf-8")
with pytest.raises(typer.Exit):
page_ops.rename_command(old="Borealis", new="collection", dry_run=False)
def test_move_refuses_a_destination_that_differs_only_by_case(patched_wiki):
_page_at(patched_wiki, "entities/tools", "Dup")
(patched_wiki / "entities/zzz-wrong").mkdir()
_page_at(patched_wiki, "entities/zzz-wrong", "dup")
with pytest.raises(typer.Exit):
page_ops.move_command(page_title="dup", reconcile=False, dry_run=False)
assert (patched_wiki / "entities/tools/Dup.md").exists()
assert (patched_wiki / "entities/zzz-wrong/dup.md").exists()
def test_move_reconcile_skips_a_destination_that_differs_only_by_case(patched_wiki, capsys):
_page_at(patched_wiki, "entities/tools", "Dup")
(patched_wiki / "entities/zzz-wrong").mkdir()
_page_at(patched_wiki, "entities/zzz-wrong", "dup")
with pytest.raises(typer.Exit):
page_ops.move_command(page_title=None, reconcile=True, dry_run=False)
assert (patched_wiki / "entities/zzz-wrong/dup.md").exists()
assert (patched_wiki / "entities/tools/Dup.md").exists()
+71
View File
@@ -0,0 +1,71 @@
import unicodedata
import pytest
from chemenu.titles import collision_key, title_problems
@pytest.mark.parametrize("title", [
"aurora", "gateway.example.net", "Source - CON", "Source - Aurora Notes",
"COM10", "COMM", "Ünïcode Straße", "with space inside", ".hidden", "README",
"Log", "Contract", "a-b_c (d)",
])
def test_a_valid_title_has_no_problems(title):
assert title_problems(title) == []
@pytest.mark.parametrize("char", list('<>:"/\\|?*'))
def test_each_forbidden_character_is_named(char):
problems = title_problems(f"A{char}B")
assert len(problems) == 1
assert f"'{char}'" in problems[0]
def test_an_empty_title_is_refused():
assert title_problems("") == ["the title is empty"]
def test_a_control_character_is_named_by_code_point():
assert "U+0009" in title_problems("A\tB")[0]
assert "U+007F" in title_problems("A\x7fB")[0]
@pytest.mark.parametrize("title", ["A.", "A ", "A...", "A. "])
def test_a_trailing_dot_or_space_is_refused(title):
assert any("ends with a dot or a space" in p for p in title_problems(title))
@pytest.mark.parametrize("title", [
"CON", "con", "Prn", "AUX", "nul", "COM0", "COM1", "com9", "LPT0", "lpt9",
"COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³",
"CON.txt", "nul.tar.gz", "COM1.example", "CON .x",
])
def test_windows_device_names_are_refused_before_the_first_dot(title):
problems = title_problems(title)
assert problems and "reserved device name" in problems[0]
@pytest.mark.parametrize("title", ["INDEX", "Index", "index", "collection", "Collection.md", "COLLECTION"])
def test_stack_names_are_refused(title):
problems = title_problems(title)
assert problems and "reserved for a file the stack" in problems[0]
def test_the_rule_reads_the_whole_title_so_a_prefix_lifts_a_reserved_name():
assert title_problems("CON") != []
assert title_problems("Source - CON") == []
assert title_problems("Source - A: B") != []
def test_every_problem_is_reported_at_once():
assert len(title_problems("CON.")) == 2
def test_collision_key_folds_case_and_normalization():
nfc = unicodedata.normalize("NFC", "Café")
nfd = unicodedata.normalize("NFD", "Café")
assert nfc != nfd
assert collision_key(nfc) == collision_key(nfd)
assert collision_key("Foo") == collision_key("foo") == collision_key("FOO")
assert collision_key("Straße") == collision_key("STRASSE")
assert collision_key("Foo") != collision_key("Foo ")
+63
View File
@@ -0,0 +1,63 @@
"""What a page title may be, stated as pure functions.
A title becomes a file name one to one, so the rule is the intersection of what
Windows, macOS and Linux accept - checked on every platform, because a corpus
written on Linux is checked out on the others. The normative statement is
`kb/CONTRACT.md` § "Titles are identifiers"; this module implements it and holds
no `fail()`, so `lint_core` can use it without going through `commands/`.
"""
from __future__ import annotations
import unicodedata
FORBIDDEN_CHARS = frozenset('<>:"/\\|?*')
# Windows device names, matched on the part before the first dot. The superscript
# forms are reserved by some Windows versions and not others; refusing all of
# them costs nothing.
_WINDOWS_RESERVED = frozenset(
{"CON", "PRN", "AUX", "NUL"}
| {f"{device}{digit}" for device in ("COM", "LPT") for digit in "0123456789¹²³"}
)
# Names the stack itself owns next to a page: the generated catalog shard and
# the per-collection authoring contract (`kb_scan.GENERATED_INDEX`,
# `kb_scan._COLLECTION_CONTRACT`, both matched here by the part before the dot).
_STACK_RESERVED = frozenset({"INDEX", "COLLECTION"})
def collision_key(name: str) -> str:
"""The key under which two names count as the same file on a case-insensitive,
normalizing file system (NTFS, APFS): NFC, then `casefold`."""
return unicodedata.normalize("NFC", name).casefold()
def title_problems(title: str) -> list[str]:
"""Every reason `title` cannot be a page title; empty when it can."""
if not title:
return ["the title is empty"]
problems: list[str] = []
forbidden = sorted({c for c in title if c in FORBIDDEN_CHARS})
if forbidden:
shown = " ".join(f"'{c}'" for c in forbidden)
problems.append(f"contains {shown}, which Windows does not allow in a file name")
control = sorted({c for c in title if unicodedata.category(c) == "Cc"})
if control:
shown = " ".join(f"U+{ord(c):04X}" for c in control)
problems.append(f"contains the control character(s) {shown}")
# Windows ignores trailing spaces and dots on the name before the extension,
# so `CON .x` opens the console device just as `CON.x` does.
base = title.split(".", 1)[0].rstrip(" ").upper()
if base in _WINDOWS_RESERVED:
problems.append(f"'{base}' is a reserved device name on Windows")
elif base in _STACK_RESERVED:
problems.append(f"'{base}' is reserved for a file the stack generates or owns")
if title.endswith((".", " ")):
problems.append("ends with a dot or a space, which Windows strips from file names")
return problems