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

+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