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)
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:
1 parent
94deccb18d
commit
8be5e6e5f3
19 files changed
+654
-33
No files matched your search
@@ -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)."
|
||||
))
|
||||
@@ -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",
|
||||
),
|
||||
))
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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()) == []
|
||||
@@ -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()
|
||||
@@ -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 ")
|
||||
@@ -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
|
||||
Reference in new issue
Block a user