feat: Prosa ist kein Identifier - Link-Taxonomie als Enum, generierte Regionen mit Markern (4.0.0)
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:
+13
-11
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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}, "
|
||||
|
||||
@@ -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} ->")
|
||||
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 - "
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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]]]:
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user