feat: Prosa ist kein Identifier - Link-Taxonomie als Enum, generierte Regionen mit Markern (4.0.0)
CI / verify (push) Successful in 55s
Release / release (push) Successful in 38s

Files changed:
- .gitea/workflows/ci.yml
- AGENTS.md
- CHANGES.md
- VERSION
- instructions/CONTRACT.md
- instructions/link-taxonomy.md
- instructions/migrations/4.0.0-link-taxonomy.md
- instructions/setup-instance.md
- kb/CONTRACT.md
- kb/CONVENTIONS.md
- kb/CONVENTIONS.md.template
- kb/comparisons/COLLECTION.md
- kb/concepts/COLLECTION.md
- kb/entities/COLLECTION.md
- kb/sources/COLLECTION.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/blocks.py
- tools/chemenu/cli.py
- tools/chemenu/commands/cite_cmd.py
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/links_cmd.py
- tools/chemenu/commands/migrate_cmd.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/page_ops.py
- tools/chemenu/commands/run_budget.py
- tools/chemenu/commands/xref.py
- tools/chemenu/conventions.py
- tools/chemenu/corpus_diff.py
- tools/chemenu/frontmatter_io.py
- tools/chemenu/kb_collections.py
- tools/chemenu/kb_state.py
- tools/chemenu/links.py
- tools/chemenu/lint_core.py
- tools/chemenu/provenance.py
- tools/chemenu/sections.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_blocks.py
- tools/chemenu/tests/test_cite_cmd.py
- tools/chemenu/tests/test_conventions.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_migrate_cmd.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_pipeline_l0.py
- tools/chemenu/tests/test_types_cmd.py
- tools/chemenu/tests/test_xref.py
- types/concept.schema.yaml
- types/entity.md
- types/entity.schema.yaml
- types/instruction.schema.yaml
- types/type-spec.md
- work/link-taxonomy-migration/README.md
- work/link-taxonomy-migration/plan.md
This commit is contained in:
2026-09-02 18:39:22 +02:00
parent 502971d147
commit 177c7e9ce8
56 changed files with 2692 additions and 750 deletions
+13 -11
View File
@@ -40,12 +40,13 @@ tools/wikitool <command> --help
| `touch --page "<Title>" [--summary "..."] [--provenance <v>] [--confidence-base <n>] [--date YYYY-MM-DD] [--set field=value ...] [--add field=value ...] [--remove field=value ...] [--no-date] [--dry-run]` | Update a page's own frontmatter: bump `modified:` and optionally rewrite any field its type declares. `--summary`/`--provenance`/`--confidence-base` are shorthands; `--set` reaches every other field and **replaces** its value, while `--add`/`--remove` change single elements of an array field (removing an absent element succeeds and says so). Repeating `--set` for one array field appends *within the call*, and `\,` is a literal comma - same rules as `new --set`. Refused with the command that owns them instead: `type:` (page-lifecycle), `confidence:` (derived - set `--confidence-base`), and the page-ref arrays `related:`/`sources:`/`entities:`/`concepts:` (`xref`). Everything else the schema declares is settable, and an unknown field lists what the page actually has. Schema-validates the fields it writes, and `raw_files:` entries must exist on disk. A source declares `date:` instead of `modified:`, and that is the *publication* date of the raw material - it is never bumped to today, and changes only when `--date` names a value explicitly. |
| `rename --from "<Old>" --to "<New>" [--dry-run]` | Rename a page and repoint every reference to it: body `[[wikilinks]]` (aliases and anchors preserved), a `[^cite-id]` whose id was derived from the old title (refreshed to match the new one, both in its Footnotes definition and every reference to it), the page's own H1, and every page-ref frontmatter array declared by the type's `page_ref_fields:`. 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` |
| `rm --page "<Title>" [--yes] [--dry-run]` | Delete a page and mechanically de-link it. Refuses without `--yes` while other pages still reference it. Strips ref-array entries and bare `- [[Title]]` / `- **label:** [[Title]]` bullets; leaves prose and inline citations in place and reports them |
| `xref add --a "<A>" --b "<B>" --rel-a "<label>" --rel-b "<label>"` | Bidirectionally link two pages: frontmatter `related:` + body Relationships/See Also bullets. Idempotent. Refuses, before writing either side, when a page's type does not declare `related:` - a source page declares `entities:`/`concepts:` instead, and writing `related:` there produced frontmatter the schema rejects; the refusal names the fields the type does declare and points at `link-source`. |
| `xref remove --a "<A>" --b "<B>" [--dry-run]` | Inverse of `xref add` *and* `xref link-source`: clears `<B>` from every page-ref frontmatter field `<A>`'s type declares (`related:`, `sources:`, `entities:`, `concepts:`) plus the matching bullets. It also sweeps a field the type does *not* declare but some other type does, and drops that key outright once empty - a leftover written before the check above existed has to stay repairable, or the page is a dead end. `--b` need not still exist as a page, so this is how a reference left by a hand-deleted or hand-renamed page gets cleared without hand-editing frontmatter. Idempotent. |
| `xref link-source --source "Source - X" --entities A,B,C` | Batch-link a source page to every entity/concept it mentions, **in both directions**: each target gets `sources:` + a See Also bullet, and the source page records each target in its own `entities:`/`concepts:`. Which of the two is chosen follows the target's collection (`kb/entities/` -> `entities:`), so a new collection needs no code change here. A target whose collection matches no reference field the source type declares is linked one-way and named in the output. Idempotent in both directions |
| `xref add --a "<A>" --b "<B>" --rel <label>` | Declare **one** edge: `A <label> B`, written into A's `related:` as `- <label>: B` and rendered into A's generated links region. B is not touched and does not point back - its inbound view is rendered from the graph. Idempotent, and re-running with a different label *relabels* rather than appending, since one page asserts one thing about another. Refuses before writing when the type does not declare `related:` (a source page declares `entities:`/`concepts:` - the refusal names them and points at `link-source`), and when `<label>` is not authorised by the source collection's `outbound:` block for the target's collection; that refusal lists the authorised set and points at `instructions/link-taxonomy.md` |
| `xref remove --a "<A>" --b "<B>" [--dry-run]` | Clears the reference in **both** directions - it is the cleanup command for a deleted or hand-renamed page rather than the strict inverse of a one-directional `add`. Clears `<B>` from every page-ref frontmatter field `<A>`'s type declares (`related:`, `sources:`, `entities:`, `concepts:`) plus the matching bullets. It also sweeps a field the type does *not* declare but some other type does, and drops that key outright once empty - a leftover written before the check above existed has to stay repairable, or the page is a dead end. `--b` need not still exist as a page, so this is how a reference left by a hand-deleted or hand-renamed page gets cleared without hand-editing frontmatter. Idempotent. |
| `xref link-source --source "Source - X" --entities A,B,C` | Batch-link a source page to every entity/concept it mentions: each target gets `sources:`, and the source page records each target in its own `entities:`/`concepts:`. No body bullet is written on either side - `sources:` *is* the record, and the See Also bullet this used to add was the reciprocal half of a model that no longer exists. Which of the two is chosen follows the target's collection (`kb/entities/` -> `entities:`), so a new collection needs no code change here. A target whose collection matches no reference field the source type declares is linked one-way and named in the output. Idempotent in both directions |
| `links show --page "<Title>" [--json]` | The declared graph around one page in both directions: the edges it asserts (from its own `related:`, with labels) and the edges other pages assert about it (computed across the corpus). The inbound half is derived rather than stored - that is what makes it complete, and it is the answer authored directional edges would otherwise have nowhere to come from. Read-only, exempt from the Iteration Budget Gate |
| `cite id --title "Source - X" [--file <qualifier>]` | Print the deterministic footnote id `cite add` would use for this (title, file) pair. Read-only, exempt from the Iteration Budget Gate |
| `cite add --page "<Title>" --source "Source - X" [--file <qualifier>] [--dry-run]` | Upsert a `[^cite-id]: [[Source - X]]` definition in the page's Footnotes block (reusing the id if the page already cites this exact source/file pair) and add `Source - X` to frontmatter `sources:`. Prints the `[^cite-id]` marker - pasting it into the prose is still a manual, editorial step |
| `cite sync [--page "<Title>" \| --all] [--dry-run]` | Reconcile each page's Footnotes block against its actual `[^id]` references: prune definitions nothing references any more, re-render the block in first-reference order, and report any `[^id]` reference left with no definition |
| `cite add --page "<Title>" --source "Source - X" [--file <qualifier>] [--dry-run]` | Upsert a `[^cite-id]: [[Source - X]]` definition in the page's generated footnotes region, creating it between `<!-- wikitool:footnotes -->` markers if absent (reusing the id if the page already cites this exact source/file pair) and add `Source - X` to frontmatter `sources:`. Prints the `[^cite-id]` marker - pasting it into the prose is still a manual, editorial step |
| `cite sync [--page "<Title>" \| --all] [--dry-run]` | Reconcile each page's footnotes region against its actual `[^id]` references: prune definitions nothing references any more, re-render the region in first-reference order, and report any `[^id]` reference left with no definition. A page still carrying the pre-4.0.0 undelimited block is converted to a marked region in the same pass - the marker carries the region's identity now, so re-rendering it under this instance's heading is a repair rather than a rename |
| `index rebuild [--dry-run]` | Regenerate the catalog from every page's frontmatter: `kb/index.md` becomes a map (statistics, one row per collection and per area, links to the shards) and the page tables are written to a generated `INDEX.md` in each collection. An area past 50 rows gets its own shard. Stale shards from removed collections/areas are deleted in the same pass |
| `log append --op ingest\|query\|lint\|create\|update\|delete\|rename --title "..." [--body "..."\|--body-file path]` | Append a formatted entry to `kb/log.md` |
| `log status` | Read-only: count `ingest` entries logged since the last `lint` entry - the deterministic trigger behind the Maintenance Schedule's "every 10 sources" full-lint cadence |
@@ -70,15 +71,15 @@ tools/wikitool <command> --help
| `docs verify` | Check the docs that mirror the code: every CLI command documented here (and vice versa), every directory under `kb/` has a `COLLECTION.md` and no directory outside it does, every collection declaring `profile:` and a `required_by_stack:` that agrees with the stack's own list, `kb/CONVENTIONS.md` naming all three tool-owned section headings if it exists at all, every stage contract present, no pre-migration `type: entity` blocks left in the contracts, and the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, everything ignored under `reports/` and the published skill directories) |
| `eval sessions [--json]` | List the sessions that have a trace under `reports/telemetry/`, most recent first. Read-only and exempt from the Iteration Budget Gate |
| `eval score [--session <id>] [--json] [--markdown out.md] [--save] [--fail-on-error]` | Score one traced session: structural state from `lint`'s own checks (L1) plus trajectory rules over the trace (L2) - was a refused call repeated unchanged, was a gate flag passed without that gate having refused anything, did a publish of `kb/` pages go unlogged. Defaults to the current session. `--save` writes `reports/evals/<date>/<session>.{json,md}`. Read-only over `kb/` and exempt from the budget; see [../EVALS.md](../EVALS.md) |
| `dist export <target> [--dry-run] [--source-repo U] [--source-commit SHA] [--release-url U] [--update-url U]` | Write a contentless, distributable copy of this repo's machinery into an empty `<target>` directory: `AGENTS.md`/`README.md`/`EVALS.md` with any `<!-- dist:strip-start -->...<!-- dist:strip-end -->` region removed, `instructions/` (minus `instructions/dev/`), `types/`, `tools/` (no venv/caches), the `.github/hooks/`+`.vibe/` session-tracing config plus `.claude/settings.json`, `kb/CONTRACT.md` (no pages, no areas), empty `raw/{articles,documents,notes,assets}/`, `VERSION`, `USER.md.template`/`SOUL.md.template` plus `kb/CONVENTIONS.md.template` and each collection's contract re-keyed as `kb/<name>/COLLECTION.md.template` (the templates ship; the filled `USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb/<name>/COLLECTION.md` never do - all four bind their instance and none of them are the stack's to decide, and `find_leaks` refuses a plan carrying one), and a generated `.wikitool-release.json` stamp (version, export date, origin, and a sha256 per exported file - the base a later upgrade would compare against). The four origin options only fill stamp fields: `export` never calls git and cannot discover them. Refuses a non-empty target, and a tree with no `VERSION`. See `instructions/setup-instance.md`. One-way: there is no command that reconstructs a distributed instance into a dev instance - work on the stack in the origin repo (or a new dev instance exported from it) instead |
| `dist export <target> [--dry-run] [--source-repo U] [--source-commit SHA] [--release-url U] [--update-url U]` | Write a contentless, distributable copy of this repo's machinery into an empty `<target>` directory: `AGENTS.md`/`README.md`/`EVALS.md` with any `<!-- dist:strip-start -->...<!-- dist:strip-end -->` region removed, `instructions/` (minus `instructions/dev/`), `types/` (the `root: kb` page type-specs and their schemas re-keyed as `.template`, the stack's own verbatim), `tools/` (no venv/caches), the `.github/hooks/`+`.vibe/` session-tracing config plus `.claude/settings.json`, `kb/CONTRACT.md` (no pages, no areas), empty `raw/{articles,documents,notes,assets}/`, `VERSION`, `USER.md.template`/`SOUL.md.template` plus `kb/CONVENTIONS.md.template` and each collection's contract re-keyed as `kb/<name>/COLLECTION.md.template` (the templates ship; the filled `USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb/<name>/COLLECTION.md`/`types/<page-type>.md` never do - all of them bind their instance and none are the stack's to decide, and `find_leaks` refuses a plan carrying one), and a generated `.wikitool-release.json` stamp (version, export date, origin, and a sha256 per exported file - the base a later upgrade would compare against). The four origin options only fill stamp fields: `export` never calls git and cannot discover them. Refuses a non-empty target, and a tree with no `VERSION`. See `instructions/setup-instance.md`. One-way: there is no command that reconstructs a distributed instance into a dev instance - work on the stack in the origin repo (or a new dev instance exported from it) instead |
| `version show [--json]` | Print this instance's stack version and where it came from (development tree, or a distribution with its export date and origin). Bare `wikitool version` is an alias for this. Read-only, offline, and **exempt from the Iteration Budget Gate** |
| `version check [--url U] [--timeout S] [--json]` | Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (`state: current\|update\|migration\|ahead`). **The only command in `wikitool` that makes a network call** - never reached implicitly from another command, needs no key, times out, and reports an unreachable feed as an error rather than as "up to date". The feed is `$WIKITOOL_UPDATE_URL`, else the release stamp's, else the built-in origin; `$WIKITOOL_UPDATE_TOKEN` is only needed if that feed is not readable anonymously. Read-only and exempt from the budget gate |
| `version notes [--version X.Y.Z]` | Print one version's `CHANGES.md` entry, for use as release notes (default: this tree's `VERSION`). Read-only and exempt from the budget gate |
| `version bump --major\|--minor\|--patch --title "<...>" [--breaking "<what breaks>"] [--no-migration "<reason>"] [--dry-run]` | Raise `VERSION` and open the matching `CHANGES.md` entry - heading, date and author only; the body stays the author's to write, the way `new` writes frontmatter and leaves the prose. Refuses more or fewer than one part, an empty title, and a changelog already documenting a version that is not older than the new one. Compatibility follows the **leftmost non-zero component**, which for this stack (at `1.0.0` and up, no pre-release suffixes anywhere) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on update, or a downgrade that no longer works. Whether content must be migrated is a second, independent question. A MAJOR bump therefore requires `--breaking "<what stops working>"`, which is refused on any other part, and on top of it a migration document targeting the new version or `--no-migration "<reason>"`; both are recorded in the entry. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
| `migrate list [--json]` | List every migration document under `instructions/migrations/`, oldest target first, with its kind. Read-only and **exempt from the Iteration Budget Gate** |
| `migrate status [--json]` | Show the migrations this instance still owes, in the order they must run: every document whose `migrates_to` lies in `(kb_version, VERSION]`. Exits 1 only when `.wikitool-kb.json` is missing - the content's shape is a question the tool refuses to answer by guessing. Read-only and exempt from the budget gate |
| `migrate verify --from <rev> [--path P ...] [--expect-body-change] [--json] [--fail-on-error]` | Compare `kb/` against a git revision on the invariants a content migration must not change: wikilink and citation **counts** (not sets), footnote definitions, H1, and structural frontmatter. Reports added/removed pages without failing on them. `--expect-body-change` additionally flags a page whose body did not change at all. Not migration-specific - worth running after any bulk rewrite, and the one question `lint` cannot answer, since it reads a single revision and so cannot see that something went missing. Read-only and exempt from the budget gate |
| `migrate done <version> [--pages N] [--dry-run]` | Record one migration as applied, advancing `kb_version` in `.wikitool-kb.json` to its target. **Refuses any version that is not the next link in the chain** - skipping one leaves the corpus in a shape no version describes, and an interrupted multi-step upgrade has to be resumable rather than guessable |
| `migrate list [--json]` | List every migration document under `instructions/migrations/`, oldest target first, with its kind and obligation. Read-only and **exempt from the Iteration Budget Gate** |
| `migrate status [--json]` | Show the migrations this instance still owes, in the order they must run: every **required** document whose `migrates_to` lies in `(kb_version, VERSION]`. `offered` documents are listed separately above the chain and never block, never count as owed, and are bounded by the applied ledger rather than by `kb_version` - taking one deliberately does not move the version, so the version cannot say whether it was taken. When a release stamp is present, also reports which shipped files this instance has since edited (from the per-file sha256 in `.wikitool-release.json`), which is what says whether an offer may be copied over or has to be reconciled by hand; without a stamp that question is reported as unanswerable rather than answered. Exits 1 only when `.wikitool-kb.json` is missing - the content's shape is a question the tool refuses to answer by guessing. Read-only and exempt from the budget gate |
| `migrate verify --from <rev> [--path P ...] [--expect-body-change] [--json] [--fail-on-error]` | Compare `kb/` against a git revision on the invariants a content migration must not change: wikilink and citation **counts** (not sets), footnote definitions, H1, structural frontmatter, and the **count of generated-region marker pairs** - a page that went from one links region to two has the same set of region names and a different count, and a lost marker turns a generated region into prose the next write appends a second one beside. Reports added/removed pages without failing on them. `--expect-body-change` additionally flags a page whose body did not change at all. Not migration-specific - worth running after any bulk rewrite, and the one question `lint` cannot answer, since it reads a single revision and so cannot see that something went missing. Read-only and exempt from the budget gate |
| `migrate done <version> [--pages N] [--dry-run]` | Record one migration as applied, advancing `kb_version` in `.wikitool-kb.json` to its target. **Refuses any version that is not the next link in the chain** - skipping one leaves the corpus in a shape no version describes, and an interrupted multi-step upgrade has to be resumable rather than guessable. An `offered` migration is recorded in the applied ledger *without* moving `kb_version` and with no ordering rule applied: it is not a link in the chain, so there is nothing to skip, and requiring the chain first would make an unrelated file upgrade wait on it. Re-recording one already in the ledger is a no-op, not an error |
| `migrate baseline <version> [--force]` | Declare `kb_version` once, for an instance predating `.wikitool-kb.json`. Refuses to overwrite an existing declaration without `--force`: advancing after a migration is `done`, which checks the chain, and this command must not become the quiet way around it |
| `doctor [--json]` | Check that this instance is correctly configured: dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, kb/raw/reports/work/instructions structure, personalization (`USER.md`/`SOUL.md` present **and** filled - a file still carrying the template's sentinel is a `FAIL`, since a renamed template is not a filled one), the KB conventions (`kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three, because `xref`/`cite` write out of it), the environment note (`ENVIRONMENT.md` - optional, so absent is `OK`; a still-templated one is a `WARN`), generated files, and `WIKITOOL_SESSION_ID`. Read-only, exit 1 only on a `FAIL` (a missing remote, session id, or `VERSION` is a `WARN`, not a fault). Exempt from the Iteration Budget Gate |
@@ -191,9 +192,10 @@ is atomic, and whether a retry is safe.
| `version show` / `version notes` | `VERSION` is missing or unparseable; for `notes`, no `CHANGES.md` entry names the version asked for | Read-only | Fix `VERSION`, or write the changelog entry (`version bump` writes its heading). Safe to retry |
| `version check` | The feed could not be reached, answered non-JSON, or carried no `tag_name`. **Never** answers "up to date" for a question it could not ask | Read-only, no local writes | A network failure is transient - retry once, then report it. HTTP 401/403 names `$WIKITOOL_UPDATE_TOKEN`; 404 means no release exists yet or the URL points at the wrong repo |
| `version bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, a missing `VERSION`/`CHANGES.md`, a changelog already documenting a version not older than the new one, a boundary-crossing bump without `--breaking` or with neither a migration document nor `--no-migration`, or `--breaking`/`--no-migration` on a bump that crosses nothing | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run bumps again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying |
| `links show` | Page not found | Read-only | Check the exact title with `search`; a wikilink target is not always the page's stem |
| `migrate list` / `migrate status` | `list` never fails; `status` exits 1 when `.wikitool-kb.json` is missing or unreadable, or `VERSION` is | Read-only | For a missing declaration: run `migrate baseline <version>` once, then retry. Safe to retry freely otherwise |
| `migrate verify` | Only with `--fail-on-error`: an invariant changed. Also exits 1 if `--from` is not a revision in this repository | Read-only | Exit 1 from `--fail-on-error` means "act on the findings", not "the tool is broken". A finding is never fixed by re-running - it names a page and what changed on it |
| `migrate done` | Unknown version, no `.wikitool-kb.json`, nothing outstanding, or a version that is not the next link in the chain | Yes - single file write | **Not idempotent**: it advances the chain. For "not the next link", run `migrate status` and apply them in the order it prints - never force the order |
| `migrate done` | Unknown version, no `.wikitool-kb.json`, nothing outstanding, or a *required* version that is not the next link in the chain | Yes - single file write | **Not idempotent** for a required migration: it advances the chain. For "not the next link", run `migrate status` and apply them in the order it prints - never force the order. Recording an `offered` migration *is* idempotent and safe to repeat |
| `migrate baseline` | Unparseable version, or a declaration already exists and `--force` was not passed | Yes - single file write | Safe to re-run with the same version. If a declaration exists, it is almost always `migrate done` that was wanted |
| `doctor` | At least one check reported `FAIL` (a `WARN`, e.g. no remote or no `WIKITOOL_SESSION_ID`, does not exit 1) | Read-only | Each finding names its own fix command; re-run after applying it |
| `budget status` / `budget reset` | `reset` without `--yes`; `status` never fails | Read/rewrite of one JSON file | `status` is safe to retry. For `reset`: get the user's approval, then re-run with `--yes` |
+23 -16
View File
@@ -39,12 +39,13 @@ tools/
errors.py ChemenuError / ValidationError / BackendError
corpus_cache.py one parsed corpus per commit, never cached while the tree is dirty
kb_scan.py page iteration/loading over kb/
blocks.py generated regions in a page body, found by marker rather than by heading
links.py labelled edges in `related:` - the graph's semantics as data, not prose
kb_collections.py collection discovery (a directory with COLLECTION.md), and what one declares about itself
conventions.py kb/CONVENTIONS.md: what this instance decided about authoring, as opposed to what the stack enforces
type_resolver.py type-spec loading and schema resolution
lint_core.py the lint checks and the report, with no CLI attached
types_core.py type-spec listing/description, with no CLI attached
sections.py the section headings the tool reads and writes in a page body
markdown_code.py masks code spans/fences so a page may show wiki notation, not only use it
version.py the stack version: VERSION, the release stamp, the compatibility rule
kb_state.py the KB version (.wikitool-kb.json) and the migration chain
@@ -111,23 +112,29 @@ procedure written down in advance is one an agent can complete alone. Whether
a human *actually* saw it is not enforced here - that question is answered in
the eval layer (`evals/trajectory.py`, `clearance-ended-the-turn`).
**Section names are a vocabulary, not literals - and not the stack's.** `xref add` writes into
Relationships and See Also, and `cite add` owns the trailing Footnotes block, so those three
headings are structure the tool matches on. *Which words they are* is the corpus's own answer:
`conventions.py` reads them from `kb/CONVENTIONS.md`, `sections.py` resolves them on access
(PEP 562, the way `config` resolves its paths), and no heading text is written down in Python
except the pre-conventions fallback for an instance that has not declared one yet.
**Nothing locates a region by its prose.** `xref` owns the links region and `cite` the footnotes
region, and each is delimited by a `<!-- wikitool:<name> -->` marker pair (`blocks.py`). The
heading inside is rendered from `kb/CONVENTIONS.md` and is replaced along with the rest of the
region on every write - so no heading text exists in Python, and changing the declaration cannot
split a page.
Each slot has one canonical spelling - what the tool writes - plus aliases it still recognizes.
That asymmetry is what let the wiki be translated page by page instead of atomically: an
untranslated `## Relationships` is still found and appended to. Dropping an alias is therefore a
breaking change for any page not yet converted, not a cleanup. Renaming a heading is a
migration's job; no other command may do it as a side effect (see `cite_block_heading` in
`provenance.py`, which exists solely so `cite sync` stays a no-op on an untranslated page).
Both halves of that mattered. Matching on the heading made the KB language a compiler constant;
*guessing* where the region ended - at the next heading, and before that at the end of the file -
silently deleted content sitting after it on eight pages. `migrate verify` compares marker-pair
counts for the same reason it compares wikilink counts: a dropped marker is invisible otherwise.
Because the value is resolved rather than bound, nothing may capture it at import time - not a
module constant, not an evaluated default argument. That is why `provenance.CITE_BLOCK_HEADING`
is a module `__getattr__` and `render_cite_block(heading=None)` resolves inside the call.
**A relationship label is data, not prose.** `related:` carries `- <label>: <target>`
(`links.py`), the label drawn from `instructions/link-taxonomy.md` and authorised per
destination by the *source* collection's `outbound:` block. The body bullet is a rendering of
that, which is what removed the need to parse a German phrase back into a relationship - and why
the vocabulary can be checked at all, after drifting to 152 distinct labels while it could not
be. Both readers accept a bare title as an unlabelled edge: that is the shape a page is in
between the machinery landing and the migration reaching it, and `lint` is what reports it.
**An edge is authored in one direction.** `xref add` writes one, on the asserting page. The
inbound view is rendered from the graph rather than stored, so navigation does not depend on
anyone writing a mirror - and per-collection authorisation stays coherent, which it cannot be if
the tool writes edges into a collection whose rules the author never read.
**Generated output is never committed.** `reports/`, `.agents/skills/` and
`.claude/skills/` are build output; `docs verify` carries canaries in both
+136
View File
@@ -0,0 +1,136 @@
"""Generated regions inside a page body, found by delimiter rather than by prose.
`xref` owns the links block and `cite` owns the footnotes block. Both used to be
located by matching their **heading text** - `^## Beziehungen$` - which made a
translated heading a structural fact and put the KB language into the compiler.
It also made the region's *end* a guess: the footnotes block ran to the next
heading, and before that to the end of the file, which silently deleted whatever
sat after it on eight pages.
A marker pair answers both questions exactly:
<!-- wikitool:links -->
## Beziehungen
- **depends-on:** [[Hermes]]
<!-- /wikitool:links -->
Everything between the markers is generated and is replaced wholesale on the
next write - heading included, which is why the heading text is a *rendering*
value from `kb/CONVENTIONS.md` rather than something the tool searches for. An
author never edits inside the markers; anything they put there is overwritten
without warning, exactly like `kb/index.md`.
The markers are HTML comments: invisible in every renderer this corpus is read
through, and the same convention `dist:strip-start`/`-end` already uses in
`AGENTS.md`. They cost a reader nothing and cost an LLM about twenty tokens a
page - the price of not having to guess where a generated region ends.
"""
from __future__ import annotations
import re
from typing import Optional
# The two regions the tool owns. `see-also` is deliberately absent: it was the
# reciprocal half of the old bidirectional `xref add`, and under authored
# directional edges it is a *label* (`see-also`) inside the links block, not a
# section of its own.
LINKS = "links"
FOOTNOTES = "footnotes"
BLOCKS = (LINKS, FOOTNOTES)
_NAME = r"[a-z][a-z0-9-]*"
def open_marker(name: str) -> str:
return f"<!-- wikitool:{name} -->"
def close_marker(name: str) -> str:
return f"<!-- /wikitool:{name} -->"
def _region_re(name: str) -> re.Pattern[str]:
"""The whole region including both markers and the blank line around it."""
return re.compile(
r"\n*"
+ re.escape(open_marker(name))
+ r".*?"
+ re.escape(close_marker(name))
+ r"\n*",
re.DOTALL,
)
_ANY_OPEN_RE = re.compile(rf"<!-- wikitool:({_NAME}) -->")
_ANY_CLOSE_RE = re.compile(rf"<!-- /wikitool:({_NAME}) -->")
def find(body: str, name: str) -> Optional[str]:
"""The generated content of `name`'s region, markers excluded, or None."""
match = _region_re(name).search(body)
if not match:
return None
text = match.group(0)
start = text.index(open_marker(name)) + len(open_marker(name))
end = text.index(close_marker(name))
return text[start:end].strip("\n")
def render(name: str, heading: str, lines: list[str]) -> str:
"""A whole region, ready to place into a body. Empty `lines` renders "".
An empty region is no region at all rather than a heading with nothing under
it: a page that cites nothing should not carry an empty Footnotes section,
and the same holds for a page with no declared edges.
"""
if not lines:
return ""
parts = [open_marker(name), f"## {heading}", "", *lines, close_marker(name)]
return "\n".join(parts)
def replace(body: str, name: str, region: str) -> str:
"""Put `region` where `name`'s region is, or append it if there is none.
Appending at the end is right for both blocks: they are the page's trailing
machine-owned material, and an author's prose never follows them. A region
that is `""` removes what was there.
"""
existing = _region_re(name).search(body)
if existing:
replacement = f"\n\n{region}\n" if region else "\n"
return (body[: existing.start()] + replacement + body[existing.end():]).rstrip("\n") + "\n"
if not region:
return body
return body.rstrip("\n") + "\n\n" + region + "\n"
def strip(body: str, name: str) -> str:
"""The body with `name`'s region removed entirely."""
return replace(body, name, "")
def marker_pairs(body: str) -> dict[str, int]:
"""How many complete open/close pairs each region name has in `body`.
The invariant `migrate verify` checks. An agent rewriting prose next to a
boundary can drop or duplicate a marker, and the failure is otherwise silent:
a lost opening marker turns a generated region into ordinary prose that the
next write appends a second copy beside.
"""
opens = [m.group(1) for m in _ANY_OPEN_RE.finditer(body)]
closes = [m.group(1) for m in _ANY_CLOSE_RE.finditer(body)]
names = set(opens) | set(closes)
return {name: min(opens.count(name), closes.count(name)) for name in sorted(names)}
def unbalanced_markers(body: str) -> list[str]:
"""Region names whose open and close markers do not pair up."""
opens = [m.group(1) for m in _ANY_OPEN_RE.finditer(body)]
closes = [m.group(1) for m in _ANY_CLOSE_RE.finditer(body)]
return sorted(
name
for name in set(opens) | set(closes)
if opens.count(name) != closes.count(name)
)
+2
View File
@@ -21,6 +21,7 @@ try:
index_build,
instructions_cmd,
lint as lint_module,
links_cmd,
log_append,
migrate_cmd,
new_page,
@@ -54,6 +55,7 @@ app = typer.Typer(
app.add_typer(xref.app, name="xref")
app.add_typer(cite_cmd.app, name="cite")
app.add_typer(links_cmd.app, name="links")
app.add_typer(index_build.app, name="index")
app.add_typer(log_append.app, name="log")
app.add_typer(confidence_decay.app, name="confidence")
+2 -3
View File
@@ -28,7 +28,6 @@ from chemenu.kb_scan import load_kb_pages
from chemenu.provenance import (
CITE_REF_RE,
cite_id,
cite_block_heading,
render_page_body,
split_cite_block,
unique_cite_id,
@@ -81,7 +80,7 @@ def upsert_citation(page: Page, source_title: str, qualifier: Optional[str]) ->
if sources_changed:
sources.append(source_title)
new_body = render_page_body(head, definitions, cite_block_heading(page.body))
new_body = render_page_body(head, definitions)
changed = block_changed or sources_changed or new_body != page.body
return marker_id, new_body, changed
@@ -152,7 +151,7 @@ def sync_page(page: Page) -> tuple[str, bool, list[str], list[str]]:
ordered[cid] = definitions[cid]
seen.add(cid)
new_body = render_page_body(head, ordered, cite_block_heading(page.body))
new_body = render_page_body(head, ordered)
changed = new_body != page.body
return new_body, changed, pruned, undefined
+62 -1
View File
@@ -264,6 +264,60 @@ class Origin(NamedTuple):
update_url: Optional[str] = None
def instance_owned_type_stems() -> set[str]:
"""Type-spec stems whose instances are knowledge pages, and which therefore
belong to the instance rather than to the stack.
The line is `root:`, and it was already in the frontmatter before anyone
drew it: `root: kb` means the type describes a page the instance writes, so
its prose, its template and its language are the instance's business.
Anything else - `instruction` (`root: repo`), `lint-report` (no `base_dir`
at all), `type-spec` itself - describes a stack artifact and ships verbatim.
Read from `types/` rather than listed, so an instance adding its own page
type gets the same treatment without a code change.
"""
from chemenu.type_resolver import resolver
stems: set[str] = set()
for type_path, frontmatter in resolver.list_type_specs():
stem = Path(type_path).stem
if stem == "type-spec":
continue
if not frontmatter.get("base_dir"):
continue
if (frontmatter.get("root") or "kb") != "kb":
continue
stems.add(stem)
return stems
def _plan_types() -> dict[str, PlannedFile]:
"""`types/`, with the page type-specs re-keyed as templates.
Same split as the collection contracts, for the same reason and by the same
mechanism: the shipped content is a working default rather than something
wrong for the receiver, so the file itself crosses - under a name that has
to be adopted before it counts. A type-spec's `.schema.yaml` travels with
it, because the two are one type (see types/type-spec.md § Anatomy) and
adopting half of it would leave a spec validated by a file it does not own.
"""
plan = _copy_tree(config.TYPES_DIR, "types", frozenset())
stems = instance_owned_type_stems()
if not stems:
return plan
rekeyed: dict[str, PlannedFile] = {}
for relative, planned in plan.items():
name = relative.rsplit("/", 1)[-1]
stem = name.split(".", 1)[0]
if stem in stems:
rekeyed[f"{relative}.template"] = planned
else:
rekeyed[relative] = planned
return rekeyed
def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
"""Every (destination-relative path -> planned file) the export writes."""
plan: dict[str, PlannedFile] = {}
@@ -286,7 +340,7 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
plan[name] = _read_planned_file(source, name)
plan.update(_copy_tree(config.INSTRUCTIONS_DIR, "instructions", frozenset(INSTRUCTIONS_EXCLUDE_DIRS)))
plan.update(_copy_tree(config.TYPES_DIR, "types", frozenset()))
plan.update(_plan_types())
plan.update(_copy_tree(
config.ROOT / "tools", "tools", frozenset(TOOLS_EXCLUDE_DIRS), _is_coverage_output
))
@@ -382,6 +436,7 @@ _INSTANCE_OWNED_KB_FILES = (kb_collections.CONTRACT_NAME, conventions.CONVENTION
def find_leaks(plan: dict[str, PlannedFile]) -> list[str]:
"""Planned paths that carry one instance's own data instead of machinery."""
owned_types = instance_owned_type_stems()
leaks: list[str] = []
for relative in sorted(plan):
name = relative.rsplit("/", 1)[-1]
@@ -389,6 +444,12 @@ def find_leaks(plan: dict[str, PlannedFile]) -> list[str]:
leaks.append(f"{relative} (one instance's own personalization)")
elif relative.startswith("kb/") and name in _INSTANCE_OWNED_KB_FILES:
leaks.append(f"{relative} (this instance's authoring conventions; ship the .template)")
elif (
relative.startswith("types/")
and not relative.endswith(".template")
and name.split(".", 1)[0] in owned_types
):
leaks.append(f"{relative} (this instance's page type-spec; ship the .template)")
elif relative.startswith("instructions/dev/"):
leaks.append(f"{relative} (stack-development only)")
elif relative.startswith(_CONTENT_PREFIXES) and name not in _CONTENT_ALLOWED_NAMES:
+43
View File
@@ -260,10 +260,53 @@ def check_collection_contracts() -> list[str]:
issues += kb_collections.declaration_issues()
issues += conventions.declaration_issues()
issues += check_stack_required_types()
return issues
def check_stack_required_types() -> list[str]:
"""The minimum the stack asks of the type layer, and nothing beyond it.
The four page type-specs belong to the instance: it may translate them,
rewrite their templates, add sections. What it may not do is remove the one
type the provenance path is built on, or drop the field that path reads.
Everything else about `types/source.md` - its prose, its template, its title
prefix, its directory - is the instance's, and is deliberately not checked
here.
"""
from chemenu.type_resolver import resolver
issues: list[str] = []
for type_name in kb_collections.STACK_REQUIRED_TYPES:
try:
type_path = resolver.find_type_by_name(type_name)
except (ValueError, OSError) as exc:
issues.append(f"types/ could not be read to find the `{type_name}` type: {exc}")
continue
if not type_path:
issues.append(
f"no type-spec declares `name: {type_name}` - `sources coverage`, `[^cite-id]` "
f"resolution and `kb/provenance.md` all ask `page.kind == \"{type_name}\"`, so "
f"without it the whole raw/ -> kb/ provenance path resolves against nothing"
)
continue
try:
schema = resolver.get_schema(type_path) or {}
except (ValueError, OSError) as exc:
issues.append(f"{type_path}: its schema could not be read: {exc}")
continue
declared = set(schema.get("required") or [])
for field in kb_collections.STACK_REQUIRED_TYPE_FIELDS.get(type_name, ()):
if field not in declared:
issues.append(
f"{type_path}: its schema must require `{field}` - it is what the "
f"provenance path reads, and a `{type_name}` page without it claims no "
f"raw material at all"
)
return issues
def check_legacy_type_blocks() -> list[str]:
issues = []
guarded = [
+14 -11
View File
@@ -217,17 +217,18 @@ def check_conventions() -> Check:
"""Whether this instance has said how its own pages are written.
`kb/CONVENTIONS.md` carries the decisions `kb/CONTRACT.md` deliberately no
longer makes: the KB language and its three tool-owned section headings, the
relationship-label vocabulary, the tone examples, the confidence rubric, the
ADR prefix. The compiler reads the section names out of it, so an instance
without one is not merely undocumented - `xref add` and `cite add` fall back
to the names this stack hardcoded before the file existed, which is right
only for a corpus that was written under them.
longer makes: the KB language and the headings its two generated regions
render under, the tone examples, the confidence rubric, the naming forms.
Hence `FAIL` rather than `WARN`, and hence the same two failure modes the
personalization pair has: the distribution can ship the template but never
the filled file, so a template renamed and left unanswered looks present and
decides nothing.
`FAIL` rather than `WARN` because those decisions bind every page, and
because it has the same two failure modes the personalization pair has: the
distribution can ship the template but never the filled file, so a template
renamed and left unanswered looks present and decides nothing.
The headings themselves are only cosmetic now - the marker pair carries each
region's identity, so a default renders wrong words rather than corrupting
structure. That is why this check is about the *file*, not about rescuing a
lookup the compiler can no longer get wrong.
"""
path = conventions.conventions_file()
fix = (
@@ -246,7 +247,9 @@ def check_conventions() -> Check:
if issues:
return Check("conventions", "FAIL", "; ".join(issues), fix)
declared = conventions.language() or "unspecified"
headings = ", ".join(conventions.canonical(slot) for slot in conventions.SLOTS)
from chemenu import blocks
headings = ", ".join(conventions.heading(block) for block in blocks.BLOCKS)
return Check(
"conventions", "OK",
f"kb/{conventions.CONVENTIONS_FILENAME} present, language {declared}, "
+95
View File
@@ -0,0 +1,95 @@
"""`wikitool links` - the declared graph around one page, both directions.
The half that makes authored directional edges liveable. An edge is written once,
on the page that asserts it, so the question "what points at *this* page" has no
answer stored anywhere - it is computed from the graph, which is the only way it
is ever complete. A mirrored edge only ever recorded what someone remembered to
mirror.
Read-only, and exempt from the iteration budget for the same reason `search` is:
it answers a question rather than changing anything, and an agent that has to
ration looking things up starts guessing instead.
"""
from __future__ import annotations
import json as _json
from typing import Optional
import typer
from chemenu import config, links
from chemenu.commands._util import console, fail
from chemenu.kb_scan import load_kb_pages
from chemenu.page import Page
app = typer.Typer(help="Show the declared edges into and out of a page.")
EDGE_FIELD = "related"
def _collection_of(page: Page) -> Optional[str]:
try:
return page.path.relative_to(config.KB_DIR).parts[0]
except (ValueError, IndexError):
return None
def outbound(pages: dict[str, Page], title: str) -> list[dict]:
"""Edges this page asserts, in file order."""
page = pages[title]
return [
{"target": edge.target, "label": edge.label, "resolves": edge.target in pages}
for edge in links.edges(page.frontmatter, EDGE_FIELD)
]
def inbound(pages: dict[str, Page], title: str) -> list[dict]:
"""Edges other pages assert *about* this one.
A full scan of the corpus rather than a stored list, deliberately: the whole
argument for dropping mirrored edges is that this answer is derived and
therefore cannot go stale or be half-written.
"""
found = [
{"source": other, "label": edge.label, "collection": _collection_of(page)}
for other, page in pages.items()
for edge in links.edges(page.frontmatter, EDGE_FIELD)
if edge.target == title
]
return sorted(found, key=lambda item: (item["label"] or "", item["source"]))
@app.command("show")
def links_show(
page: str = typer.Option(..., "--page", help="Exact page title"),
json_out: bool = typer.Option(False, "--json", help="Print the edges as JSON"),
):
"""Show the edges out of and into a page.
Outbound is what the page declares in `related:`. Inbound is computed across
the corpus - nothing stores it, which is exactly why it is complete."""
pages = load_kb_pages(config.KB_DIR)
if page not in pages:
fail(f"No page titled '{page}' found under kb/.")
out, back = outbound(pages, page), inbound(pages, page)
if json_out:
typer.echo(_json.dumps({"page": page, "outbound": out, "inbound": back}, indent=2))
return
console.print(f"[bold]{page}[/bold]")
console.print(f"\n[cyan]asserts ({len(out)})[/cyan]")
if not out:
console.print(" (none)")
for edge in out:
label = edge["label"] or "[dim]unlabelled[/dim]"
missing = "" if edge["resolves"] else " [red](no such page)[/red]"
console.print(f" {label} -> [[{edge['target']}]]{missing}")
console.print(f"\n[cyan]asserted about it ({len(back)})[/cyan]")
if not back:
console.print(" (none - nothing in the corpus declares an edge to this page)")
for edge in back:
label = edge["label"] or "[dim]unlabelled[/dim]"
console.print(f" [[{edge['source']}]] {label} ->")
+91 -3
View File
@@ -64,6 +64,7 @@ def list_command(
"name": m.name,
"migrates_to": str(m.target),
"migration_kind": m.kind,
"obligation": m.obligation,
"description": m.description,
"path": m.relative_path,
}
@@ -78,7 +79,10 @@ def list_command(
success(f"No migration documents under {rel_path(kb_state.migrations_dir())}.")
return
for migration in migrations:
console.print(f"[bold]{migration.target}[/bold] {migration.name} ({migration.kind})")
console.print(
f"[bold]{migration.target}[/bold] {migration.name} "
f"({migration.kind}, {migration.obligation})"
)
if migration.description:
console.print(f" {migration.description}")
@@ -86,6 +90,50 @@ def list_command(
# --- migrate status --------------------------------------------------------
def _report_offers(
offered: list["kb_state.Migration"], divergent: Optional[list[str]]
) -> None:
"""Print the optional half of `status`, above the outstanding chain.
Deliberately never affects the exit code and never says "outstanding". An
offer is the stack proposing a better default for a file the instance owns;
an instance that keeps its own version is in a correct state, not a late
one. Mixing the two is how the message that actually matters - your content
no longer fits your machinery - stops being read.
"""
if not offered:
return
console.print(
f"[cyan]{len(offered)} optional upgrade(s) available[/cyan] - none of them block:"
)
for migration in offered:
console.print(f" {migration.target} {migration.name} ({migration.kind})")
if migration.description:
console.print(f" {migration.description}")
console.print(f" {migration.relative_path}")
if divergent is None:
console.print(
" [dim]This tree carries no release stamp, so which of your files still match "
"what you were given cannot be answered here.[/dim]"
)
return
if divergent:
console.print(
f" [dim]{len(divergent)} file(s) differ from the release you installed - those are "
"yours to reconcile by hand rather than overwrite:[/dim]"
)
for relative in divergent[:10]:
console.print(f" [dim]{relative}[/dim]")
if len(divergent) > 10:
console.print(f" [dim]... and {len(divergent) - 10} more[/dim]")
else:
console.print(
" [dim]No file differs from the release you installed, so an offer can be taken "
"by copying.[/dim]"
)
@app.command("status")
def status_command(
json_out: bool = typer.Option(False, "--json", help="Print the chain as JSON"),
@@ -113,6 +161,8 @@ def status_command(
return
pending = kb_state.chain(migrations, kb_version, stack)
offered = kb_state.offers(migrations, kb_state.applied_names(kb_state.read_kb_state()))
divergent = kb_state.divergent_files()
if json_out:
typer.echo(
@@ -124,6 +174,11 @@ def status_command(
{"name": m.name, "migrates_to": str(m.target), "migration_kind": m.kind}
for m in pending
],
"offered": [
{"name": m.name, "migrates_to": str(m.target), "migration_kind": m.kind}
for m in offered
],
"divergent_files": divergent,
},
indent=2,
)
@@ -131,6 +186,7 @@ def status_command(
return
console.print(f"stack {stack}, content {kb_version}")
_report_offers(offered, divergent)
if not pending:
if kb_version < stack:
console.print(
@@ -166,7 +222,12 @@ def done_command(
Refuses any version that is not the *next* link in the chain: skipping a
migration is how a corpus ends up in a shape no version describes, and an
interrupted multi-step upgrade has to be resumable rather than guessable."""
interrupted multi-step upgrade has to be resumable rather than guessable.
An `offered` migration is recorded but does not move the version, and no
ordering rule applies to it - it is not a link in the chain. The record is
the only thing that distinguishes an offer someone took from one they
ignored, precisely because the version stays put."""
stack, kb_version = _versions()
if kb_version is None:
fail(
@@ -182,6 +243,34 @@ def done_command(
return
migrations = kb_state.load_migrations()
state = kb_state.read_kb_state() or {}
# An offer is recorded but does not advance the version: it is not a link in
# the chain, so there is no ordering rule to check and nothing to skip. The
# ledger is what makes it stop being offered - without that record there
# would be no way to tell a taken offer from an ignored one, because
# `kb_version` deliberately does not move.
offered = {m.name: m for m in migrations if not m.is_required}
taken = next((m for m in offered.values() if str(m.target) == version), None)
if taken is not None:
if taken.name in kb_state.applied_names(state):
success(f"{taken.name} is already recorded as taken. Nothing to do.")
return
if dry_run:
success(f"Dry run: would record the optional {taken.name}. Nothing written.")
return
applied = list(state.get("applied") or [])
entry = {"migration": taken.name, "at": today_iso(), "obligation": kb_state.OFFERED}
if pages is not None:
entry["pages"] = pages
applied.append(entry)
kb_state.write_kb_state(kb_version, applied)
success(
f"Recorded the optional {taken.name}. Content stays at {kb_version} - an offer "
"changes a file you own, not the shape of your content."
)
return
expected = kb_state.next_link(migrations, kb_version, stack)
if expected is None:
fail(
@@ -197,7 +286,6 @@ def done_command(
)
return
state = kb_state.read_kb_state() or {}
applied = list(state.get("applied") or [])
entry = {"migration": expected.name, "at": today_iso()}
if pages is not None:
+2 -7
View File
@@ -24,7 +24,7 @@ import re
import typer
from chemenu import config, conventions
from chemenu import config
from chemenu.commands._util import (
check_collision,
check_raw_files_exist,
@@ -166,11 +166,7 @@ def _apply_template_variables(template: str, variables: Dict[str, Any]) -> str:
"""Apply variable substitutions to a template string.
Supports:
- `{field}` - plain substitution from `variables[field]`, including the
`{section.<slot>}` names this instance gave the three tool-owned
headings (see chemenu.conventions). Those are what took the KB language
out of `types/*.md`: a template writes `## {section.relationships}`, so
scaffolding a page in another language needs no edit under `types/`
- `{field}` - plain substitution from `variables[field]`
- `{field|filter}` - apply a named filter (bullets, join, capitalize)
to `variables[field]`'s value, so templates can render list/enum
frontmatter fields directly instead of the caller precomputing a
@@ -333,7 +329,6 @@ def new_page_command(
**frontmatter,
"name": name,
"today": today.isoformat(),
**conventions.section_variables(),
},
)
+11 -8
View File
@@ -25,7 +25,7 @@ from typing import Optional
import typer
from chemenu import config
from chemenu import config, links
from chemenu.commands._util import check_collision, fail, rel_path, success
from chemenu.frontmatter_io import write_page
from chemenu.page import Page
@@ -33,7 +33,6 @@ from chemenu.kb_scan import load_kb_pages
from chemenu.provenance import (
CITE_REF_RE,
cite_id,
cite_block_heading,
render_page_body,
split_cite_block,
unique_cite_id,
@@ -103,7 +102,7 @@ def retarget_cite_ids(body: str, old: str, new: str) -> str:
return body
new_head = CITE_REF_RE.sub(lambda m: f"[^{renames.get(m.group(1), m.group(1))}]", head)
return render_page_body(new_head, new_definitions, cite_block_heading(body))
return render_page_body(new_head, new_definitions)
def retarget_frontmatter(page: Page, old: str, new: str) -> bool:
@@ -114,9 +113,11 @@ def retarget_frontmatter(page: Page, old: str, new: str) -> bool:
values = page.frontmatter.get(field)
if not values:
continue
updated = [new if value == old else value for value in values]
if updated != values:
page.frontmatter[field] = updated
# Through `links` so a labelled edge keeps its label across a rename:
# the entry is `{label: target}`, and a plain equality swap would have
# compared the mapping against a title and silently left it pointing at
# the old page.
if links.retarget(page.frontmatter, field, old, new):
changed = True
return changed
@@ -157,8 +158,10 @@ def strip_frontmatter_ref(page: Page, title: str) -> bool:
values = page.frontmatter.get(field)
if not values:
continue
updated = [value for value in values if value != title]
if updated == values:
before = list(values)
links.remove(page.frontmatter, field, title)
updated = page.frontmatter.get(field) or []
if updated == before:
continue
if not updated and field not in declared:
del page.frontmatter[field]
+4
View File
@@ -82,6 +82,10 @@ SKIP_COMMAND_PATHS = {
("eval", "score"),
("eval", "sessions"),
("cite", "id"),
# Retrieval, like `search`: an agent that has to ration looking up what
# points at a page starts guessing instead - and under authored directional
# edges this is the *only* way to ask that question.
("links", "show"),
("version", "show"),
("version", "check"),
("version", "notes"),
+116 -96
View File
@@ -1,9 +1,22 @@
"""Bidirectional cross-reference management between wiki pages.
"""Cross-reference management between wiki pages.
`xref add` keeps two pages' frontmatter `related:` lists AND their body
"## Relationships" sections in sync in one operation, instead of the 3-5
separate manual edits this used to take per pair of pages. It is idempotent:
re-running it never duplicates a link.
`xref add` writes **one** edge: a label plus a target, into the asserting page's
`related:` frontmatter, and re-renders that page's generated links region from
it. It is idempotent, and re-running with a different label relabels rather than
duplicating.
It used to write four things at once - `related:` and a Relationships bullet on
both pages, plus reciprocal See Also bullets. That made every edge symmetric by
construction, which is not what a link means: an edge is an authored reader aid,
and "follow this to verify the premise" rarely reads the same from the other
end. Worse, it is incompatible with per-collection label authorisation, because
the mirrored half is written into a collection whose rules the author never
read.
The reverse direction is therefore authored separately, when it is a primary
statement of its own - and navigation does not depend on anyone bothering:
`index rebuild` renders the inbound view from the graph, completely and without
maintenance. See instructions/link-taxonomy.md.
"""
from __future__ import annotations
@@ -12,7 +25,7 @@ from pathlib import Path
import typer
from chemenu import config, sections
from chemenu import blocks, config, conventions, kb_collections, links
from chemenu.commands._util import fail, parse_list, success
from chemenu.commands.page_ops import strip_frontmatter_ref
from chemenu.frontmatter_io import write_page
@@ -71,121 +84,119 @@ def _back_reference_field(source: Page, target: Page) -> str | None:
return collection if collection in _declared_ref_fields(source) else None
def add_related(frontmatter: dict, other_title: str) -> bool:
"""Add other_title to frontmatter['related'] if not already present.
Returns True if a change was made."""
related = frontmatter.setdefault("related", [])
if other_title in related:
return False
related.append(other_title)
return True
def _section_bounds(body: str, heading: str) -> tuple[int, int] | None:
match = sections.heading_re(heading).search(body)
if not match:
def _collection_of(page: Page) -> str | None:
"""The collection a page lives in, or None if it is outside `kb/`."""
try:
return page.path.relative_to(config.KB_DIR).parts[0]
except (ValueError, IndexError):
return None
start = match.end()
next_heading = re.search(r"^## ", body[start:], re.MULTILINE)
end = start + next_heading.start() if next_heading else len(body)
return start, end
def add_bullet_to_section(body: str, heading: str, bullet: str, dedup_link: str) -> str:
"""Insert `bullet` into the `## {heading}` section of body, unless a
wikilink to dedup_link already appears there. Creates the section
(before the See Also section if present, else at the end) if missing.
def render_links_block(page: Page) -> str:
"""The page's generated links region, built from its `related:` edges.
`heading` is a canonical name from `sections`; an existing section is found
under its aliases too, so a page that has not been translated yet is still
appended to rather than given a duplicate section. A section this creates
always carries the canonical name."""
bounds = _section_bounds(body, heading)
if bounds is None:
section = f"## {heading}\n\n{bullet}\n\n"
see_also = sections.heading_re(sections.SEE_ALSO).search(body)
if heading != sections.SEE_ALSO and see_also:
return body[: see_also.start()] + section + body[see_also.start() :]
return body.rstrip("\n") + "\n\n" + section.rstrip("\n") + "\n"
start, end = bounds
section_text = body[start:end]
if f"[[{dedup_link}]]" in section_text:
return body
trimmed = section_text.rstrip("\n")
new_section = trimmed + "\n" + bullet + "\n\n"
return body[:start] + new_section + body[end:]
The body is a *rendering* of the frontmatter, not a second place the graph
is stored. That is what removed the need to parse a German bullet back into
a relationship: the label lives in the data, and this writes it out.
"""
lines = []
for edge in links.edges(page.frontmatter, "related"):
if edge.is_labelled:
lines.append(f"- **{edge.label}:** [[{edge.target}]]")
else:
lines.append(f"- [[{edge.target}]]")
return blocks.render(blocks.LINKS, conventions.heading(blocks.LINKS), lines)
def add_relationship_bullet(body: str, label: str, other_title: str) -> str:
bullet = f"- **{label}:** [[{other_title}]]"
return add_bullet_to_section(body, sections.RELATIONSHIPS, bullet, other_title)
def apply_links_block(page: Page, body: str | None = None) -> str:
"""`body` with the links region re-rendered from `page.frontmatter`."""
return blocks.replace(
page.body if body is None else body, blocks.LINKS, render_links_block(page)
)
def add_see_also_bullet(body: str, other_title: str) -> str:
return add_bullet_to_section(body, sections.SEE_ALSO, f"- [[{other_title}]]", other_title)
def _check_authorised(source: Page, target: Page, label: str) -> None:
"""Refuse a label the source collection has not authorised for that
destination.
Checked here rather than only in `lint` because this is the moment the
author is present: a refusal names the authorised set and can be answered by
picking a better label, while a lint finding a day later is answered by
whoever is holding the report.
"""
source_collection = _collection_of(source)
destination = _collection_of(target)
if source_collection is None or destination is None:
return
allowed = kb_collections.authorised_labels(source_collection, destination)
if not allowed:
fail(
f"kb/{source_collection}/COLLECTION.md authorises no labels for edges into "
f"kb/{destination}/. Add an `outbound:` entry for it, or do not link there "
f"from this collection."
)
if label not in allowed:
fail(
f"'{label}' is not authorised for kb/{source_collection}/ -> kb/{destination}/.\n"
f" Authorised: {', '.join(sorted(allowed))}\n"
f" The catalogue and what each label asserts: instructions/link-taxonomy.md\n"
f" Authorising a further label is a deliberate edit to "
f"kb/{source_collection}/COLLECTION.md, not a way around this refusal."
)
@app.command("add")
def xref_add(
a: str = typer.Option(..., "--a", help="Exact title of page A"),
b: str = typer.Option(..., "--b", help="Exact title of page B"),
rel_a: str = typer.Option("related to", "--rel-a", help="Relationship label on A pointing to B"),
rel_b: str = typer.Option("related to", "--rel-b", help="Relationship label on B pointing to A"),
see_also: bool = typer.Option(True, "--see-also/--no-see-also", help="Also add reciprocal 'See Also' bullets"),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes to both pages instead of writing"),
a: str = typer.Option(..., "--a", help="Exact title of the page that asserts the edge"),
b: str = typer.Option(..., "--b", help="Exact title of the page it points at"),
rel: str = typer.Option(
..., "--rel", help="Label from instructions/link-taxonomy.md, e.g. depends-on"
),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview the change instead of writing"),
):
"""Declare that A <rel> B. One edge, on A only.
Say the sentence before choosing the label: `[A] <rel> [B]`. If it only
reads true backwards, the edge belongs on B - run this the other way round
rather than reaching for an inverse label.
B is not modified and does not need to point back. Its inbound view is
rendered from the graph.
"""
pages = load_kb_pages(config.KB_DIR)
page_a = _find_page(pages, a)
page_b = _find_page(pages, b)
# Both refusals before either write, so a rejected pair leaves no half-link.
# Every refusal before the single write, so a rejected edge leaves nothing.
_require_related_field(page_a, a)
_require_related_field(page_b, b)
_check_authorised(page_a, page_b, rel)
related_changed_a = add_related(page_a.frontmatter, b)
related_changed_b = add_related(page_b.frontmatter, a)
body_a = add_relationship_bullet(page_a.body, rel_a, b)
body_b = add_relationship_bullet(page_b.body, rel_b, a)
if see_also:
body_a = add_see_also_bullet(body_a, b)
body_b = add_see_also_bullet(body_b, a)
changed_a = related_changed_a or body_a != page_a.body
changed_b = related_changed_b or body_b != page_b.body
changed = links.upsert(page_a.frontmatter, "related", links.Edge(b, rel))
body = apply_links_block(page_a)
changed = changed or body != page_a.body
if dry_run:
state_a = "would update" if changed_a else "already up to date"
state_b = "would update" if changed_b else "already up to date"
typer.echo(f"[dry-run] '{a}': {state_a} (related / Relationships / See Also)")
typer.echo(f"[dry-run] '{b}': {state_b} (related / Relationships / See Also)")
typer.echo(
f"[dry-run] '{a}': {'would declare' if changed else 'already declares'} "
f"{rel} -> '{b}'"
)
typer.echo("No files written (--dry-run).")
return
try:
write_page(page_a.path, page_a.frontmatter, body_a)
except OSError as exc:
fail(f"Failed to write '{a}': {exc}. '{b}' was not touched - fix the write failure and retry once.")
if not changed:
success(f"'{a}' already declares {rel} -> '{b}'; nothing changed.")
return
try:
write_page(page_b.path, page_b.frontmatter, body_b)
write_page(page_a.path, page_a.frontmatter, body)
except OSError as exc:
fail(
f"'{a}' was updated but writing '{b}' failed: {exc}. The link is now one-directional - "
f"fix the write failure, then re-run `xref add --a \"{a}\" --b \"{b}\"` (idempotent, safe to retry)."
)
success(f"Linked '{a}' <-> '{b}' ({rel_a} / {rel_b})")
fail(f"Failed to write '{a}': {exc}")
success(f"'{a}' {rel} '{b}'")
def remove_related(frontmatter: dict, other_title: str) -> bool:
"""Drop other_title from frontmatter['related'] if present. Returns True if
a change was made."""
related = frontmatter.get("related")
if not related or other_title not in related:
return False
frontmatter["related"] = [title for title in related if title != other_title]
return True
"""Drop every edge pointing at other_title. True if a change was made."""
return links.remove(frontmatter, "related", other_title)
def remove_link_bullets(body: str, other_title: str) -> str:
"""Remove the whole-line Relationships/See Also bullets `xref add` writes -
@@ -223,14 +234,19 @@ def xref_remove(
page_a = _find_page(pages, a)
page_b = pages.get(b)
body_a = remove_link_bullets(page_a.body, b)
changed_a = strip_frontmatter_ref(page_a, b) or body_a != page_a.body
# The frontmatter first, then the region re-rendered from it - the body is a
# rendering, so editing the bullet out directly would leave an empty region
# behind and, worse, put the two out of step.
changed_a = strip_frontmatter_ref(page_a, b)
body_a = apply_links_block(page_a, remove_link_bullets(page_a.body, b))
changed_a = changed_a or body_a != page_a.body
changed_b = False
body_b = ""
if page_b is not None:
body_b = remove_link_bullets(page_b.body, a)
changed_b = strip_frontmatter_ref(page_b, a) or body_b != page_b.body
changed_b = strip_frontmatter_ref(page_b, a)
body_b = apply_links_block(page_b, remove_link_bullets(page_b.body, a))
changed_b = changed_b or body_b != page_b.body
if dry_run:
typer.echo(f"[dry-run] '{a}': {'would update' if changed_a else 'no reference to remove'}")
@@ -278,7 +294,11 @@ def xref_link_source(
sources = page.frontmatter.setdefault("sources", [])
if source not in sources:
sources.append(source)
body = add_see_also_bullet(page.body, source)
# No body bullet. `sources:` *is* the record, and the See Also bullet
# this used to add was the reciprocal half of a bidirectional model
# that no longer exists - 353 of the corpus's 555 such bullets were
# provably redundant with an edge that already said the same thing.
body = page.body
# The way back. Until this existed the command wrote only the targets,
# so a source page's own `entities:`/`concepts:` stayed as `new` left
+37 -84
View File
@@ -35,30 +35,30 @@ from chemenu.frontmatter_io import read_page
CONVENTIONS_FILENAME = "CONVENTIONS.md"
CONVENTIONS_TEMPLATE = f"{CONVENTIONS_FILENAME}.template"
# The three tool-owned headings, by slot name. The slot is the stable
# identifier - it is what code, the type-spec templates and the conventions
# file all key on - while the heading text itself is the instance's to choose.
RELATIONSHIPS = "relationships"
SEE_ALSO = "see_also"
FOOTNOTES = "footnotes"
SLOTS = (RELATIONSHIPS, SEE_ALSO, FOOTNOTES)
# Frontmatter keys read out of kb/CONVENTIONS.md.
# The two tool-owned regions, keyed by the block name in `chemenu.blocks`. The
# block name is the identifier - it is what the marker pair carries and what the
# tool locates the region by - while the heading text below it is prose the
# instance chooses.
#
# `see_also` is gone as a section: it was the reciprocal half of the old
# bidirectional `xref add`, and under authored directional edges it is a *label*
# inside the links block rather than a region of its own.
SECTIONS_KEY = "sections"
SECTION_ALIASES_KEY = "section_aliases"
LANGUAGE_KEY = "language"
# Every heading name this stack has ever written as canonical, newest first.
# Two jobs, and they are separate: the first entry is the fallback for an
# instance that has no conventions file yet, and the whole tuple is an implicit
# alias set that every instance recognizes regardless of what it declares. The
# second is what makes a corpus translatable page by page - a page still
# carrying `## Footnotes` is untranslated, not broken, and `cite sync` has to
# stay a no-op on it.
PRE_CONVENTIONS_NAMES: dict[str, tuple[str, ...]] = {
RELATIONSHIPS: ("Beziehungen", "Relationships"),
SEE_ALSO: ("Siehe auch", "See Also"),
FOOTNOTES: ("Fußnoten", "Footnotes"),
# What a heading renders as when the instance has not said. Purely cosmetic, and
# that is a genuine change from before: while the tool located a region by
# matching this text, a wrong default silently split a page into two sections and
# `xref add` appended to the wrong one. Now the marker pair carries the identity,
# so a region rendered under the wrong words is a *display* fault that the next
# write repairs by itself once `kb/CONVENTIONS.md` says otherwise.
#
# So this is a fallback for the window between installing the machinery and
# writing the conventions file - `doctor` is what makes that window loud - and
# not a language the compiler has an opinion about.
DEFAULT_HEADINGS: dict[str, str] = {
"links": "Relationships",
"footnotes": "Footnotes",
}
@@ -119,44 +119,12 @@ def language() -> Optional[str]:
return str(value).strip() or None
def canonical(slot: str) -> str:
"""The heading name this instance writes for `slot`."""
declared = _mapping(SECTIONS_KEY).get(slot)
def heading(block: str) -> str:
"""The heading this instance renders above `block`'s generated region."""
declared = _mapping(SECTIONS_KEY).get(block)
if isinstance(declared, str) and declared.strip():
return declared.strip()
return PRE_CONVENTIONS_NAMES[slot][0]
def names(slot: str) -> tuple[str, ...]:
"""Every heading name `slot` is recognized under, canonical first.
The canonical name, then any `section_aliases:` the instance declared, then
the names this stack wrote before the conventions file existed. Deduplicated
while preserving that order, so an instance declaring English does not end
up with `Relationships` listed twice.
"""
declared_aliases = _mapping(SECTION_ALIASES_KEY).get(slot)
extra = declared_aliases if isinstance(declared_aliases, list) else []
ordered = [
canonical(slot),
*(str(name).strip() for name in extra if str(name).strip()),
*PRE_CONVENTIONS_NAMES[slot],
]
seen: dict[str, None] = {}
for name in ordered:
seen.setdefault(name, None)
return tuple(seen)
def section_variables() -> dict[str, str]:
"""The `{section.<slot>}` substitutions a type-spec template can use.
This is what took the three German headings out of `types/*.md`: a template
writes `## {section.relationships}` and the instance's own conventions fill
it in, so scaffolding a page in another language needs no edit under
`types/`.
"""
return {f"section.{slot}": canonical(slot) for slot in SLOTS}
return DEFAULT_HEADINGS.get(block, block.title())
def declaration_issues() -> list[str]:
@@ -167,6 +135,8 @@ def declaration_issues() -> list[str]:
looks like. An absent file is *not* reported here - that is a separate
finding with a separate fix, and only `doctor` makes it one.
"""
from chemenu import blocks
path = conventions_file()
if not path.is_file():
return []
@@ -176,46 +146,29 @@ def declaration_issues() -> list[str]:
if not frontmatter:
return [
f"kb/{CONVENTIONS_FILENAME} has no readable frontmatter - it must declare "
f"`{SECTIONS_KEY}:` with the heading names this instance writes"
f"`{SECTIONS_KEY}:` with the headings this instance renders"
]
declared = frontmatter.get(SECTIONS_KEY)
if not isinstance(declared, dict):
return [
f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}:` must be a mapping of "
f"{'/'.join(SLOTS)} to the heading text this instance writes"
f"{'/'.join(blocks.BLOCKS)} to the heading this instance renders above it"
]
for slot in SLOTS:
value = declared.get(slot)
for block in blocks.BLOCKS:
value = declared.get(block)
if not isinstance(value, str) or not value.strip():
issues.append(
f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}.{slot}` is missing or empty - "
"`xref add` and `cite add` write into a heading this instance has not named"
f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}.{block}` is missing or empty - "
f"the generated `{block}` region would render under a default heading rather "
"than this instance's own"
)
for slot in sorted(set(declared) - set(SLOTS)):
for block in sorted(set(declared) - set(blocks.BLOCKS)):
issues.append(
f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}.{slot}` is not a section the tool "
f"owns; the slots are {', '.join(SLOTS)}"
f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}.{block}` is not a region the tool "
f"generates; the regions are {', '.join(blocks.BLOCKS)}"
)
aliases = frontmatter.get(SECTION_ALIASES_KEY, {})
if not isinstance(aliases, dict):
issues.append(
f"kb/{CONVENTIONS_FILENAME}: `{SECTION_ALIASES_KEY}:` must be a mapping of a "
"slot to the list of headings still recognized under it"
)
else:
for slot, value in sorted(aliases.items()):
if slot not in SLOTS:
issues.append(
f"kb/{CONVENTIONS_FILENAME}: `{SECTION_ALIASES_KEY}.{slot}` is not a "
f"section the tool owns; the slots are {', '.join(SLOTS)}"
)
elif not isinstance(value, list):
issues.append(
f"kb/{CONVENTIONS_FILENAME}: `{SECTION_ALIASES_KEY}.{slot}` must be a list"
)
if config.TEMPLATE_SENTINEL in path.read_text(encoding="utf-8"):
issues.append(
f"kb/{CONVENTIONS_FILENAME} still carries the `{config.TEMPLATE_SENTINEL}` line - "
+20 -1
View File
@@ -26,7 +26,7 @@ from collections import Counter
from dataclasses import dataclass, field
from typing import Any, Optional
from chemenu import kb_scan, provenance
from chemenu import blocks, kb_scan, provenance
from chemenu.page import Page
from chemenu.type_resolver import resolver
@@ -59,6 +59,7 @@ class PageShape:
cite_refs: Counter
cite_defs: dict[str, str]
fields: dict[str, Any]
markers: dict[str, int]
body: str
@classmethod
@@ -81,6 +82,7 @@ class PageShape:
cite_refs=Counter(m.group(1) for m in provenance.CITE_REF_RE.finditer(head)),
cite_defs={cite_id: source for cite_id, (source, _) in definitions.items()},
fields=fields,
markers=blocks.marker_pairs(page.body),
body=page.body,
)
@@ -163,6 +165,23 @@ def compare_page(path: str, before: PageShape, after: PageShape) -> list[PageFin
changed.append(f"[^{cite_id}] {was!r} -> {now!r}")
findings.append(PageFinding(path, "cite-defs", ", ".join(changed)))
# A generated region that lost or gained a marker is the failure mode the
# delimiters were introduced against, and it is silent: a lost opening
# marker turns the region into ordinary prose, and the next write appends a
# second region beside it. An agent rewriting prose at the boundary is
# exactly how that happens, which is what makes it a migration invariant
# rather than a lint nicety.
#
# Counts, not presence - the same reasoning as the wikilink counter. A page
# that goes from one links region to two has the same *set* of region names.
if before.markers != after.markers:
changed_regions = []
for name in sorted(set(before.markers) | set(after.markers)):
was, now = before.markers.get(name, 0), after.markers.get(name, 0)
if was != now:
changed_regions.append(f"{name}: {was} -> {now}")
findings.append(PageFinding(path, "markers", ", ".join(changed_regions)))
changed_fields = []
for name in sorted(set(before.fields) | set(after.fields)):
was, now = before.fields.get(name), after.fields.get(name)
+32 -1
View File
@@ -267,10 +267,41 @@ def _format_list(items: list[Any]) -> str:
return "[" + ", ".join(_format_scalar(v, flow=True) for v in items) + "]"
def _is_single_key_mapping(value: Any) -> bool:
return isinstance(value, dict) and len(value) == 1
def _format_mapping_list(key: str, items: list[Any]) -> str:
"""A list holding `label: target` pairs, rendered block-style.
The inline `[...]` form this file uses everywhere else cannot carry a
mapping without quoting rules nobody reading the file would guess, so a
labelled edge list is the one place block style earns its keep:
related:
- depends-on: Hermes
- Borealis
Bare strings mixed in stay bare - that is an edge whose label has not been
declared yet, and promoting it to some default here would erase exactly what
`lint` is looking for.
"""
lines = [f"{key}:"]
for item in items:
if _is_single_key_mapping(item):
(label, target), = item.items()
lines.append(f" - {_format_scalar(label)}: {_format_scalar(target)}")
else:
lines.append(f" - {_format_scalar(item)}")
return "\n".join(lines)
def dump_frontmatter(frontmatter: dict[str, Any]) -> str:
lines = []
for key, value in frontmatter.items():
if isinstance(value, list):
if isinstance(value, list) and any(_is_single_key_mapping(v) for v in value):
lines.append(_format_mapping_list(key, value))
elif isinstance(value, list):
lines.append(f"{key}: {_format_list(value)}")
else:
lines.append(f"{key}: {_format_scalar(value)}")
+87 -20
View File
@@ -37,14 +37,59 @@ CONTRACT_NAME = "COLLECTION.md"
PROFILE_FIELD = "profile"
REQUIRED_BY_STACK_FIELD = "required_by_stack"
# Collections `wikitool` itself depends on by name, as opposed to ones that
# merely hold pages. `sources` is here because three parts of the stack resolve
# against it rather than against a page's type: `sources coverage` asks which
# raw files no source page claims, every `[^cite-id]` footnote resolves to a
# page in it, and `sources rebuild-index` writes `kb/provenance.md` from it. An
# instance may add, rename or drop any collection that is not on this list;
# renaming one that is leaves those three with nothing to resolve against.
STACK_REQUIRED_COLLECTIONS = ("sources",)
# Which link labels a page in this collection may use, per destination
# collection. The **source** collection decides, which is the whole point: an
# edge is an authored reader-aid written on the page that asserts it, so the
# rules that govern it are the rules of the collection that page lives in. A
# destination is another collection's name, or `any`.
#
# This is Commonplace's ADR-019 adopted directly, and it is what makes a
# 35-label catalogue usable: a collection authorises the six that make sense
# from it, and the rest of the palette is simply not on its menu.
OUTBOUND_FIELD = "outbound"
ANY_DESTINATION = "any"
# The types `wikitool` itself depends on existing, as opposed to ones an
# instance keeps because they are useful. `source` is here because the whole
# `raw/ -> kb/` provenance path is built on it: `sources coverage` asks which
# raw files no source page claims, every `[^cite-id]` resolves to a source page,
# and `sources rebuild-index` writes `kb/provenance.md` from them. All three ask
# `page.kind == "source"`, so what is load-bearing is the type-spec's `name:`
# and its schema requiring `raw_files:` - not the directory, not the title
# prefix, and not a word of its prose or its template.
#
# That is the whole anchor, and it is deliberately this small: the four page
# type-specs belong to the instance (see types/type-spec.md), so anything more
# would be the stack reaching into a file it does not own.
STACK_REQUIRED_TYPES = ("source",)
STACK_REQUIRED_TYPE_FIELDS = {"source": ("raw_files",)}
def stack_required_collections() -> tuple[str, ...]:
"""Collection names an instance may not rename or drop.
**Derived, not listed.** The required collection is whichever one the
required type writes into - so an instance that legitimately renames
`kb/sources/` to something else, and says so in the type-spec's `base_dir:`,
stays consistent instead of tripping a constant that hardcoded the old name.
A second literal list would only be a copy that drifts.
"""
from chemenu.type_resolver import resolver
names: list[str] = []
for type_name in STACK_REQUIRED_TYPES:
try:
type_path = resolver.find_type_by_name(type_name)
if not type_path:
continue
if resolver.get_root(type_path) != "kb":
continue
base_dir = resolver.get_base_dir(type_path)
except (ValueError, OSError):
continue
if base_dir:
names.append(str(base_dir).strip("/"))
return tuple(dict.fromkeys(names))
def iter_kb_collections(kb_dir: Path | None = None) -> list[Path]:
@@ -119,6 +164,25 @@ def collection_declaration(collection: Path) -> dict[str, Any]:
return frontmatter
def authorised_labels(source: str, destination: str, kb_dir: Path | None = None) -> set[str]:
"""Labels a page in `source` may use on an edge into `destination`.
The union of the destination's own entry and `any`. An empty result means
the collection authorises nothing for that destination - which is a real
answer ("do not link there from here"), not a missing declaration.
"""
root = kb_dir if kb_dir is not None else config.KB_DIR
declared = collection_declaration(root / source).get(OUTBOUND_FIELD)
if not isinstance(declared, dict):
return set()
labels: set[str] = set()
for key in (destination, ANY_DESTINATION):
entry = declared.get(key)
if isinstance(entry, list):
labels.update(str(label).strip() for label in entry if str(label).strip())
return labels
def declaration_issues(kb_dir: Path | None = None) -> list[str]:
"""What each `COLLECTION.md` fails to declare about itself.
@@ -127,19 +191,22 @@ def declaration_issues(kb_dir: Path | None = None) -> list[str]:
free text, because the profile catalogue is a palette rather than an enum,
and a collection an instance invented has no entry there to name.
`required_by_stack:` is not the instance's to choose at all: it must agree
with `STACK_REQUIRED_COLLECTIONS`, so a collection whose contract claims the
stack depends on it - or one the stack does depend on and that says it does
not - is a finding rather than a preference.
with what `stack_required_collections()` derives from the required types, so
a collection whose contract claims the stack depends on it - or one the
stack does depend on and that says it does not - is a finding rather than a
preference.
"""
root = kb_dir if kb_dir is not None else config.KB_DIR
issues: list[str] = []
required = stack_required_collections()
present = {path.name for path in iter_kb_collections(root)}
for name in STACK_REQUIRED_COLLECTIONS:
for name in required:
if name not in present:
issues.append(
f"kb/{name}/ is missing - `sources coverage`, `[^cite-id]` resolution and "
f"`kb/provenance.md` all resolve against it by name"
f"kb/{name}/ is missing - it is where the stack-required `source` type writes, "
f"and `sources coverage`, `[^cite-id]` resolution and `kb/provenance.md` all "
f"depend on those pages existing"
)
for collection in iter_kb_collections(root):
@@ -159,17 +226,17 @@ def declaration_issues(kb_dir: Path | None = None) -> list[str]:
f"instructions/kb-profiles.md this collection adopted, or `none`"
)
required = declared.get(REQUIRED_BY_STACK_FIELD)
expected = collection.name in STACK_REQUIRED_COLLECTIONS
if not isinstance(required, bool):
required_flag = declared.get(REQUIRED_BY_STACK_FIELD)
expected = collection.name in required
if not isinstance(required_flag, bool):
issues.append(
f"{relative}: `{REQUIRED_BY_STACK_FIELD}:` is missing or not a boolean - "
f"it must be {str(expected).lower()} for this collection"
)
elif required != expected:
elif required_flag != expected:
issues.append(
f"{relative}: `{REQUIRED_BY_STACK_FIELD}: {str(required).lower()}` contradicts "
f"the stack, which "
f"{relative}: `{REQUIRED_BY_STACK_FIELD}: {str(required_flag).lower()}` "
f"contradicts the stack, which "
+ (
"does depend on this collection by name"
if expected
+101 -6
View File
@@ -39,6 +39,16 @@ def kb_state_file() -> Path:
return config.ROOT / KB_STATE_FILENAME
# Whether a migration has to run, as opposed to how it is carried out. The two
# are independent: a `mechanical` migration can be optional and an `assisted`
# one mandatory. Keeping them on one axis is what would make `migrate status`
# cry wolf - an instance nagged about an improvement it declined stops reading
# the nag that means its content no longer fits the machinery.
REQUIRED = "required"
OFFERED = "offered"
OBLIGATIONS = (REQUIRED, OFFERED)
@dataclass(frozen=True)
class Migration:
"""One migration document under `instructions/migrations/`."""
@@ -48,6 +58,11 @@ class Migration:
kind: str # "mechanical" | "assisted"
description: str
path: Path
obligation: str = REQUIRED
@property
def is_required(self) -> bool:
return self.obligation != OFFERED
@property
def relative_path(self) -> str:
@@ -130,6 +145,7 @@ def load_migrations() -> list[Migration]:
target = Version.parse(str(raw_target))
except VersionError:
continue
obligation = str(frontmatter.get("obligation") or REQUIRED)
migrations.append(
Migration(
name=str(frontmatter.get("name") or path.stem),
@@ -137,6 +153,7 @@ def load_migrations() -> list[Migration]:
kind=str(frontmatter.get("migration_kind") or "assisted"),
description=str(frontmatter.get("description") or ""),
path=path,
obligation=obligation if obligation in OBLIGATIONS else REQUIRED,
)
)
return sorted(migrations, key=lambda m: m.target)
@@ -147,13 +164,46 @@ def chain(
) -> list[Migration]:
"""The migrations still owed, in the order they must run.
Every migration whose target lies in `(kb_version, stack_version]`, oldest
first. An instance at 1.3.1 upgrading to 2.0.0 gets 1.4.0, 1.7.0, 2.0.0 -
and the absence of any migration targeting 1.3.x is not a special case, it
simply is not in the interval. Targets above the installed machinery are
excluded: the instance has no code for them yet.
Every **required** migration whose target lies in
`(kb_version, stack_version]`, oldest first. An instance at 1.3.1 upgrading
to 2.0.0 gets 1.4.0, 1.7.0, 2.0.0 - and the absence of any migration
targeting 1.3.x is not a special case, it simply is not in the interval.
Targets above the installed machinery are excluded: the instance has no code
for them yet.
`offered` migrations are deliberately absent. They are not links in the
version chain: declining one leaves the content in a shape the machinery
still accepts, so counting it as owed would make `kb_version` unreachable
for an instance that simply kept its own file.
"""
return [m for m in migrations if kb_version < m.target <= stack_version]
return [
m for m in migrations if m.is_required and kb_version < m.target <= stack_version
]
def applied_names(state: Optional[dict]) -> set[str]:
"""Every migration this instance has recorded as carried out."""
entries = (state or {}).get("applied") or []
return {
str(entry.get("migration"))
for entry in entries
if isinstance(entry, dict) and entry.get("migration")
}
def offers(migrations: list[Migration], applied: set[str]) -> list[Migration]:
"""Optional upgrades this instance has not taken, oldest target first.
Bounded by the **applied ledger**, not by `kb_version`, and that is not a
detail: taking an offer deliberately does not move `kb_version`, so the
version says nothing about whether an offer was taken. Filtering by it
would hide every offer the moment some unrelated required migration ran.
Not bounded above by the stack version either. An offer is about a file the
instance owns rather than about the shape of its content, so it stays on the
table until it is recorded - or until the operator deletes the document.
"""
return [m for m in migrations if not m.is_required and m.name not in applied]
def next_link(
@@ -161,3 +211,48 @@ def next_link(
) -> Optional[Migration]:
pending = chain(migrations, kb_version, stack_version)
return pending[0] if pending else None
# --- what this instance changed about what it was given --------------------
def divergent_files() -> Optional[list[str]]:
"""Files whose content no longer matches the release this instance installed.
Reads the per-file sha256 in `.wikitool-release.json`, which `dist export`
has been writing since the stamp existed and which nothing has read until
now. Its own docstring says why it is there: it is the only way a later
upgrade can tell a file the instance *edited* from one it merely *received*.
That distinction is what makes an `offered` migration actionable. The stack
proposing a better `entity` template needs to know whether it may be copied
over or whether the instance has its own version that a person has to
reconcile - and only the recorded hash can answer that.
Returns None when the question is unanswerable (a development tree, which
carries no stamp), which is different from `[]` (nothing diverged).
"""
import hashlib
from chemenu import version as version_mod
try:
stamp = version_mod.read_stamp()
except VersionError:
return None
if not stamp:
return None
recorded = stamp.get("files")
if not isinstance(recorded, dict):
return None
divergent: list[str] = []
for relative, digest in sorted(recorded.items()):
path = config.ROOT / relative
if not path.is_file():
divergent.append(relative)
continue
current = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
if current != digest:
divergent.append(relative)
return divergent
+143
View File
@@ -0,0 +1,143 @@
"""Labelled edges in a page's `related:` frontmatter.
An edge is a **label plus a target**, and the label is an identifier rather than
prose:
related:
- depends-on: Hermes
- implements: Hybrid Search
It used to be a bare list of titles with the label written only into a body
bullet - which meant the graph's semantics lived in German prose the tool had to
parse back, and the vocabulary drifted to 152 distinct labels in 337 bullets
because nothing could check it. The label moves into the data; the body bullet
becomes a rendering of the data.
**Both shapes read.** A bare string is an edge whose label is not yet declared,
which is exactly the state a page is in between the machinery landing and the
corpus migration reaching that page. Readers therefore never crash on the old
shape, and `lint` is what reports it - the migration is finished when no
unlabelled edge is left.
Direction is authored, never mirrored: an edge lives on the page that asserts
it, and the inbound view is rendered from the graph rather than stored. See
instructions/link-taxonomy.md.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterable, Optional
# The label a not-yet-migrated bare-string edge reports as. Deliberately not a
# real catalogue label: it must be impossible for an instance to authorise it,
# so `lint` cannot be satisfied by declaring the placeholder legal.
UNLABELLED = None
@dataclass(frozen=True)
class Edge:
"""One declared relationship: what this page asserts about `target`."""
target: str
label: Optional[str] = UNLABELLED
@property
def is_labelled(self) -> bool:
return bool(self.label)
def parse_entry(entry: Any) -> Optional[Edge]:
"""One `related:` element as an Edge, or None if it is not one at all.
A single-key mapping is a labelled edge; a bare string is an unlabelled one.
Anything else - a multi-key mapping, a list, a number - is malformed, and
returning None rather than guessing is what lets `lint` report it as a
finding instead of a reader silently inventing an edge.
"""
if isinstance(entry, str):
title = entry.strip()
return Edge(title) if title else None
if isinstance(entry, dict) and len(entry) == 1:
(label, target), = entry.items()
label, target = str(label).strip(), str(target).strip()
return Edge(target, label) if label and target else None
return None
def edges(frontmatter: dict[str, Any], field: str) -> list[Edge]:
"""Every well-formed edge in `field`, in file order."""
parsed = (parse_entry(entry) for entry in (frontmatter.get(field) or []))
return [edge for edge in parsed if edge is not None]
def malformed(frontmatter: dict[str, Any], field: str) -> list[Any]:
"""Elements of `field` that are neither a title nor a `label: target` pair."""
return [
entry for entry in (frontmatter.get(field) or []) if parse_entry(entry) is None
]
def targets(frontmatter: dict[str, Any], field: str) -> list[str]:
"""Just the page titles in `field`, labelled or not.
The compatibility seam. Every caller that only ever wanted "which pages does
this reference" - dangling-reference checks, `rename`, `rm`, the link graph -
goes through here and is untouched by the label carried alongside.
"""
return [edge.target for edge in edges(frontmatter, field)]
def render(edge_list: Iterable[Edge]) -> list[Any]:
"""Edges back into frontmatter form, ready for `dump_frontmatter`.
An unlabelled edge round-trips as a bare string rather than being promoted
to some default label: inventing one here would erase the very thing `lint`
is looking for.
"""
rendered: list[Any] = []
for edge in edge_list:
rendered.append({edge.label: edge.target} if edge.is_labelled else edge.target)
return rendered
def upsert(frontmatter: dict[str, Any], field: str, edge: Edge) -> bool:
"""Add or relabel `edge` in `field`. True if anything changed.
Idempotent by target: one page asserts one thing about another, so a second
call with a different label *replaces* rather than appends. Two edges to the
same target would render two bullets and leave no way to say which is meant.
"""
current = edges(frontmatter, field)
for position, existing in enumerate(current):
if existing.target == edge.target:
if existing.label == edge.label:
return False
current[position] = edge
frontmatter[field] = render(current)
return True
current.append(edge)
frontmatter[field] = render(current)
return True
def remove(frontmatter: dict[str, Any], field: str, target: str) -> bool:
"""Drop every edge pointing at `target`. True if anything changed."""
current = edges(frontmatter, field)
kept = [edge for edge in current if edge.target != target]
if len(kept) == len(current):
return False
frontmatter[field] = render(kept)
return True
def retarget(frontmatter: dict[str, Any], field: str, old: str, new: str) -> bool:
"""Repoint every edge from `old` to `new`, keeping its label."""
current = edges(frontmatter, field)
changed = False
for position, edge in enumerate(current):
if edge.target == old:
current[position] = Edge(new, edge.label)
changed = True
if changed:
frontmatter[field] = render(current)
return changed
+86 -2
View File
@@ -16,7 +16,7 @@ from __future__ import annotations
from datetime import date
from pathlib import Path
from chemenu import config
from chemenu import blocks, config, kb_collections, links
from chemenu.frontmatter_io import frontmatter_error
from chemenu.markdown_code import strip_code_spans
from chemenu.provenance import broken_raw_refs as find_broken_raw_refs
@@ -164,7 +164,16 @@ def run_lint(kb_dir: Path) -> dict:
# propagated, a deleted page, or a URL pasted where a title belongs - used
# to pass every check. Which fields hold page titles is declared by each
# type-spec's `page_ref_fields:`, not hardcoded here.
def _collection_of(page):
try:
return page.path.relative_to(config.KB_DIR).parts[0]
except (ValueError, IndexError):
return None
dangling_frontmatter_refs = []
malformed_edges: list[dict] = []
unlabelled_edges: list[dict] = []
unauthorised_labels: list[dict] = []
for title, page in sorted(pages.items()):
type_path = page.frontmatter.get("type")
if not type_path:
@@ -174,11 +183,53 @@ def run_lint(kb_dir: Path) -> dict:
except ValueError:
continue # unresolvable type is already reported as type_resolution_errors
for field in ref_fields:
for target in page.frontmatter.get(field) or []:
# Through `links` so a labelled edge (`- depends-on: Hermes`) is read
# as its target rather than as a mapping - the entry carries the
# label alongside the title now, and comparing the whole entry would
# report every declared edge as dangling.
for target in links.targets(page.frontmatter, field):
if target not in pages:
dangling_frontmatter_refs.append(
{"page": title, "field": field, "target": target}
)
for entry in links.malformed(page.frontmatter, field):
malformed_edges.append(
{"page": title, "field": field, "entry": str(entry)}
)
# Labels are checked on `related:` only. `sources:`/`entities:`/
# `concepts:` are the provenance path, unlabelled by construction.
if "related" in ref_fields:
source_collection = _collection_of(page)
for edge in links.edges(page.frontmatter, "related"):
if not edge.is_labelled:
unlabelled_edges.append({"page": title, "target": edge.target})
continue
target_page = pages.get(edge.target)
if source_collection is None or target_page is None:
continue
destination = _collection_of(target_page)
if destination is None:
continue
allowed = kb_collections.authorised_labels(source_collection, destination)
if edge.label not in allowed:
unauthorised_labels.append(
{
"page": title,
"target": edge.target,
"label": edge.label,
"destination": destination,
}
)
# A generated region whose markers do not pair up is not a tidiness problem:
# the next write appends a second region beside it instead of replacing it,
# and the page then carries two. An agent rewriting prose at the boundary is
# how a marker goes missing, which is why this is a hard error.
unbalanced_marker_findings = [
{"page": title, "region": name}
for title, page in sorted(pages.items())
for name in blocks.unbalanced_markers(page.body)
]
quote_limit_violations = []
for title, page in sorted(pages.items()):
@@ -238,6 +289,10 @@ def run_lint(kb_dir: Path) -> dict:
"undefined_footnote_refs": undefined_footnote_refs,
"orphan_footnote_defs": orphan_footnote_defs,
"dangling_frontmatter_refs": dangling_frontmatter_refs,
"malformed_edges": malformed_edges,
"unlabelled_edges": unlabelled_edges,
"unauthorised_labels": unauthorised_labels,
"unbalanced_markers": unbalanced_marker_findings,
"quote_limit_violations": quote_limit_violations,
"invalid_type_paths": invalid_type_paths,
"type_resolution_errors": type_resolution_errors,
@@ -322,6 +377,23 @@ def render_markdown(report: dict) -> str:
lines, "Orphan Footnote Definitions", report["orphan_footnote_defs"],
lambda i: f"[[{i['page']}]] defines `[^{i['id']}]` (-> [[{i['source']}]]) but nothing references it - run `wikitool cite sync`",
)
_section(
lines, "Malformed Edges", report.get("malformed_edges", []),
lambda i: f"[[{i['page']}]] `{i['field']}`: {i['entry']}",
)
_section(
lines, "Unbalanced Generated-Region Markers", report.get("unbalanced_markers", []),
lambda i: f"[[{i['page']}]]: `{i['region']}`",
)
_section(
lines, "Unlabelled Edges", report.get("unlabelled_edges", []),
lambda i: f"[[{i['page']}]] -> [[{i['target']}]]",
)
_section(
lines, "Labels Not Authorised by the Source Collection",
report.get("unauthorised_labels", []),
lambda i: f"[[{i['page']}]] `{i['label']}` -> kb/{i['destination']}/ ([[{i['target']}]])",
)
_section(
lines, "Dangling Frontmatter References", report["dangling_frontmatter_refs"],
lambda i: f"[[{i['page']}]] `{i['field']}:` names `{i['target']}`, which is not a page",
@@ -406,6 +478,16 @@ def default_report_path(report: dict) -> Path:
# through the index or navigation only. `quote_limit_violations` is advisory
# too - it flags a habit, not a broken tree.
#
# `unlabelled_edges` and `unauthorised_labels` are advisory **for now**, and
# that is a dated decision rather than a judgment about severity: they describe
# exactly the state a corpus is in between the 4.0.0 machinery landing and the
# migration reaching each page, which is the window `.wikitool-kb.json` exists
# to represent. They become hard errors once the migration is recorded - the
# same path `legacy_citation_markers` took.
#
# `malformed_edges` and `unbalanced_markers` are hard from the start: neither
# describes an unconverted page, only a broken one.
#
# 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 = (
@@ -421,6 +503,8 @@ HARD_ERROR_KEYS = (
"undefined_footnote_refs",
"orphan_footnote_defs",
"dangling_frontmatter_refs",
"malformed_edges",
"unbalanced_markers",
"invalid_type_paths",
"type_resolution_errors",
"schema_validation_errors",
+104 -95
View File
@@ -20,7 +20,7 @@ import unicodedata
from pathlib import Path
from typing import Optional
from chemenu import config, sections
from chemenu import blocks, config, conventions
from chemenu.markdown_code import strip_code_spans
from chemenu.page import Page
@@ -45,10 +45,6 @@ CITE_DEF_RE = re.compile(
)
CITE_REF_RE = re.compile(rf"\[\^({_CITE_ID_PATTERN})\]")
# Where the Footnotes block stops: the next ATX heading of any level. Without
# this the block ran to the end of the file and took any following section with
# it - see split_cite_block().
_NEXT_HEADING_RE = re.compile(r"^#{1,6} ", re.MULTILINE)
# The pre-migration marker: `^[[Source - X]]` or `^[[Source - X|file.md]]`,
# read by a Pandoc-style parser as an inline footnote wrapping a broken
@@ -61,27 +57,29 @@ LEGACY_CITE_RE = re.compile(r"\^\[\[([^\]|#]+)(?:\|([^\]]+))?\]\]")
# footnote definitions regardless of the heading text; this heading is purely
# for human readability when the raw markdown is read directly.
#
# Written under the canonical name, but split_cite_block() matches the aliases
# too - a page whose block still says "## Footnotes" keeps working until it is
# translated. See chemenu/sections.py.
# The prefix a source page's title carries, stripped when minting a cite id so
# the id is not "s-source-x". It is the `source` type-spec's own
# `title_prefix:`, asked for at call time rather than written down here: the
# type-spec belongs to the instance, so hardcoding the string made a documented
# instance decision into a compiler constant - the same leak `sections.py` had.
#
# Resolved on access rather than bound at import (PEP 562), because the
# canonical name is now this instance's own - `kb/CONVENTIONS.md`, via
# chemenu.conventions - and a module constant would freeze whichever corpus the
# process started in. The functions below take it as a default the same way, via
# None rather than an evaluated default argument.
def __getattr__(name: str) -> str:
if name == "CITE_BLOCK_HEADING":
return f"## {sections.FOOTNOTES}"
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
# The literal survives as the fallback for a tree with no resolvable `source`
# type (a fixture, a half-built instance). It is what this stack shipped, so a
# corpus that can reach the fallback was minted under it, and ids stay stable.
_FALLBACK_SOURCE_TITLE_PREFIX = "Source - "
def cite_block_heading_default() -> str:
"""The Footnotes heading this instance writes, `## ` included."""
return f"## {sections.FOOTNOTES}"
def source_title_prefix() -> str:
"""This instance's source-page title prefix, from the type-spec."""
from chemenu.type_resolver import resolver
_SOURCE_TITLE_PREFIX = "Source - "
try:
type_path = resolver.find_type_by_name("source")
if type_path:
return resolver.get_title_prefix(type_path)
except (ValueError, OSError):
pass
return _FALLBACK_SOURCE_TITLE_PREFIX
@@ -117,7 +115,8 @@ def cite_id(title: str, qualifier: Optional[str] = None) -> str:
NFKD transliteration is lossy), so callers resolving a real page use
unique_cite_id() to add a `-2`/`-3` suffix on collision.
"""
base_title = title[len(_SOURCE_TITLE_PREFIX):] if title.startswith(_SOURCE_TITLE_PREFIX) else title
prefix = source_title_prefix()
base_title = title[len(prefix):] if prefix and title.startswith(prefix) else title
slug = "s-" + _slugify(base_title)
if qualifier:
slug += "--" + _slugify(qualifier)
@@ -138,62 +137,64 @@ def unique_cite_id(existing_ids: set[str], title: str, qualifier: Optional[str]
return f"{base}-{suffix}"
def split_cite_block(body: str) -> tuple[str, dict[str, tuple[str, Optional[str]]]]:
"""Split the Footnotes block off `body`.
# Headings a pre-4.0.0 page carries above its citation definitions, for the
# migration window only. Before the block was delimited it was *located* by this
# text, which is why there are four of them - two languages times two eras. The
# list is read, never written, and `instructions/migrations/` removes the need
# for it once every page carries markers.
_LEGACY_FOOTNOTE_HEADINGS = ("Fußnoten", "Footnotes", "Fussnoten", "Notes")
Returns (body_without_block, definitions), where definitions maps
cite_id -> (source_title, qualifier_or_None) in file order. If there is
no Footnotes block, definitions is {} and body is returned with trailing
blank lines trimmed (so re-rendering after emptying the block is stable).
_LEGACY_HEADING_RE = re.compile(
r"^## (?:" + "|".join(re.escape(name) for name in _LEGACY_FOOTNOTE_HEADINGS) + r")[ \t]*$",
re.MULTILINE,
)
_NEXT_HEADING_RE = re.compile(r"^#{1,6} ", re.MULTILINE)
**The block is not "everything to the end of the file".** It used to be,
and every caller here reassembles a page as `head + rendered block` - so a
section that happened to sit after the block was silently deleted on the
next `cite add`, `cite sync` or `rename`. That is not hypothetical: `xref
add` appends its Relationships and See Also sections at the end of the
file, so whether a page kept its cross-references came down to which of the
two commands ran last. Eight pages were carrying content in that position
when this was found.
So the block ends where the next heading begins, and everything after it -
plus anything inside it that is not a citation definition - is folded back
on to `head`. Nothing is discarded, and because the rendered block is
always emitted last, a page that had drifted into the broken layout is
normalised the first time any of these commands touches it.
def _definitions_in(block: str) -> dict[str, tuple[str, Optional[str]]]:
"""Every `[^id]: [[Target]]` definition in one region, code masked out.
A fenced example of a definition line is an illustration, not a definition.
`strip_code_spans` preserves offsets and line structure, so the masked text
reads line-for-line against the real one.
"""
# Where the block *starts* is decided on the unmasked body, deliberately.
# Masking first would mean one unclosed fence anywhere in the prose blanks
# the real `## Footnotes` heading too, and the page then reads as having no
# definitions at all - every citation on it undefined, from a single typo.
# A fenced example of the heading itself is the rarer accident and the
# cheaper one: it costs one page its block, not every citation on it.
match = sections.heading_re(sections.FOOTNOTES).search(body)
masked = strip_code_spans(block)
return {
m.group(1): (m.group(2).strip(), m.group(3).strip() if m.group(3) else None)
for m in CITE_DEF_RE.finditer(masked)
}
def _split_legacy_block(body: str) -> tuple[str, dict[str, tuple[str, Optional[str]]]]:
"""The pre-marker layout: a heading, then definitions, ending at the next
heading.
Kept only so the corpus stays readable between this machinery landing and
the migration reaching each page. Every weakness of the old approach lives
here - it guesses the region's end, and it can be fooled by a fenced example
of the heading - which is the argument the marker pair settles.
"""
match = _LEGACY_HEADING_RE.search(body)
if not match:
return body.rstrip("\n"), {}
head, rest = body[: match.start()], body[match.end():]
next_section = _NEXT_HEADING_RE.search(rest)
block, trailing = (rest[: next_section.start()], rest[next_section.start():]) if next_section else (rest, "")
following = _NEXT_HEADING_RE.search(rest)
block, trailing = (
(rest[: following.start()], rest[following.start():]) if following else (rest, "")
)
# Inside the block, code is masked: a fenced example of a definition line is
# an illustration, not a definition. strip_code_spans() preserves offsets
# and line structure, so the masked block can be read line-for-line against
# the real one.
definitions = _definitions_in(block)
masked_block = strip_code_spans(block)
definitions = {
m.group(1): (m.group(2).strip(), m.group(3).strip() if m.group(3) else None)
for m in CITE_DEF_RE.finditer(masked_block)
}
# Lines inside the block that are not definitions are content too - prose
# someone left there, a stray bullet. Rescued rather than rejected: this
# runs under `lint` and `corpus_diff` as well, where raising would refuse
# to read a page instead of reporting it.
# someone left there, a stray bullet. Rescued rather than rejected: this runs
# under `lint` and `corpus_diff` as well, where raising would refuse to read
# a page instead of reporting it.
stray = "\n".join(
line
for line, masked in zip(block.splitlines(), masked_block.splitlines())
if line.strip() and not CITE_DEF_RE.match(masked)
)
rescued = "\n\n".join(part.strip("\n") for part in (stray, trailing) if part.strip())
head = head.rstrip("\n")
if rescued:
@@ -201,49 +202,57 @@ def split_cite_block(body: str) -> tuple[str, dict[str, tuple[str, Optional[str]
return head, definitions
def cite_block_heading(body: str) -> str:
"""The Footnotes heading `body` actually carries, canonical if it has none.
def split_cite_block(body: str) -> tuple[str, dict[str, tuple[str, Optional[str]]]]:
"""Split the citation region off `body`.
Rewriting a page must not silently retitle its block: a page still using an
alias is untranslated, not broken, and `cite sync` has to stay a no-op on
it. Translating the heading is the migration's job, not the tool's."""
match = sections.heading_re(sections.FOOTNOTES).search(body)
return match.group(0).strip() if match else cite_block_heading_default()
Returns (body_without_region, definitions), where definitions maps
cite_id -> (source_title, qualifier_or_None) in file order.
**The region is delimited, not guessed.** It used to end "at the next
heading", and before that "at the end of the file" - and every caller here
reassembles a page as `head + rendered region`, so a section that happened to
sit after it was silently deleted on the next `cite add`, `cite sync` or
`rename`. Eight pages were carrying content in that position when it was
found. A marker pair answers where the region stops exactly, which is the
whole reason for it.
A page with no markers is read through the legacy path instead, so the
corpus stays readable until the migration reaches it.
"""
region = blocks.find(body, blocks.FOOTNOTES)
if region is None:
return _split_legacy_block(body)
return blocks.strip(body, blocks.FOOTNOTES).rstrip("\n"), _definitions_in(region)
def render_cite_block(
definitions: dict[str, tuple[str, Optional[str]]], heading: Optional[str] = None
) -> str:
"""Render the Footnotes block for `definitions` (cite_id -> (title,
qualifier)), preserving dict order. Empty dict renders "" - a page with
no citations carries no block at all.
def render_cite_block(definitions: dict[str, tuple[str, Optional[str]]]) -> str:
"""The citation region for `definitions`, markers included, in dict order.
`heading=None` means this instance's canonical Footnotes heading, resolved
at call time. It cannot be an evaluated default: the name comes from
`kb/CONVENTIONS.md`, so a default bound at import would answer for whichever
corpus the process started in."""
if not definitions:
return ""
lines = [heading or cite_block_heading_default(), ""]
An empty dict renders "" - a page with no citations carries no region at
all, rather than a heading with nothing under it.
"""
lines = []
for cid, (title, qualifier) in definitions.items():
target = f"{title}|{qualifier}" if qualifier else title
lines.append(f"[^{cid}]: [[{target}]]")
return "\n".join(lines) + "\n"
return blocks.render(
blocks.FOOTNOTES, conventions.heading(blocks.FOOTNOTES), lines
)
def render_page_body(
head: str,
definitions: dict[str, tuple[str, Optional[str]]],
heading: Optional[str] = None,
head: str, definitions: dict[str, tuple[str, Optional[str]]]
) -> str:
"""Reassemble a page body from its non-Footnotes content and citation
definitions - the inverse of split_cite_block(). Pass the original body's
`cite_block_heading()` to preserve an alias the page still uses."""
head = head.rstrip("\n")
block = render_cite_block(definitions, heading)
if not block:
return head + "\n"
return head + "\n\n" + block
"""Reassemble a page body from its non-citation content and its definitions -
the inverse of `split_cite_block`.
The heading is no longer threaded through from the caller. It used to be, so
that rewriting a page would not silently retitle a block whose text the tool
was *matching on*; now the marker carries the identity and the heading is a
rendering value, so re-rendering it under this instance's own words is a
repair rather than a rename.
"""
return blocks.replace(head.rstrip("\n") + "\n", blocks.FOOTNOTES, render_cite_block(definitions))
def extract_inline_cites(body: str) -> set[tuple[str, Optional[str]]]:
-84
View File
@@ -1,84 +0,0 @@
"""The section headings wikitool reads and writes inside a page body.
These headings are structural, not prose: `xref add` locates Relationships and
See Also by name, and `cite add` owns the trailing Footnotes block. An author
may add any other heading they like - only the ones named here are matched by
the tool, and only these have to stay predictable.
**Which words they are is the instance's decision, not the stack's.** They
follow the KB language, and the KB language is declared in `kb/CONVENTIONS.md`
(see `chemenu.conventions`). This module used to hold `RELATIONSHIPS =
"Beziehungen"` as a Python constant, which made an instance writing its pages
in any other language edit the compiler to say so - the one place a documented
instance convention had leaked into code.
Each heading has one **canonical** name - what the tool writes - and any number
of **aliases** it still recognizes. That asymmetry is what lets a corpus migrate
page by page instead of all at once: a page still carrying `## Relationships` is
found and appended to correctly, and only takes the canonical name when the page
itself is translated. Removing an alias is therefore a breaking change for every
page not yet converted, not a cleanup.
The three module attributes below resolve on access (PEP 562), the same way
`config`'s paths do and for the same reason: a caller that repoints `KB_DIR`
must not be answered out of a value bound at import time by whichever tree the
process started in.
"""
from __future__ import annotations
import re
from chemenu import conventions
# The slots, re-exported so a caller keeps using `sections.RELATIONSHIPS` as an
# opaque handle. The value it resolves to is the heading text; the name it is
# looked up under is stable.
_SLOT_ATTRS = {
"RELATIONSHIPS": conventions.RELATIONSHIPS,
"SEE_ALSO": conventions.SEE_ALSO,
"FOOTNOTES": conventions.FOOTNOTES,
}
def __getattr__(name: str) -> str:
slot = _SLOT_ATTRS.get(name)
if slot is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
return conventions.canonical(slot)
def __dir__() -> list[str]:
return sorted([*globals(), *_SLOT_ATTRS])
def _slot_of(canonical: str) -> str:
"""The slot whose current canonical name is `canonical`.
Callers hold on to the resolved heading text (`sections.FOOTNOTES`), not to
the slot, so the lookup has to go back the other way. Falls back to matching
against every name a slot is recognized under, so a caller that resolved the
attribute before the conventions file changed still lands on the right slot.
"""
for slot in conventions.SLOTS:
if canonical == conventions.canonical(slot):
return slot
for slot in conventions.SLOTS:
if canonical in conventions.names(slot):
return slot
raise ValueError(f"{canonical!r} is not a tool-owned section heading")
def names(canonical: str) -> tuple[str, ...]:
"""Every name `canonical` is recognized under, canonical first."""
return conventions.names(_slot_of(canonical))
def heading_re(canonical: str) -> re.Pattern[str]:
"""Match a `## <heading>` line for `canonical` or any of its aliases."""
alternation = "|".join(re.escape(name) for name in names(canonical))
return re.compile(rf"^## (?:{alternation})[ \t]*$", re.MULTILINE)
def is_known(heading: str) -> bool:
"""True if `heading` is a canonical name or an alias of one."""
return any(heading in conventions.names(slot) for slot in conventions.SLOTS)
+13 -1
View File
@@ -171,9 +171,21 @@ def kb_dir(tmp_path: Path) -> Path:
"entities/technologies", "entities/people",
"concepts", "sources", "comparisons"):
(kb / sub).mkdir(parents=True)
# The contracts carry a real declaration, because three things now read one:
# `docs verify` checks `profile:`/`required_by_stack:`, and `xref add` asks
# `outbound:` whether a label is authorised from this collection. A fixture
# contract without it would make every `xref add` in the suite fail for a
# reason that has nothing to do with what the test is about.
for collection in ("entities", "concepts", "sources", "comparisons"):
(kb / collection / "COLLECTION.md").write_text(
f"# kb/{collection}/ - Collection Contract\n", encoding="utf-8"
"---\n"
f"profile: {collection}\n"
f"required_by_stack: {'true' if collection == 'sources' else 'false'}\n"
"outbound:\n"
" any: [depends-on, required-by, runs-on, hosts, uses, implements, see-also]\n"
"---\n\n"
f"# kb/{collection}/ - Collection Contract\n",
encoding="utf-8",
)
write_page(
+90
View File
@@ -0,0 +1,90 @@
"""Tests for generated regions - the delimiters that replaced heading matching.
The whole point is that a region's *identity* stops depending on its heading
text. Everything here is about the two questions the old approach answered by
guessing: where does the region start, and where does it stop.
"""
from __future__ import annotations
from chemenu import blocks
PROSE = "# Page\n\n## Beschreibung\n\nProse.\n"
def _links(heading="Beziehungen", lines=("- **uses:** [[X]]",)):
return blocks.render(blocks.LINKS, heading, list(lines))
def test_a_region_round_trips():
body = blocks.replace(PROSE, blocks.LINKS, _links())
assert blocks.find(body, blocks.LINKS) == "## Beziehungen\n\n- **uses:** [[X]]"
assert blocks.strip(body, blocks.LINKS) == PROSE
def test_replacing_does_not_append_a_second_region():
body = blocks.replace(PROSE, blocks.LINKS, _links())
again = blocks.replace(body, blocks.LINKS, _links(lines=["- **uses:** [[Y]]"]))
assert again.count(blocks.open_marker(blocks.LINKS)) == 1
assert "[[X]]" not in again and "[[Y]]" in again
def test_the_heading_inside_a_region_is_not_how_it_is_found():
"""A page whose region carries a heading the instance never declared - an
unconverted page, a hand-edit, another language - is still located exactly.
Under heading matching this was the case that silently created a second
section."""
body = blocks.replace(PROSE, blocks.LINKS, _links(heading="Something Else Entirely"))
assert "[[X]]" in blocks.find(body, blocks.LINKS)
assert blocks.strip(body, blocks.LINKS) == PROSE
def test_content_after_a_region_survives_a_rewrite():
"""The eight-page bug, as a test. The old block ran to the next heading -
and before that to the end of the file - so anything sitting after it was
deleted on the next write."""
body = blocks.replace(PROSE, blocks.LINKS, _links()) + "\n## Afterwards\n\nKeep me.\n"
rewritten = blocks.replace(body, blocks.LINKS, _links(lines=["- **uses:** [[Z]]"]))
assert "Keep me." in rewritten
assert rewritten.count("## Afterwards") == 1
def test_two_regions_coexist_without_reading_each_other():
body = blocks.replace(PROSE, blocks.LINKS, _links())
body = blocks.replace(
body, blocks.FOOTNOTES,
blocks.render(blocks.FOOTNOTES, "Fußnoten", ["[^s-x]: [[Source - X]]"]),
)
assert "[[X]]" in blocks.find(body, blocks.LINKS)
assert "[^s-x]" in blocks.find(body, blocks.FOOTNOTES)
assert blocks.marker_pairs(body) == {"links": 1, "footnotes": 1}
def test_an_empty_region_is_no_region_at_all():
"""A page that cites nothing must not carry an empty Footnotes heading."""
assert blocks.render(blocks.LINKS, "Beziehungen", []) == ""
body = blocks.replace(PROSE, blocks.LINKS, _links())
assert blocks.replace(body, blocks.LINKS, "") == PROSE
def test_an_absent_region_reads_as_none_not_as_empty():
"""None and "" have to stay distinguishable: one means the page has no
region, the other that it has one holding nothing."""
assert blocks.find(PROSE, blocks.LINKS) is None
def test_a_dropped_marker_is_detectable():
"""An agent rewriting prose at the boundary can lose one. Silent otherwise:
the region becomes ordinary prose and the next write appends a second one
beside it."""
body = blocks.replace(PROSE, blocks.LINKS, _links())
assert blocks.unbalanced_markers(body) == []
assert blocks.unbalanced_markers(body.replace(blocks.close_marker(blocks.LINKS), "")) == ["links"]
assert blocks.unbalanced_markers(body.replace(blocks.open_marker(blocks.LINKS), "")) == ["links"]
def test_marker_pairs_counts_rather_than_sets():
"""A page that went from one region to two has the same set of names and a
different count - which is why `migrate verify` compares counts."""
body = blocks.replace(PROSE, blocks.LINKS, _links())
doubled = body + "\n" + _links() + "\n"
assert blocks.marker_pairs(doubled) == {"links": 2}
+31 -8
View File
@@ -133,27 +133,50 @@ def test_sync_page_is_idempotent_once_clean():
from chemenu.page import Page
body = "\n# X\n\n## Definition\n\nCites [^s-a].\n\n## Fußnoten\n\n[^s-a]: [[Source - A]]\n"
from chemenu import blocks
body = blocks.replace(
"\n# X\n\n## Definition\n\nCites [^s-a].\n",
blocks.FOOTNOTES,
blocks.render(blocks.FOOTNOTES, "Fußnoten", ["[^s-a]: [[Source - A]]"]),
)
page = Page(path=Path("/tmp/X.md"), frontmatter={}, body=body)
new_body, changed, pruned, undefined = sync_page(page)
assert changed is False
assert new_body == body
assert pruned == []
assert undefined == []
def test_sync_page_leaves_an_untranslated_footnotes_heading_alone():
"""`cite sync` must not retitle a block just because the page has not been
translated yet - that would make it rewrite the whole corpus on one run."""
def test_sync_page_upgrades_a_pre_marker_block_to_a_delimited_region():
"""The mechanical half of the marker migration, done by the command that
already owns the block.
This inverts an older rule. While the tool *located* the block by matching
its heading, re-rendering one under a different name was a rewrite of the
whole corpus on a single run, so `cite sync` had to leave an untranslated
heading alone. Now the marker carries the identity: converting the region is
a repair, and the heading follows `kb/CONVENTIONS.md` from then on."""
from pathlib import Path
from chemenu import blocks
from chemenu.page import Page
body = "\n# X\n\n## Definition\n\nCites [^s-a].\n\n## Footnotes\n\n[^s-a]: [[Source - A]]\n"
page = Page(path=Path("/tmp/X.md"), frontmatter={}, body=body)
new_body, changed, pruned, undefined = sync_page(page)
assert changed is False
assert "## Footnotes" in new_body
assert "## Fußnoten" not in new_body
new_body, changed, _pruned, undefined = sync_page(page)
assert changed is True
assert undefined == []
assert blocks.unbalanced_markers(new_body) == []
assert "[^s-a]: [[Source - A]]" in blocks.find(new_body, blocks.FOOTNOTES)
# The prose above it is untouched, and the legacy heading is not left behind
# as a second, now-empty section.
assert "## Definition" in new_body
# The legacy heading is not left behind as a second, now-empty section: the
# region carries its own heading, rendered from `kb/CONVENTIONS.md`.
assert "## Footnotes" not in new_body
assert new_body.count("<!-- wikitool:footnotes -->") == 1
def test_cite_sync_command_over_kb(kb_dir, raw_dir, monkeypatch):
+84 -57
View File
@@ -1,9 +1,9 @@
"""Tests for `kb/CONVENTIONS.md` - the instance-owned half of the authoring rules.
Two things are under test here, and they are the two the split exists for: the
compiler reads its section headings from the corpus rather than from Python, and
a collection declares who owns its rules rather than having it inferred from the
directory name.
headings the compiler *renders* come from the corpus rather than from Python,
and a collection declares who owns its rules rather than having it inferred from
the directory name.
"""
from __future__ import annotations
@@ -11,15 +11,15 @@ from pathlib import Path
import pytest
from chemenu import config, conventions, kb_collections, sections
from chemenu import blocks, config, conventions, kb_collections
from chemenu.tests.conftest import use_shipped_type_specs
GERMAN = (
"---\n"
"language: de\n"
"profile: german\n"
"sections:\n"
" relationships: Beziehungen\n"
" see_also: Siehe auch\n"
" links: Beziehungen\n"
" footnotes: Fußnoten\n"
"---\n\n# conventions\n"
)
@@ -29,11 +29,8 @@ FRENCH = (
"language: fr\n"
"profile: none\n"
"sections:\n"
" relationships: Relations\n"
" see_also: Voir aussi\n"
" links: Relations\n"
" footnotes: Notes\n"
"section_aliases:\n"
" relationships: [Beziehungen]\n"
"---\n\n# conventions\n"
)
@@ -44,6 +41,11 @@ def kb_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
kb.mkdir()
monkeypatch.setattr(config, "ROOT", tmp_path)
monkeypatch.setattr(config, "KB_DIR", kb)
# Which collection the stack requires is *derived* from where the required
# `source` type writes, so these tests need the shipped `types/` reachable -
# a fixture tree without one derives an empty requirement and would assert
# against a rule that is not running. See conftest.use_shipped_type_specs.
use_shipped_type_specs(monkeypatch)
conventions.reset_cache()
yield kb
conventions.reset_cache()
@@ -64,65 +66,53 @@ def _collection(kb: Path, name: str, profile: str = "none", required: bool = Fal
return directory
def test_missing_file_falls_back_to_what_the_stack_used_to_hardcode(kb_root):
"""The state between installing this machinery and running the migration
that writes the file. Every command has to keep working through it, and the
only corpus that can be in it was written under these names."""
assert conventions.canonical(conventions.FOOTNOTES) == "Fußnoten"
assert sections.FOOTNOTES == "Fußnoten"
def test_a_missing_file_renders_under_a_cosmetic_default(kb_root):
"""The window between installing the machinery and writing the conventions
file. It has to render *something*, and a wrong heading is now merely wrong
words: the marker pair carries the region's identity, so the next write
repairs it once the instance declares one. Before markers, the same mistake
split a page into two sections."""
assert conventions.heading(blocks.FOOTNOTES) == "Footnotes"
assert conventions.heading(blocks.LINKS) == "Relationships"
def test_the_compiler_writes_the_headings_the_instance_declared(kb_root):
def test_the_compiler_renders_the_headings_the_instance_declared(kb_root):
_write(kb_root, FRENCH)
assert sections.RELATIONSHIPS == "Relations"
assert sections.SEE_ALSO == "Voir aussi"
assert sections.FOOTNOTES == "Notes"
def test_declared_aliases_and_the_pre_conventions_names_are_both_recognized(kb_root):
"""The translation path. A page still carrying the old heading has to be
found and appended to, or a language change would silently split every page
into two Relationships sections."""
_write(kb_root, FRENCH)
pattern = sections.heading_re(sections.RELATIONSHIPS)
for heading in ("## Relations", "## Beziehungen", "## Relationships"):
assert pattern.search(f"# Page\n\n{heading}\n\n- x\n"), heading
def test_the_canonical_name_is_not_duplicated_among_its_aliases(kb_root):
"""An instance declaring the pre-conventions name gets it once, not twice -
otherwise `heading_re`'s alternation carries a redundant branch and
`names()` misreports what a page could be carrying."""
_write(
kb_root,
"---\nsections:\n relationships: Relationships\n"
" see_also: See Also\n footnotes: Footnotes\n---\n",
)
names = conventions.names(conventions.RELATIONSHIPS)
assert names[0] == "Relationships"
assert len(names) == len(set(names))
def test_section_variables_are_what_a_type_spec_template_substitutes(kb_root):
_write(kb_root, GERMAN)
assert conventions.section_variables() == {
"section.relationships": "Beziehungen",
"section.see_also": "Siehe auch",
"section.footnotes": "Fußnoten",
}
assert conventions.heading(blocks.LINKS) == "Relations"
assert conventions.heading(blocks.FOOTNOTES) == "Notes"
def test_a_rewritten_file_is_not_answered_out_of_the_cache(kb_root):
_write(kb_root, GERMAN)
assert sections.FOOTNOTES == "Fußnoten"
assert conventions.heading(blocks.FOOTNOTES) == "Fußnoten"
_write(kb_root, FRENCH)
assert sections.FOOTNOTES == "Notes"
assert conventions.heading(blocks.FOOTNOTES) == "Notes"
def test_a_region_is_found_by_its_marker_not_by_its_heading(kb_root):
"""The point of the whole change. A page whose heading says something the
instance never declared - an untranslated page, a hand-edit, another
language entirely - is still located exactly."""
_write(kb_root, FRENCH)
body = blocks.replace(
"# Page\n\nProse.\n",
blocks.LINKS,
blocks.render(blocks.LINKS, "Ganz andere Wörter", ["- **uses:** [[X]]"]),
)
assert "- **uses:** [[X]]" in blocks.find(body, blocks.LINKS)
def test_an_unknown_section_key_is_reported(kb_root):
_write(
kb_root,
"---\nsections:\n links: L\n footnotes: F\n see_also: S\n---\n",
)
assert any("see_also" in issue for issue in conventions.declaration_issues())
def test_an_incomplete_sections_block_is_reported(kb_root):
_write(kb_root, "---\nlanguage: de\nsections:\n relationships: Beziehungen\n---\n")
_write(kb_root, "---\nlanguage: de\nsections:\n links: Beziehungen\n---\n")
issues = conventions.declaration_issues()
assert any("sections.see_also" in issue for issue in issues)
assert any("sections.footnotes" in issue for issue in issues)
@@ -165,3 +155,40 @@ def test_a_correct_declaration_reports_nothing(kb_root):
_collection(kb_root, "sources", profile="sources", required=True)
_collection(kb_root, "entities", profile="entities")
assert kb_collections.declaration_issues(kb_root) == []
# --- outbound authorisation ------------------------------------------------
def _authorising(kb: Path, name: str, outbound: str, required: bool = False) -> Path:
directory = kb / name
directory.mkdir(parents=True, exist_ok=True)
(directory / kb_collections.CONTRACT_NAME).write_text(
f"---\nprofile: {name}\nrequired_by_stack: {str(required).lower()}\n"
f"outbound:\n{outbound}\n---\n\n# {name}\n",
encoding="utf-8",
)
return directory
def test_the_source_collection_decides_which_labels_may_be_used(kb_root):
"""Commonplace ADR-019, adopted: the rules that govern an edge are the rules
of the collection the *asserting* page lives in. That is also why the reverse
edge cannot be written automatically - it would be governed by a contract the
author never read."""
_authorising(kb_root, "entities", " concepts: [implements]\n entities: [uses]")
assert kb_collections.authorised_labels("entities", "concepts") == {"implements"}
assert kb_collections.authorised_labels("entities", "entities") == {"uses"}
def test_any_widens_every_destination(kb_root):
_authorising(kb_root, "entities", " any: [see-also]\n concepts: [implements]")
assert kb_collections.authorised_labels("entities", "concepts") == {"implements", "see-also"}
assert kb_collections.authorised_labels("entities", "sources") == {"see-also"}
def test_an_undeclared_destination_authorises_nothing(kb_root):
"""An empty result is a real answer - "do not link there from here" - not a
missing declaration to be filled in with a permissive default."""
_authorising(kb_root, "entities", " concepts: [implements]")
assert kb_collections.authorised_labels("entities", "sources") == set()
+37
View File
@@ -76,6 +76,19 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
types_dir = root / "types"
types_dir.mkdir()
(types_dir / "entity.schema.yaml").write_text("type: object\n", encoding="utf-8")
# Two real type-specs, one on each side of the `root:` line, so the export's
# split has something to split. `entity` writes into kb/ and is therefore
# the instance's; `instruction` writes into the repo and is the stack's.
(types_dir / "entity.md").write_text(
"---\ntype: types/type-spec.md\nname: entity\ndescription: d\n"
"schema: types/entity.schema.yaml\nbase_dir: entities\n---\n\n# Entity\n",
encoding="utf-8",
)
(types_dir / "instruction.md").write_text(
"---\ntype: types/type-spec.md\nname: instruction\ndescription: d\n"
"schema: null\nbase_dir: instructions\nroot: repo\n---\n\n# Instruction\n",
encoding="utf-8",
)
tools_dir = root / "tools"
(tools_dir / "chemenu").mkdir(parents=True)
@@ -274,6 +287,10 @@ def test_find_leaks_is_silent_on_a_clean_plan(repo):
# filled name must never cross - only the `.template` beside it does.
"kb/CONVENTIONS.md",
"kb/entities/COLLECTION.md",
# A page type-spec under its filled name: the instance's, not the
# stack's, so shipping it would hand a new instance this one's
# authoring language as though the stack had decided it.
"types/entity.md",
],
)
def test_find_leaks_catches_one_instance_own_data(repo, relative):
@@ -318,6 +335,26 @@ def test_plan_ships_the_conventions_template_and_not_the_filled_file(repo):
assert "kb/CONVENTIONS.md" not in plan
def test_page_type_specs_ship_as_templates_and_stack_types_do_not(repo, monkeypatch):
"""The `root:` line, applied. A type-spec whose instances are pages under
`kb/` describes what this instance writes, so its prose, template and
language are the instance's; one whose instances are stack artifacts ships
verbatim. The `.schema.yaml` travels with its spec - the two are one type,
and adopting half would leave a spec validated by a file it does not own."""
from chemenu.type_resolver import resolver
monkeypatch.setattr(resolver, "_repo_root", config.ROOT)
plan = dist_cmd.build_plan()
assert "types/entity.md.template" in plan
assert "types/entity.schema.yaml.template" in plan
assert "types/entity.md" not in plan
assert "types/entity.schema.yaml" not in plan
assert "types/instruction.md" in plan
assert "types/instruction.md.template" not in plan
def test_plan_creates_empty_raw_subdirs_not_real_content(repo):
plan = dist_cmd.build_plan()
for sub in ("articles", "documents", "notes", "assets"):
+7 -3
View File
@@ -25,14 +25,18 @@ def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
kb = root / "kb"
for sub in ("entities", "concepts", "sources", "comparisons"):
(kb / sub).mkdir(parents=True)
(kb / sub / "COLLECTION.md").write_text(f"# {sub}\n", encoding="utf-8")
(kb / sub / "COLLECTION.md").write_text(
f"---\nprofile: {sub}\nrequired_by_stack: "
f"{'true' if sub == 'sources' else 'false'}\n---\n\n# {sub}\n",
encoding="utf-8",
)
(kb / "index.md").write_text("# Index\n", encoding="utf-8")
(kb / "log.md").write_text("# Log\n", encoding="utf-8")
(kb / "provenance.md").write_text("# Provenance\n", encoding="utf-8")
(kb / "CONTRACT.md").write_text("# kb contract\n", encoding="utf-8")
(kb / "CONVENTIONS.md").write_text(
"---\nlanguage: en\nprofile: none\nsections:\n"
" relationships: Relationships\n see_also: See Also\n footnotes: Footnotes\n"
" links: Relationships\n footnotes: Footnotes\n"
"---\n\n# conventions\n",
encoding="utf-8",
)
@@ -220,7 +224,7 @@ def test_conventions_with_an_incomplete_sections_block_fail(instance):
"""Present and deciding nothing - the same failure mode the personalization
sentinel check exists for, one directory down."""
(config.KB_DIR / conventions.CONVENTIONS_FILENAME).write_text(
"---\nlanguage: en\nsections:\n relationships: Relationships\n---\n", encoding="utf-8"
"---\nlanguage: en\nsections:\n links: Relationships\n---\n", encoding="utf-8"
)
conventions.reset_cache()
assert _status(doctor.run_doctor(), "conventions") == "FAIL"
+144 -1
View File
@@ -16,7 +16,13 @@ from chemenu.version import Version
CHANGES = "# Changelog\n\n---\n\n## 1.0.0 - 2026-08-30 - First\n\nBody.\n"
def write_migration(directory: Path, target: str, slug: str, kind: str = "assisted") -> Path:
def write_migration(
directory: Path,
target: str,
slug: str,
kind: str = "assisted",
obligation: str = "required",
) -> Path:
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{target}-{slug}.md"
path.write_text(
@@ -27,6 +33,7 @@ def write_migration(directory: Path, target: str, slug: str, kind: str = "assist
"manual: true\n"
f"migrates_to: {target}\n"
f"migration_kind: {kind}\n"
f"obligation: {obligation}\n"
"---\n\n# Migration\n\nSteps.\n",
encoding="utf-8",
)
@@ -259,3 +266,139 @@ def test_verify_reports_an_unknown_revision(git_instance):
json_out=False,
fail_on_error=False,
)
# --- obligation: required vs offered ---------------------------------------
def test_an_offered_migration_is_not_in_the_outstanding_chain(instance):
"""An offer is the stack proposing a better default for a file the instance
owns. Declining it leaves the content in a shape the machinery accepts, so
counting it as owed would make `kb_version` unreachable for an instance that
simply kept its own file."""
write_migration(
instance / "instructions" / "migrations", "1.9.0", "nicer-template",
kind="mechanical", obligation="offered",
)
migrations = kb_state.load_migrations()
pending = kb_state.chain(migrations, Version.parse("1.3.0"), Version.parse("2.0.0"))
assert "1.9.0-nicer-template" not in [m.name for m in pending]
assert [str(m.target) for m in pending] == ["1.4.0", "1.7.0", "2.0.0"]
def test_an_offered_migration_is_listed_separately(instance):
write_migration(
instance / "instructions" / "migrations", "1.9.0", "nicer-template",
kind="mechanical", obligation="offered",
)
offered = kb_state.offers(kb_state.load_migrations(), applied=set())
assert [m.name for m in offered] == ["1.9.0-nicer-template"]
def test_an_offer_stays_on_the_table_regardless_of_the_version(instance):
"""Offers are bounded by the applied ledger, not by kb_version - taking one
deliberately does not move the version, so the version can say nothing about
whether it was taken. Nor are they bounded above by the stack: an offer is
about a file the instance owns, not about the content shape."""
directory = instance / "instructions" / "migrations"
write_migration(directory, "1.1.0", "old-default", obligation="offered")
write_migration(directory, "2.1.0", "later-default", obligation="offered")
offered = kb_state.offers(kb_state.load_migrations(), applied=set())
assert [m.name for m in offered] == ["1.1.0-old-default", "2.1.0-later-default"]
def test_taking_an_offer_records_it_without_moving_the_version(instance):
write_migration(
instance / "instructions" / "migrations", "1.9.0", "nicer-template",
obligation="offered",
)
set_kb_version(instance, "1.3.1")
migrate_cmd.done_command(version="1.9.0", pages=None, dry_run=False)
state = kb_state.read_kb_state()
assert state["kb_version"] == "1.3.1"
assert state["applied"][-1]["migration"] == "1.9.0-nicer-template"
assert kb_state.offers(kb_state.load_migrations(), kb_state.applied_names(state)) == []
def test_an_offer_out_of_order_is_not_refused(instance):
"""The chain's ordering rule exists because skipping a link leaves the
corpus in an undescribed shape. An offer is not a link, so there is nothing
to skip - and refusing it would make the required chain a prerequisite for
an unrelated file upgrade."""
write_migration(
instance / "instructions" / "migrations", "1.9.0", "nicer-template",
obligation="offered",
)
set_kb_version(instance, "1.3.1") # 1.4.0 is the next *required* link
migrate_cmd.done_command(version="1.9.0", pages=None, dry_run=False)
assert kb_state.read_kb_version() == Version(1, 3, 1)
def test_obligation_defaults_to_required_when_undeclared(instance):
"""Every migration written before this axis existed is mandatory, and an
unreadable value must not silently downgrade one."""
directory = instance / "instructions" / "migrations"
path = write_migration(directory, "1.5.0", "legacy")
path.write_text(
path.read_text(encoding="utf-8").replace("obligation: required\n", ""), encoding="utf-8"
)
bogus = write_migration(directory, "1.6.0", "bogus", obligation="whatever")
assert bogus.is_file()
by_name = {m.name: m for m in kb_state.load_migrations()}
assert by_name["1.5.0-legacy"].obligation == kb_state.REQUIRED
assert by_name["1.6.0-bogus"].obligation == kb_state.REQUIRED
def test_status_never_blocks_on_an_offer(instance, capsys):
write_migration(
instance / "instructions" / "migrations", "1.9.0", "nicer-template",
obligation="offered",
)
set_kb_version(instance, "2.0.0")
migrate_cmd.status_command(json_out=True)
result = json.loads(capsys.readouterr().out)
assert result["pending"] == []
assert [m["name"] for m in result["offered"]] == ["1.9.0-nicer-template"]
# The chain is empty and the offer is listed: `status` reports both without
# the offer ever counting as owed.
# --- divergence against the release stamp ----------------------------------
def _write_stamp(root: Path, files: dict[str, str]) -> None:
import hashlib
digests = {}
for relative, content in files.items():
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
digests[relative] = "sha256:" + hashlib.sha256(content.encode("utf-8")).hexdigest()
(root / ".wikitool-release.json").write_text(
json.dumps({"schema": 1, "version": "2.0.0", "files": digests}), encoding="utf-8"
)
def test_divergent_files_tells_an_edited_file_from_a_received_one(instance):
"""The half of the release stamp that has existed since it was written and
that nothing read until offers needed it: may this file be overwritten, or
does a person have to reconcile it?"""
_write_stamp(instance, {"types/entity.md": "shipped\n", "types/concept.md": "shipped\n"})
(instance / "types" / "entity.md").write_text("locally changed\n", encoding="utf-8")
assert kb_state.divergent_files() == ["types/entity.md"]
def test_a_deleted_file_counts_as_divergent(instance):
_write_stamp(instance, {"types/entity.md": "shipped\n"})
(instance / "types" / "entity.md").unlink()
assert kb_state.divergent_files() == ["types/entity.md"]
def test_divergence_is_unanswerable_without_a_stamp(instance):
"""None, not []. A development tree carries no stamp, and reporting
"nothing diverged" there would be a fabricated answer."""
assert kb_state.divergent_files() is None
+14 -23
View File
@@ -75,29 +75,20 @@ def test_new_entity_creates_page_with_expected_frontmatter(monkeypatch, kb_dir):
assert "# gateway.example.net" in body
def test_scaffolded_body_carries_the_headings_this_instance_declared(monkeypatch, kb_dir):
"""The type-spec writes `## {section.relationships}`, not a heading text, so
an instance in another language scaffolds its own headings without editing
anything under `types/`. This is that path end to end."""
from chemenu import conventions
(kb_dir / conventions.CONVENTIONS_FILENAME).write_text(
"---\nlanguage: fr\nprofile: none\nsections:\n relationships: Relations\n"
" see_also: Voir aussi\n footnotes: Notes\n---\n\n# conventions\n",
encoding="utf-8",
)
conventions.reset_cache()
try:
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", "passerelle", "--set", "entity_type=system",
])
assert result.exit_code == 0, result.output
_fm, body = read_page(kb_dir / "entities/systems/passerelle.md")
assert "## Relations" in body
assert "## Voir aussi" in body
assert "{section." not in body
finally:
conventions.reset_cache()
def test_a_scaffolded_body_carries_no_tool_owned_region(monkeypatch, kb_dir):
"""A template must not scaffold the links or footnotes regions. They are
generated between markers from frontmatter and re-rendered on every write,
so a scaffolded copy would be a section the author may not edit and the tool
would replace anyway - and, before the markers existed, a second one it
appended beside."""
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", "passerelle", "--set", "entity_type=system",
])
assert result.exit_code == 0, result.output
_fm, body = read_page(kb_dir / "entities/systems/passerelle.md")
assert "wikitool:links" not in body
assert "wikitool:footnotes" not in body
assert "{section." not in body
def test_new_entity_applies_schema_declared_defaults(monkeypatch, kb_dir):
+22 -6
View File
@@ -41,7 +41,11 @@ def empty_kb(tmp_path, monkeypatch):
for collection in COLLECTIONS:
(kb / collection).mkdir(parents=True)
(kb / collection / "COLLECTION.md").write_text(
f"# kb/{collection}/ - Collection Contract\n", encoding="utf-8"
f"---\nprofile: {collection}\n"
f"required_by_stack: {'true' if collection == 'sources' else 'false'}\n"
"outbound:\n any: [implements, uses, see-also]\n---\n\n"
f"# kb/{collection}/ - Collection Contract\n",
encoding="utf-8",
)
raw = tmp_path / "raw"
raw.mkdir()
@@ -76,7 +80,8 @@ def build_wiki(kb):
"--set", "summary=A concept created by the pipeline test"])
for page in kb.rglob("Pipeline *.md"):
finish_page(page)
invoke(["xref", "add", "--a", "Pipeline Host", "--b", "Pipeline Concept"])
invoke(["xref", "add", "--a", "Pipeline Host", "--b", "Pipeline Concept",
"--rel", "implements"])
invoke(["index", "rebuild"])
@@ -101,12 +106,23 @@ def test_a_wiki_built_by_the_tools_lints_clean(empty_kb):
assert not has_hard_errors(report), f"hard errors after a clean build: {found}"
def test_the_pages_reach_each_other(empty_kb):
"""`xref add` is what makes two pages findable from one another; if it and
the link checker disagreed, the lint above would report a broken link."""
def test_an_edge_points_one_way_and_the_far_end_stops_being_an_orphan(empty_kb):
"""`xref add` declares one direction, and that is what the orphan check now
measures: reachability.
It used to assert that *neither* page was an orphan, which only held because
`xref add` wrote a mirror edge on the target. With authored directional
edges the source of the only edge in a two-page wiki genuinely has nothing
pointing at it - so the check reporting it is the check working, not a
regression. A real corpus answers this by having entry points that other
pages point at."""
build_wiki(empty_kb)
assert run_lint(empty_kb)["orphan_pages"] == []
report = run_lint(empty_kb)
assert "Pipeline Concept" not in report["orphan_pages"]
assert report["orphan_pages"] == ["Pipeline Host"]
assert report["broken_links"] == []
assert report["dangling_frontmatter_refs"] == []
def test_the_catalog_covers_what_was_created(empty_kb):
+7 -6
View File
@@ -1,6 +1,5 @@
from typer.testing import CliRunner
from chemenu import conventions
from chemenu.cli import app
runner = CliRunner()
@@ -44,11 +43,13 @@ def test_types_describe_entity_reports_schema_and_body():
"project", "system", "tool", "technology", "person",
]
assert fields_by_name["tags"]["required"] is False
# The body must carry the page skeleton an authoring LLM works from. Anchored on the
# template *variable* rather than on any heading text: the spec no longer names the
# tool-owned sections at all - `kb/CONVENTIONS.md` does, and `new` substitutes it - so a
# literal here would assert the very coupling that was removed.
assert f"## {{section.{conventions.RELATIONSHIPS}}}" in data["body"]
# The body must carry the page skeleton an authoring LLM works from...
assert "## Kerndaten" in data["body"]
# ...and must *not* carry a tool-owned region. Those are generated between
# markers from frontmatter, so scaffolding one would create a section the
# author may not edit and the next write would replace anyway.
assert "wikitool:links" not in data["body"]
assert "wikitool:footnotes" not in data["body"]
def test_types_describe_unknown_name_fails_cleanly():
+195 -74
View File
@@ -1,25 +1,56 @@
from chemenu import blocks, links
from chemenu.frontmatter_io import read_page
from chemenu.commands.xref import (
add_related,
add_relationship_bullet,
add_see_also_bullet,
apply_links_block,
remove_link_bullets,
remove_related,
render_links_block,
)
from chemenu.kb_scan import load_kb_pages
from chemenu.page import Page
from pathlib import Path
def test_add_related_is_deduplicated():
fm = {"related": ["A"]}
assert add_related(fm, "B") is True
assert add_related(fm, "B") is False
assert fm["related"] == ["A", "B"]
def _page(related, body="\n# X\n\nProse.\n"):
return Page(Path("kb/entities/X.md"), {"related": related}, body)
def test_remove_related_is_the_inverse_of_add():
fm = {"related": ["A", "B"]}
def test_an_edge_carries_its_label_in_the_data():
fm = {}
assert links.upsert(fm, "related", links.Edge("B", "depends-on")) is True
assert links.upsert(fm, "related", links.Edge("B", "depends-on")) is False
assert fm["related"] == [{"depends-on": "B"}]
def test_relabelling_replaces_rather_than_appends():
"""One page asserts one thing about another. Two edges to the same target
would render two bullets with no way to say which is meant."""
fm = {"related": [{"uses": "B"}]}
assert links.upsert(fm, "related", links.Edge("B", "depends-on")) is True
assert fm["related"] == [{"depends-on": "B"}]
def test_a_bare_title_reads_as_an_unlabelled_edge():
"""The shape every page is in between this machinery landing and the
migration reaching it. Readers must not crash on it, and it must stay
visibly unlabelled so `lint` can report it."""
fm = {"related": ["B", {"uses": "C"}]}
edges = links.edges(fm, "related")
assert [(e.target, e.label) for e in edges] == [("B", None), ("C", "uses")]
assert links.targets(fm, "related") == ["B", "C"]
assert [e.is_labelled for e in edges] == [False, True]
def test_a_malformed_entry_is_reported_rather_than_guessed_at():
fm = {"related": [{"a": "X", "b": "Y"}, 42, "Fine"]}
assert links.targets(fm, "related") == ["Fine"]
assert len(links.malformed(fm, "related")) == 2
def test_remove_related_is_the_inverse_of_upsert():
fm = {"related": [{"uses": "A"}, {"uses": "B"}]}
assert remove_related(fm, "B") is True
assert fm["related"] == ["A"]
assert fm["related"] == [{"uses": "A"}]
assert remove_related(fm, "B") is False
@@ -27,44 +58,58 @@ def test_remove_related_tolerates_a_missing_field():
assert remove_related({}, "B") is False
def test_remove_link_bullets_removes_what_add_wrote():
body = "\n# X\n\n## Relationships\n\n- **uses:** [[B]]\n\n## See Also\n\n- [[B]]\n"
def test_retarget_keeps_the_label():
fm = {"related": [{"depends-on": "Old"}]}
assert links.retarget(fm, "related", "Old", "New") is True
assert fm["related"] == [{"depends-on": "New"}]
def test_the_body_block_is_rendered_from_the_frontmatter():
"""The body is a rendering of the graph, not a second place it is stored.
That is what removed the need to parse a German bullet back into a
relationship."""
page = _page([{"depends-on": "Hermes"}, "Unlabelled"])
body = apply_links_block(page)
region = blocks.find(body, blocks.LINKS)
assert "- **depends-on:** [[Hermes]]" in region
assert "- [[Unlabelled]]" in region
def test_rendering_is_idempotent_and_replaces_rather_than_appends():
page = _page([{"uses": "A"}])
once = apply_links_block(page)
twice = apply_links_block(page, once)
assert once == twice
page.frontmatter["related"] = [{"uses": "B"}]
thrice = apply_links_block(page, once)
assert thrice.count("<!-- wikitool:links -->") == 1
assert "[[A]]" not in thrice and "[[B]]" in thrice
def test_a_page_with_no_edges_carries_no_region():
page = _page([])
body = apply_links_block(page)
assert "wikitool:links" not in body
assert render_links_block(page) == ""
def test_prose_after_the_region_survives_a_rewrite():
"""The failure the marker pair exists to make impossible. The old block ran
to the next heading - and before that to the end of the file - so a section
sitting after it was deleted on the next write. Eight pages were carrying
content in that position when it was found."""
page = _page([{"uses": "A"}])
body = apply_links_block(page) + "\n## Afterwards\n\nKeep me.\n"
page.frontmatter["related"] = [{"uses": "B"}]
rewritten = apply_links_block(page, body)
assert "Keep me." in rewritten
assert rewritten.count("## Afterwards") == 1
def test_remove_link_bullets_removes_what_the_renderer_wrote():
body = "\n# X\n\n## Beziehungen\n\n- **uses:** [[B]]\n- [[B]]\n"
result = remove_link_bullets(body, "B")
assert "[[B]]" not in result
assert "## Relationships" in result and "## See Also" in result
def test_relationship_bullet_idempotent():
body = "\n# X\n\n## Relationships\n\n- **Related to:** [[A]]\n\n## See Also\n\n- [[A]]\n"
once = add_relationship_bullet(body, "hosts", "B")
twice = add_relationship_bullet(once, "hosts", "B")
assert once == twice
assert "[[B]]" in once
def test_see_also_bullet_creates_section_if_missing():
body = "\n# X\n\n## Description\n\nSomething.\n"
updated = add_see_also_bullet(body, "Y")
assert "## Siehe auch" in updated
assert "[[Y]]" in updated
def test_see_also_bullet_appends_to_an_untranslated_section():
"""A page still carrying the English heading is appended to, not given a
second section - that is what lets the corpus migrate page by page."""
body = "\n# X\n\n## Description\n\nSomething.\n\n## See Also\n\n- [[A]]\n"
updated = add_see_also_bullet(body, "Y")
assert updated.count("## See Also") == 1
assert "## Siehe auch" not in updated
assert "[[Y]]" in updated
def test_relationship_bullet_appends_to_an_untranslated_section():
body = "\n# X\n\n## Relationships\n\n- **Related to:** [[A]]\n"
updated = add_relationship_bullet(body, "hosts", "B")
assert updated.count("## Relationships") == 1
assert "## Beziehungen" not in updated
assert "- **hosts:** [[B]]" in updated
def test_xref_add_updates_both_pages_on_disk(kb_dir):
@@ -76,23 +121,25 @@ def test_xref_add_updates_both_pages_on_disk(kb_dir):
config.INDEX_FILE = kb_dir / "index.md"
runner = CliRunner()
result = runner.invoke(app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel-a", "uses", "--rel-b", "used by"])
modbus_before = (kb_dir / "concepts/Modbus.md").read_text(encoding="utf-8")
result = runner.invoke(app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel", "uses"])
assert result.exit_code == 0, result.output
pages = load_kb_pages(kb_dir)
assert "Modbus" in pages["gdeploy"].frontmatter["related"]
assert "gdeploy" in pages["Modbus"].frontmatter["related"]
assert "[[Modbus]]" in pages["gdeploy"].body
assert "[[gdeploy]]" in pages["Modbus"].body
assert links.targets(pages["gdeploy"].frontmatter, "related") == ["Modbus"]
assert "- **uses:** [[Modbus]]" in pages["gdeploy"].body
fm_before, body_before = read_page(kb_dir / "entities/tools/gdeploy.md")
link_count_before = body_before.count("[[Modbus]]") # one in Relationships, one in See Also
# B is not touched at all. Its inbound view is rendered from the graph, so
# nothing has to be written there for a reader to find its way back.
assert (kb_dir / "concepts/Modbus.md").read_text(encoding="utf-8") == modbus_before
# Re-running must not duplicate the relationship or See Also bullets.
result2 = runner.invoke(app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel-a", "uses", "--rel-b", "used by"])
_fm_before, body_before = read_page(kb_dir / "entities/tools/gdeploy.md")
link_count_before = body_before.count("[[Modbus]]")
result2 = runner.invoke(app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel", "uses"])
assert result2.exit_code == 0
fm_after, body_after = read_page(kb_dir / "entities/tools/gdeploy.md")
assert fm_after["related"].count("Modbus") == 1
assert links.targets(fm_after, "related").count("Modbus") == 1
assert body_after.count("[[Modbus]]") == link_count_before
@@ -107,14 +154,14 @@ def test_xref_remove_undoes_xref_add(kb_dir):
runner = CliRunner()
before = (kb_dir / "entities/tools/gdeploy.md").read_text(encoding="utf-8")
added = runner.invoke(app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus"])
added = runner.invoke(app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel", "uses"])
assert added.exit_code == 0, added.output
removed = runner.invoke(app, ["xref", "remove", "--a", "gdeploy", "--b", "Modbus"])
assert removed.exit_code == 0, removed.output
pages = load_kb_pages(kb_dir)
assert "Modbus" not in pages["gdeploy"].frontmatter["related"]
assert "gdeploy" not in pages["Modbus"].frontmatter["related"]
assert "Modbus" not in links.targets(pages["gdeploy"].frontmatter, "related")
assert "gdeploy" not in links.targets(pages["Modbus"].frontmatter, "related")
assert "[[Modbus]]" not in pages["gdeploy"].body
assert before # sanity: fixture page was non-empty
@@ -198,10 +245,10 @@ def test_xref_add_dry_run_writes_nothing(kb_dir):
runner = CliRunner()
result = runner.invoke(
app,
["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel-a", "uses", "--rel-b", "used by", "--dry-run"],
["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel", "uses", "--dry-run"],
)
assert result.exit_code == 0, result.output
assert "would update" in result.output
assert "would declare" in result.output
assert "No files written" in result.output
assert gdeploy_path.read_text(encoding="utf-8") == gdeploy_before
@@ -230,9 +277,11 @@ def test_xref_link_source_dry_run_writes_nothing(kb_dir):
assert gdeploy_path.read_text(encoding="utf-8") == gdeploy_before
def test_xref_add_reports_a_write_failure_without_silently_leaving_a_one_way_link(kb_dir, monkeypatch):
"""If writing B fails after A already succeeded, the command must fail
loudly (not silently succeed with a one-directional link) and say so."""
def test_xref_add_reports_a_write_failure(kb_dir, monkeypatch):
"""One edge, one write - so there is no half-written pair to report any
more. The old two-sided `xref add` could update A and fail on B, leaving a
link the user had to be told was one-directional; a directional edge has
nothing to be half of."""
from typer.testing import CliRunner
from chemenu.cli import app
from chemenu.commands import xref as xref_module
@@ -241,25 +290,40 @@ def test_xref_add_reports_a_write_failure_without_silently_leaving_a_one_way_lin
config.KB_DIR = kb_dir
config.INDEX_FILE = kb_dir / "index.md"
real_write_page = xref_module.write_page
def flaky_write_page(path, frontmatter, body):
if path.name == "Modbus.md":
raise OSError("disk full")
return real_write_page(path, frontmatter, body)
raise OSError("disk full")
monkeypatch.setattr(xref_module, "write_page", flaky_write_page)
runner = CliRunner()
result = runner.invoke(
app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel-a", "uses", "--rel-b", "used by"]
app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel", "uses"]
)
assert result.exit_code == 1
assert "disk full" in result.output
assert "one-directional" in result.output
pages = load_kb_pages(kb_dir)
assert "Modbus" in pages["gdeploy"].frontmatter["related"] # A's write already happened
assert "Modbus" not in links.targets(pages["gdeploy"].frontmatter, "related")
def test_xref_add_refuses_a_label_the_collection_does_not_authorise(kb_dir):
"""The source collection decides which labels may be used from it. Refused
here rather than only in `lint`, because this is the moment the author is
present and can pick a better one."""
from typer.testing import CliRunner
from chemenu.cli import app
import chemenu.config as config
config.KB_DIR = kb_dir
config.INDEX_FILE = kb_dir / "index.md"
runner = CliRunner()
result = runner.invoke(
app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel", "hängt ab von"]
)
assert result.exit_code == 1
assert "not authorised" in result.output
assert "link-taxonomy" in result.output
def test_xref_link_source_distinguishes_write_failures_from_missing_pages(kb_dir, monkeypatch):
@@ -334,7 +398,9 @@ def test_xref_add_refuses_a_type_without_a_related_field(kb_dir):
`related:` there produced frontmatter the schema rejects, and `xref remove`
could not clear it - one command creating a state another could not undo."""
runner, app = _runner_env(kb_dir)
result = runner.invoke(app, ["xref", "add", "--a", "Source - Aurora", "--b", "aurora"])
result = runner.invoke(
app, ["xref", "add", "--a", "Source - Aurora", "--b", "aurora", "--rel", "uses"]
)
assert result.exit_code == 1
# Rich wraps the message to the terminal width, so compare on collapsed
# whitespace rather than pinning the line breaks.
@@ -414,3 +480,58 @@ def test_xref_link_source_dry_run_leaves_the_source_page_alone(kb_dir):
"--dry-run"],
)
assert path.read_text(encoding="utf-8") == before
# --- the inbound view ------------------------------------------------------
def test_the_inbound_view_is_derived_not_stored(kb_dir):
"""The load-bearing half of dropping mirrored edges. Nothing writes an edge
onto the target, so the only way "what points at this page" can be answered
completely is by computing it - which is also why it cannot go stale or be
half-written the way a mirror could."""
from typer.testing import CliRunner
from chemenu.cli import app
import chemenu.config as config
config.KB_DIR = kb_dir
config.INDEX_FILE = kb_dir / "index.md"
runner = CliRunner()
assert runner.invoke(
app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel", "uses"]
).exit_code == 0
import json
result = runner.invoke(app, ["links", "show", "--page", "Modbus", "--json"])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["inbound"] == [{"source": "gdeploy", "label": "uses", "collection": "entities"}]
# Nothing was written onto Modbus itself to make that answer possible.
assert "gdeploy" not in links.targets(
load_kb_pages(kb_dir)["Modbus"].frontmatter, "related"
)
def test_an_unresolvable_outbound_edge_is_marked(kb_dir):
"""`lint` reports dangling references corpus-wide; this reports it for the
one page someone is looking at, which is where it gets fixed."""
from typer.testing import CliRunner
from chemenu.cli import app
import chemenu.config as config
import json
config.KB_DIR = kb_dir
config.INDEX_FILE = kb_dir / "index.md"
page = load_kb_pages(kb_dir)["gdeploy"]
links.upsert(page.frontmatter, "related", links.Edge("Gone", "uses"))
from chemenu.frontmatter_io import write_page
write_page(page.path, page.frontmatter, page.body)
result = CliRunner().invoke(app, ["links", "show", "--page", "gdeploy", "--json"])
assert json.loads(result.output)["outbound"] == [
{"target": "Gone", "label": "uses", "resolves": False}
]