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
+47
-1
@@ -59,14 +59,19 @@ concern - readable here, never shipped as something to parse.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7.1.0-beta.32 - 2026-09-29 - dist upgrade --latest: one-command update from the release feed
|
## 8.0.0-beta.1 - 2026-09-30 - Page titles must form valid, unique file names on Windows and macOS
|
||||||
|
|
||||||
**Author:** Torben Nehmer
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
**Breaking Change:** Page titles must form valid, unique file names on Windows and macOS: new and rename refuse forbidden characters, reserved names (including INDEX and COLLECTION), a trailing dot or space, and titles that collide with another page by case or Unicode normalization; lint reports existing violations as hard errors - rename each affected page with tools/wikitool rename
|
||||||
|
|
||||||
|
**Migration:** none required - No page format changes; the rule only refuses titles, and each affected page is renamed individually with tools/wikitool rename
|
||||||
|
|
||||||
<!-- wikitool:bumps -->
|
<!-- wikitool:bumps -->
|
||||||
**High impact**
|
**High impact**
|
||||||
- wikitool: one data record per command - `-h`, index and CONTRACT.md render from cli_contract (Gitea #121 Phase 1)
|
- wikitool: one data record per command - `-h`, index and CONTRACT.md render from cli_contract (Gitea #121 Phase 1)
|
||||||
- dist upgrade --latest: one-command update from the release feed
|
- dist upgrade --latest: one-command update from the release feed
|
||||||
|
- Page titles must form valid, unique file names on Windows and macOS
|
||||||
|
|
||||||
**Medium impact**
|
**Medium impact**
|
||||||
- CalDAV task-tracker provider (Nextcloud Tasks, iOS Reminders); review reports unknown-value findings instead of skipping them
|
- CalDAV task-tracker provider (Nextcloud Tasks, iOS Reminders); review reports unknown-value findings instead of skipping them
|
||||||
@@ -103,6 +108,47 @@ concern - readable here, never shipped as something to parse.
|
|||||||
- new_page/type_resolver comments no longer claim only entities declare a layout:
|
- new_page/type_resolver comments no longer claim only entities declare a layout:
|
||||||
<!-- /wikitool:bumps -->
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
### Page titles must form valid, unique file names on Windows and macOS (Gitea #155)
|
||||||
|
|
||||||
|
A title is the wiki's only identifier for a page and becomes the file name one to one, but nothing
|
||||||
|
checked that the name was usable outside Linux. A corpus written on Linux could not be checked out
|
||||||
|
on Windows (`CON.md`, `A: B.md`, a trailing dot) or collapsed two pages into one on macOS and
|
||||||
|
Windows (`Foo.md` and `FOO.md`, or the same accented title in NFC and NFD). The rule is now stated
|
||||||
|
once, in `kb/CONTRACT.md` § "Titles are identifiers", implemented as pure functions in
|
||||||
|
`chemenu/titles.py`, and enforced on every platform - a corpus written on Linux is read on the
|
||||||
|
others.
|
||||||
|
|
||||||
|
A title is refused when it is empty, contains one of `< > : " / \ | ? *` or a control character,
|
||||||
|
ends with a dot or a space, or starts - before its first dot, ignoring case and trailing spaces -
|
||||||
|
with a Windows device name (`CON`, `PRN`, `AUX`, `NUL`, `COM0`-`COM9`, `LPT0`-`LPT9`, including the
|
||||||
|
superscript digits) or with `INDEX` or `COLLECTION`, the two names the stack owns next to a page.
|
||||||
|
Two titles collide when their NFC-normalized, case-folded forms are equal. The full title,
|
||||||
|
including a type's `title_prefix`, is what is checked.
|
||||||
|
|
||||||
|
- `new` checks the title for every type and every root, then the collision against the corpus for
|
||||||
|
`kb/` pages, then that the target file does not exist - all before anything is created, the
|
||||||
|
tracker project included. This last check also fixes a data-loss bug found on the way: for the
|
||||||
|
`root: repo` types, `new instruction --name gates` silently overwrote `instructions/gates.md`.
|
||||||
|
`new` never overwrites an existing file now.
|
||||||
|
- `rename --to` is checked the same way, also under `--dry-run`, with the page itself excluded so
|
||||||
|
a case-only rename (`Foo` to `FOO`) still works. `rename --from` is deliberately never checked:
|
||||||
|
it is how a page that is already invalid gets fixed.
|
||||||
|
- `move` refuses a target that an existing entry claims under another case or normalization, for
|
||||||
|
a single move and in `--reconcile` alike.
|
||||||
|
- `lint` reports existing violations under **Unportable Titles**, with the colliding paths named
|
||||||
|
and a `wikitool rename` remedy. The finding is a hard error at every `kb_version` and is
|
||||||
|
deliberately not migration-gated: there is no migration for it, so `kb_version` never advances
|
||||||
|
on its account, and each affected page is renamed individually.
|
||||||
|
|
||||||
|
The bump is `--major` because a corpus that carries such a title stops passing `lint --fail-on-error`
|
||||||
|
after the upgrade; a demo/testbed corpus and the shipped instructions are clean. Uncertain and
|
||||||
|
refused conservatively: whether `COM0`, `LPT0` and the superscript forms are device names on every
|
||||||
|
Windows version differs, so all of them are refused.
|
||||||
|
|
||||||
|
`tools/CONTRACT.md` is regenerated, and `kb/CONTRACT.md`, `kb/CONVENTIONS.md` and its template,
|
||||||
|
`instructions/page-lifecycle.md`, `instructions/wiki-lint/SKILL.md` and `README.md` carry the
|
||||||
|
rule.
|
||||||
|
|
||||||
### dist upgrade --latest: one-command update from the release feed (Gitea #161)
|
### dist upgrade --latest: one-command update from the release feed (Gitea #161)
|
||||||
|
|
||||||
Updating a tarball instance took a manual detour: `version notes`, then fetching the `.tar.gz` and
|
Updating a tarball instance took a manual detour: `version notes`, then fetching the `.tar.gz` and
|
||||||
|
|||||||
@@ -322,6 +322,10 @@ Ingest incoming/my-notes.md
|
|||||||
- Use human-readable titles with spaces for files: `Hybrid Search.md`, not kebab-case
|
- Use human-readable titles with spaces for files: `Hybrid Search.md`, not kebab-case
|
||||||
- Use singular for entities: `HA Integration.md` (not `HA Integrations.md`)
|
- Use singular for entities: `HA Integration.md` (not `HA Integrations.md`)
|
||||||
- Use wikilinks matching the file name exactly: `[[Entity Name]]`
|
- Use wikilinks matching the file name exactly: `[[Entity Name]]`
|
||||||
|
- A title is a file name, so it has to work on Windows and macOS as well: no `< > : " / \ | ? *`,
|
||||||
|
no reserved names such as `CON` or `Index`, no trailing dot, and no second page whose title
|
||||||
|
differs only by case. `wikitool new` and `wikitool rename` refuse such titles, `wikitool lint`
|
||||||
|
reports existing ones, and `kb/CONTRACT.md` § Titles are identifiers has the full rule
|
||||||
- **Titles follow the subject's own established name, not the wiki's language.** `Act Runner` and
|
- **Titles follow the subject's own established name, not the wiki's language.** `Act Runner` and
|
||||||
`GitOps Ownership Model` keep theirs. A title is the only identifier a page has - it also lives
|
`GitOps Ownership Model` keep theirs. A title is the only identifier a page has - it also lives
|
||||||
in every wikilink and citation id pointing at it - so translating one is a rename, never an
|
in every wikilink and citation id pointing at it - so translating one is a rename, never an
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ frontmatter reference arrays (`related:`, `sources:`, `entities:`, `concepts:`).
|
|||||||
hand.** Each of the commands below rewrites all three places at once; hand-editing rewrites
|
hand.** Each of the commands below rewrites all three places at once; hand-editing rewrites
|
||||||
one and leaves the others pointing at nothing.
|
one and leaves the others pointing at nothing.
|
||||||
|
|
||||||
|
<!-- wikitool:toc -->
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Rename](#rename)
|
||||||
|
- [Delete](#delete)
|
||||||
|
- [Move](#move)
|
||||||
|
- [Drop a single reference](#drop-a-single-reference)
|
||||||
|
- [Afterwards](#afterwards)
|
||||||
|
- [Scope](#scope)
|
||||||
|
<!-- /wikitool:toc -->
|
||||||
|
|
||||||
## Rename
|
## Rename
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -25,6 +36,11 @@ Repoints body wikilinks (aliases and anchors preserved), a citation id derived f
|
|||||||
title (both its Footnotes definition and every `[^cite-id]` reference to it), the page's own
|
title (both its Footnotes definition and every `[^cite-id]` reference to it), the page's own
|
||||||
H1, and every frontmatter reference array the type declares in `page_ref_fields:`.
|
H1, and every frontmatter reference array the type declares in `page_ref_fields:`.
|
||||||
|
|
||||||
|
`--to` has to be a valid, unique file name on every platform, and `--dry-run` refuses it the
|
||||||
|
same way the real run does. The rule is in `kb/CONTRACT.md` § Titles are identifiers. Only `--to`
|
||||||
|
is checked, so this is also the fix for `lint`'s **Unportable Titles** finding: rename the page
|
||||||
|
away from the title that breaks the rule. A change of case alone (`Foo` to `FOO`) is allowed.
|
||||||
|
|
||||||
**If `--from` is not a page but is referenced**, rename instead repoints those references onto
|
**If `--from` is not a page but is referenced**, rename instead repoints those references onto
|
||||||
the existing `--to` page and moves nothing. That is the fix for a reference spelled
|
the existing `--to` page and moves nothing. That is the fix for a reference spelled
|
||||||
`act_runner` when the page is `Act Runner`.
|
`act_runner` when the page is `Act Runner`.
|
||||||
@@ -62,9 +78,10 @@ reference anywhere in the wiki needs updating.
|
|||||||
to do. `wikitool lint`'s **Misplaced Pages** finding is the advisory this fixes - it is not a
|
to do. `wikitool lint`'s **Misplaced Pages** finding is the advisory this fixes - it is not a
|
||||||
hard error, so an unreconciled corpus is not a broken one, only one `move` would tidy.
|
hard error, so an unreconciled corpus is not a broken one, only one `move` would tidy.
|
||||||
|
|
||||||
A destination that already holds a file with the page's name is refused, not silently
|
A destination that already holds a file with the page's name - or one that differs from it only
|
||||||
overwritten - that only happens on a pre-existing duplicate-title collision, which `lint`'s
|
in case or Unicode normalization - is refused, not silently overwritten. That only happens on a
|
||||||
**Duplicate Titles** finding reports separately.
|
pre-existing duplicate-title collision, which `lint`'s **Duplicate Titles** and **Unportable
|
||||||
|
Titles** findings report separately.
|
||||||
|
|
||||||
## Drop a single reference
|
## Drop a single reference
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ mechanical half looks exactly like a complete one.
|
|||||||
No flags: prints the sections that found something, writes the full report to
|
No flags: prints the sections that found something, writes the full report to
|
||||||
`reports/Lint Report <YYYY-MM-DD>.md`, and names that path. This deterministically finds
|
`reports/Lint Report <YYYY-MM-DD>.md`, and names that path. This deterministically finds
|
||||||
unreadable frontmatter, broken wikilinks, dangling frontmatter references, orphan pages,
|
unreadable frontmatter, broken wikilinks, dangling frontmatter references, orphan pages,
|
||||||
catalog drift, missing fields, duplicate titles, filename/title mismatches, broken
|
catalog drift, missing fields, duplicate titles, titles that are not valid, unique file
|
||||||
|
names on Windows and macOS, filename/title mismatches, broken
|
||||||
`raw_files:` references, raw files claimed by more than one source page, invalid type paths,
|
`raw_files:` references, raw files claimed by more than one source page, invalid type paths,
|
||||||
schema failures, citation/frontmatter drift, and edges whose label is missing, not authorised
|
schema failures, citation/frontmatter drift, and edges whose label is missing, not authorised
|
||||||
by the source collection, or redundant beside a specific label on the reverse direction.
|
by the source collection, or redundant beside a specific label on the reverse direction.
|
||||||
|
|||||||
@@ -120,6 +120,24 @@ Which *form* those titles take - spaces or kebab-case, singular or plural, what
|
|||||||
decision record - is the instance's, in
|
decision record - is the instance's, in
|
||||||
[kb/CONVENTIONS.md § Naming](CONVENTIONS.md#naming).
|
[kb/CONVENTIONS.md § Naming](CONVENTIONS.md#naming).
|
||||||
|
|
||||||
|
**A title is also a file name, so it must be one on every platform** - Windows and macOS as
|
||||||
|
well as Linux, checked wherever the command runs: a corpus written on Linux is checked out on
|
||||||
|
the others, and a title that Linux accepts and Windows refuses breaks every clone there. The full
|
||||||
|
title counts, after any `title_prefix`. A title must not:
|
||||||
|
|
||||||
|
- be empty, or end with a dot or a space
|
||||||
|
- contain `<` `>` `:` `"` `/` `\` `|` `?` `*` or a control character
|
||||||
|
- start, before its first dot and regardless of case, with a Windows device name (`CON`, `PRN`,
|
||||||
|
`AUX`, `NUL`, `COM0`-`COM9`, `LPT0`-`LPT9`, and the superscript forms `COM¹`-`COM³`,
|
||||||
|
`LPT¹`-`LPT³`) or with a name the stack itself keeps beside a page (`INDEX`, `COLLECTION`)
|
||||||
|
- collide with another page once both are normalized to NFC and compared by `casefold` - NTFS and
|
||||||
|
APFS fold case, and APFS folds NFC and NFD as well
|
||||||
|
|
||||||
|
`wikitool new` and `wikitool rename` refuse such a title (`new` for every type, whatever root it
|
||||||
|
writes to; `rename` only for `--to`, so a page that already breaks the rule can always be renamed
|
||||||
|
away from it), and never write over an existing file. `wikitool lint` reports existing pages that
|
||||||
|
break the rule as Unportable Titles, a hard error at every `kb_version`.
|
||||||
|
|
||||||
## Every page should
|
## Every page should
|
||||||
|
|
||||||
- [ ] Carry a clear, descriptive title and a summary near the top
|
- [ ] Carry a clear, descriptive title and a summary near the top
|
||||||
|
|||||||
+3
-2
@@ -96,8 +96,9 @@ What to name a thing: projects use their repository or common name; systems a de
|
|||||||
name; tools the tool's own name; technologies their standard spelling and capitalization;
|
name; tools the tool's own name; technologies their standard spelling and capitalization;
|
||||||
people a full name or common handle.
|
people a full name or common handle.
|
||||||
|
|
||||||
The one naming fact that is *not* a choice, and therefore lives in the contract: the filename
|
The naming facts that are *not* a choice, and therefore live in the contract: the filename
|
||||||
stem is the page title, and `[[wikilinks]]` must match it exactly.
|
stem is the page title, `[[wikilinks]]` must match it exactly, and the title must be a valid,
|
||||||
|
unique file name on every platform (`kb/CONTRACT.md` § Titles are identifiers).
|
||||||
|
|
||||||
## Tone
|
## Tone
|
||||||
|
|
||||||
|
|||||||
@@ -81,8 +81,9 @@ them - they are rebuilt from frontmatter on every write. Any *other* heading is
|
|||||||
- {The ADR prefix, if this instance files decisions as pages.}
|
- {The ADR prefix, if this instance files decisions as pages.}
|
||||||
- {What to name a thing: projects, systems, tools, technologies, people.}
|
- {What to name a thing: projects, systems, tools, technologies, people.}
|
||||||
|
|
||||||
The one naming fact that is *not* a choice, and therefore lives in the contract: the filename
|
The naming facts that are *not* a choice, and therefore live in the contract: the filename
|
||||||
stem is the page title, and `[[wikilinks]]` must match it exactly.
|
stem is the page title, `[[wikilinks]]` must match it exactly, and the title must be a valid,
|
||||||
|
unique file name on every platform (`kb/CONTRACT.md` § Titles are identifiers).
|
||||||
|
|
||||||
## Tone
|
## Tone
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -180,6 +180,7 @@ Scaffold a new wiki page of any type.
|
|||||||
|
|
||||||
- 0 success
|
- 0 success
|
||||||
- 1 A page with this title already exists, the type is unknown, or a `--set` value is invalid
|
- 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 `raw_files` path does not exist
|
||||||
- 1 A capture field the type-spec requires is missing, or set to `unknown`
|
- 1 A capture field the type-spec requires is missing, or set to `unknown`
|
||||||
- 1 `--resume` with a type other than `project`
|
- 1 `--resume` with a type other than `project`
|
||||||
@@ -191,6 +192,7 @@ Scaffold a new wiki page of any type.
|
|||||||
**ON FAILURE**
|
**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
|
- 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 `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
|
- 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
|
- `--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**
|
**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.
|
- 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:`.
|
- 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.
|
- `--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
|
- 0 success
|
||||||
- 1 `--from` equals `--to`
|
- 1 `--from` equals `--to`
|
||||||
- 1 Neither `--from` nor `--to` is a page
|
- 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
|
- 1 A page write failed partway; nothing was renamed on disk
|
||||||
|
|
||||||
**ON FAILURE**
|
**ON FAILURE**
|
||||||
|
|
||||||
- `--from` equals `--to` -> Fix the arguments and retry once
|
- `--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`
|
- 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
|
- 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**
|
**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`.
|
- 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.
|
- 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.
|
- `--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**
|
**SEE ALSO**
|
||||||
|
|
||||||
@@ -570,14 +575,14 @@ Move a page (or every misplaced page) to the directory its type-spec computes.
|
|||||||
- 0 success
|
- 0 success
|
||||||
- 1 Neither or both of `--page`/`--reconcile` given
|
- 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 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
|
- 1 `--reconcile` failed partway
|
||||||
|
|
||||||
**ON FAILURE**
|
**ON FAILURE**
|
||||||
|
|
||||||
- Neither or both of `--page`/`--reconcile` given -> Fix the arguments and retry once
|
- 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 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
|
- `--reconcile` failed partway -> Safe to retry as-is - `--reconcile` only re-moves what is still misplaced
|
||||||
|
|
||||||
**NEVER**
|
**NEVER**
|
||||||
@@ -1097,6 +1102,7 @@ Run structural lint checks against kb/.
|
|||||||
**NOTES**
|
**NOTES**
|
||||||
|
|
||||||
- Structural and provenance checks over `kb/`: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, and unbalanced generated-region markers.
|
- Structural and provenance checks over `kb/`: broken wikilinks, 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.
|
- 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.
|
- 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.
|
- 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
|
- `wiki-lint` skill - the procedure that runs this
|
||||||
- `wikitool move --reconcile` - fixes Misplaced and Nested Pages
|
- `wikitool move --reconcile` - fixes Misplaced and Nested Pages
|
||||||
|
- `wikitool rename` - fixes Unportable Titles
|
||||||
- `wikitool log status` - whether a full lint is due
|
- `wikitool log status` - whether a full lint is due
|
||||||
|
|
||||||
#### `search`
|
#### `search`
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Any, Dict, Optional
|
|||||||
|
|
||||||
import typer
|
import typer
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
from rich.markup import escape
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
@@ -217,15 +218,72 @@ def rel_path(path: Path) -> str:
|
|||||||
return str(path)
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
def check_collision(name: str) -> None:
|
def check_title(name: str) -> None:
|
||||||
"""Fail if any page under kb/ already has `name` as its filename stem.
|
"""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
|
The stem *is* the page title and wikilinks resolve by title alone, so two
|
||||||
files sharing a stem in different directories are indistinguishable to
|
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 import config
|
||||||
|
from chemenu.kb_scan import iter_kb_pages
|
||||||
|
from chemenu.titles import collision_key
|
||||||
|
|
||||||
for path in config.KB_DIR.rglob("*.md"):
|
wanted = collision_key(name)
|
||||||
if path.stem == name:
|
for path in iter_kb_pages(config.KB_DIR):
|
||||||
fail(f"A page titled '{name}' already exists at {rel_path(path)}")
|
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 "
|
"mismatches, uncovered raw files, broken `raw_files:` refs, raw files claimed by more "
|
||||||
"than one source page, unmarked provenance, citation/frontmatter drift, and unbalanced "
|
"than one source page, unmarked provenance, citation/frontmatter drift, and unbalanced "
|
||||||
"generated-region markers.",
|
"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 "
|
"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.",
|
"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:` "
|
"Edges whose label is missing or not authorised by the source collection's `outbound:` "
|
||||||
@@ -97,6 +101,7 @@ __all__ = [
|
|||||||
see_also=(
|
see_also=(
|
||||||
"`wiki-lint` skill - the procedure that runs this",
|
"`wiki-lint` skill - the procedure that runs this",
|
||||||
"`wikitool move --reconcile` - fixes Misplaced and Nested Pages",
|
"`wikitool move --reconcile` - fixes Misplaced and Nested Pages",
|
||||||
|
"`wikitool rename` - fixes Unportable Titles",
|
||||||
"`wikitool log status` - whether a full lint is due",
|
"`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 (
|
from chemenu.commands._util import (
|
||||||
check_collision,
|
check_collision,
|
||||||
check_raw_files_exist,
|
check_raw_files_exist,
|
||||||
|
check_target_free,
|
||||||
|
check_title,
|
||||||
fail,
|
fail,
|
||||||
needs_clearance,
|
needs_clearance,
|
||||||
parse_set_fields,
|
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)",),
|
gates=("human-intervention-required (`new project` only)",),
|
||||||
),
|
),
|
||||||
notes=(
|
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 "
|
"The type-spec drives everything: fields, directory (`base_dir`/`layout`), title "
|
||||||
"prefix, and template. `types list`/`types describe` show what a type requires.",
|
"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 "
|
"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",
|
"value is invalid",
|
||||||
reaction="Not transient - fix the argument and retry once",
|
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(
|
cli_contract.Failure(
|
||||||
cause="A `raw_files` path does not exist",
|
cause="A `raw_files` path does not exist",
|
||||||
reaction="Not transient - fix the path and retry once",
|
reaction="Not transient - fix the path and retry once",
|
||||||
@@ -533,6 +550,9 @@ def new_page_command(
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
fail(str(exc))
|
fail(str(exc))
|
||||||
page_title = f"{title_prefix}{name}"
|
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":
|
if root == "kb":
|
||||||
# Title collisions matter because wikilinks resolve by title alone, so
|
# Title collisions matter because wikilinks resolve by title alone, so
|
||||||
# two pages sharing a stem are indistinguishable to every link in the
|
# 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"])
|
check_raw_files_exist(frontmatter["raw_files"])
|
||||||
|
|
||||||
path = target_dir / f"{page_title}.md"
|
path = target_dir / f"{page_title}.md"
|
||||||
|
check_target_free(path)
|
||||||
body = _apply_template_variables(
|
body = _apply_template_variables(
|
||||||
template,
|
template,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,7 +26,15 @@ from typing import Optional
|
|||||||
import typer
|
import typer
|
||||||
|
|
||||||
from chemenu import cli_contract, config, links
|
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.frontmatter_io import write_page
|
||||||
from chemenu.lint_core import find_misplaced
|
from chemenu.lint_core import find_misplaced
|
||||||
from chemenu.page import Page
|
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 "
|
"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.",
|
"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.",
|
"`--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=(
|
failures=(
|
||||||
cli_contract.Failure(
|
cli_contract.Failure(
|
||||||
@@ -244,8 +255,10 @@ def inbound_pages(pages: dict[str, Page], title: str) -> list[str]:
|
|||||||
"`wikitool xref remove`",
|
"`wikitool xref remove`",
|
||||||
),
|
),
|
||||||
cli_contract.Failure(
|
cli_contract.Failure(
|
||||||
cause="The `--to` title is already taken",
|
cause="The `--to` title is already taken - also by a page that differs only in case "
|
||||||
reaction="Choose another title and retry once",
|
"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(
|
cli_contract.Failure(
|
||||||
cause="A page write failed partway; nothing was renamed on disk",
|
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 "
|
f"to '{new}' would just move the dangling reference; create the page first "
|
||||||
"with `wikitool new ...`, or drop the reference with `wikitool xref remove`."
|
"with `wikitool new ...`, or drop the reference with `wikitool xref remove`."
|
||||||
)
|
)
|
||||||
elif not dry_run:
|
else:
|
||||||
check_collision(new)
|
# `--from` is never checked: a page whose title breaks the rule has to
|
||||||
elif new in pages:
|
# stay renamable, or `lint`'s finding would have no remedy. The page
|
||||||
fail(f"A page titled '{new}' already exists at {rel_path(pages[new].path)}")
|
# 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] = []
|
touched: list[str] = []
|
||||||
failed: 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",
|
reaction="Fix the title, or give the page its `type:`, then retry once",
|
||||||
),
|
),
|
||||||
cli_contract.Failure(
|
cli_contract.Failure(
|
||||||
cause="The destination already exists (a pre-existing duplicate-stem collision) - "
|
cause="The destination already holds an entry with the same name, or one that "
|
||||||
"refused rather than silently skipped",
|
"differs only in case or Unicode normalization (a pre-existing duplicate-stem "
|
||||||
|
"collision) - refused rather than silently skipped",
|
||||||
reaction="Resolve the collision, then retry",
|
reaction="Resolve the collision, then retry",
|
||||||
),
|
),
|
||||||
cli_contract.Failure(
|
cli_contract.Failure(
|
||||||
@@ -598,8 +617,9 @@ def move_command(
|
|||||||
collisions: list[str] = []
|
collisions: list[str] = []
|
||||||
for title, page, target_dir in candidates:
|
for title, page, target_dir in candidates:
|
||||||
new_path = target_dir / f"{title}.md"
|
new_path = target_dir / f"{title}.md"
|
||||||
if new_path.exists():
|
clash = target_conflict(new_path)
|
||||||
collisions.append(f"{title} (target {rel_path(new_path)} already exists)")
|
if clash is not None:
|
||||||
|
collisions.append(f"{title} (target {rel_path(clash)} already exists)")
|
||||||
continue
|
continue
|
||||||
planned.append((title, page, target_dir, new_path))
|
planned.append((title, page, target_dir, new_path))
|
||||||
|
|
||||||
@@ -657,8 +677,7 @@ def move_command(
|
|||||||
return
|
return
|
||||||
|
|
||||||
new_path = target_dir / f"{page_title}.md"
|
new_path = target_dir / f"{page_title}.md"
|
||||||
if new_path.exists():
|
check_target_free(new_path)
|
||||||
fail(f"Cannot move '{page_title}': {rel_path(new_path)} already exists.")
|
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
typer.echo(f"[dry-run] would move {rel_path(target.path)} -> {rel_path(new_path)}")
|
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_duplicate_title_paths,
|
||||||
find_nested_pages,
|
find_nested_pages,
|
||||||
inbound_links,
|
inbound_links,
|
||||||
|
iter_kb_pages,
|
||||||
load_kb_pages,
|
load_kb_pages,
|
||||||
)
|
)
|
||||||
|
from chemenu.titles import collision_key, title_problems
|
||||||
from chemenu.type_resolver import resolver
|
from chemenu.type_resolver import resolver
|
||||||
|
|
||||||
# Style guide's one mechanically-checkable rule (hard oracle: a plain count).
|
# 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
|
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:
|
def run_lint(kb_dir: Path) -> dict:
|
||||||
pages = load_kb_pages(kb_dir)
|
pages = load_kb_pages(kb_dir)
|
||||||
duplicate_titles = find_duplicate_title_paths(kb_dir, config.ROOT)
|
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,
|
"dangling_index_entries": dangling_index_entries,
|
||||||
"title_mismatches": title_mismatches,
|
"title_mismatches": title_mismatches,
|
||||||
"duplicate_titles": duplicate_titles,
|
"duplicate_titles": duplicate_titles,
|
||||||
|
"unportable_titles": unportable_titles(kb_dir),
|
||||||
"misplaced_pages": misplaced,
|
"misplaced_pages": misplaced,
|
||||||
"nested_pages": nested,
|
"nested_pages": nested,
|
||||||
"unsharded_collections": unsharded_collections(kb_dir, pages),
|
"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"],
|
lines, "Duplicate Titles (naming collisions)", report["duplicate_titles"],
|
||||||
lambda i: f"`{i['stem']}` -> {', '.join(f'`{p}`' for p in i['paths'])}",
|
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(
|
_section(
|
||||||
lines, "Filename / H1 Title Mismatches", report["title_mismatches"],
|
lines, "Filename / H1 Title Mismatches", report["title_mismatches"],
|
||||||
lambda i: f"[[{i['page']}]] H1 is '{i['h1']}'",
|
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
|
# generated catalog (`index rebuild`) silently mis-describes today, on every
|
||||||
# instance, at every version - see `nested_pages()` above.
|
# 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
|
# 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.
|
# the two disagreed, a run could pass its score while lint refused it.
|
||||||
HARD_ERROR_KEYS = (
|
HARD_ERROR_KEYS = (
|
||||||
@@ -773,6 +819,7 @@ HARD_ERROR_KEYS = (
|
|||||||
"broken_links",
|
"broken_links",
|
||||||
"dangling_index_entries",
|
"dangling_index_entries",
|
||||||
"duplicate_titles",
|
"duplicate_titles",
|
||||||
|
"unportable_titles",
|
||||||
"nested_pages",
|
"nested_pages",
|
||||||
"broken_raw_refs",
|
"broken_raw_refs",
|
||||||
"duplicate_raw_file_owners",
|
"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)
|
summary = render_summary(report)
|
||||||
assert "Redundant see-also" in summary
|
assert "Redundant see-also" in summary
|
||||||
assert "[[nearside]]" in summary and "depends-on" 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
|
assert result.exit_code == 0, result.output
|
||||||
frontmatter, _ = read_page(kb_dir / "sources/notes/Source - Comma Source.md")
|
frontmatter, _ = read_page(kb_dir / "sources/notes/Source - Comma Source.md")
|
||||||
assert frontmatter["raw_files"] == ["raw/notes/Versioning, CI-CD.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/kfchou").exists()
|
||||||
assert not (patched_wiki / "entities/projects/vanillaflava").exists()
|
assert not (patched_wiki / "entities/projects/vanillaflava").exists()
|
||||||
assert not (patched_wiki / "entities/tools/misplaced-tool.md").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