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

+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