Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 177c7e9ce8 | |||
| 502971d147 | |||
| 9843df99d3 |
@@ -221,6 +221,16 @@ jobs:
|
||||
for personal in USER SOUL; do
|
||||
grep -v 'wikitool:template-unfilled' "$personal.md.template" > "$personal.md"
|
||||
done
|
||||
# The authoring conventions ride the same split one directory down,
|
||||
# and are stubbed the same way: what is under test is that the export
|
||||
# carries the templates and that `doctor`/`docs verify` accept an
|
||||
# adopted one, not what a person would write into them. The collection
|
||||
# contracts are adopted verbatim - the shipped text is a working
|
||||
# default, unlike a personalization file.
|
||||
grep -v 'wikitool:template-unfilled' kb/CONVENTIONS.md.template > kb/CONVENTIONS.md
|
||||
for template in kb/*/COLLECTION.md.template types/*.template; do
|
||||
cp "$template" "${template%.template}"
|
||||
done
|
||||
python3 -m venv tools/.venv
|
||||
tools/.venv/bin/pip install --quiet -r tools/requirements.txt
|
||||
tools/wikitool instructions sync
|
||||
|
||||
+8
-2
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"kb_version": "1.0.0",
|
||||
"applied": []
|
||||
"kb_version": "3.0.0",
|
||||
"applied": [
|
||||
{
|
||||
"migration": "3.0.0-authoring-conventions",
|
||||
"at": "2026-09-02",
|
||||
"pages": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -77,10 +77,11 @@ What a file is called says who it is for and how it is loaded. This is a rule, n
|
||||
| `SOUL.md` | Agents | Always, every session |
|
||||
| `ENVIRONMENT.md` | Agents | Every session, **if it exists** - the one optional file in this table. Not committed: it describes one checkout, not the repo |
|
||||
| `<stage>/CONTRACT.md` | Agents | When writing in that stage |
|
||||
| `kb/<collection>/COLLECTION.md` | Agents | When writing in that collection |
|
||||
| `kb/CONVENTIONS.md` | Agents | When writing any page - it holds what *this* instance decided about authoring (language, section headings, naming, tone, relationship labels, confidence rubric), where `kb/CONTRACT.md` holds what the stack enforces. Instance-owned: a distribution ships only the `.template` |
|
||||
| `kb/<collection>/COLLECTION.md` | Agents | When writing in that collection. Instance-owned in the same way, and declares in frontmatter which profile it adopted |
|
||||
| `instructions/<name>.md` | Agents | By link, or on explicit request |
|
||||
| `instructions/<name>/SKILL.md` | Agents | By the harness, once published |
|
||||
| `types/<name>.md` | Agents + validator | Via `tools/wikitool types describe` |
|
||||
| `types/<name>.md` | Agents + validator | Via `tools/wikitool types describe`. Split by `root:`: a page type-spec (`root: kb`) belongs to the instance and ships as `.template`; one describing a stack artifact ships verbatim |
|
||||
| `INDEX.md` | Both | Generated - never hand-edited |
|
||||
|
||||
A stage may carry both a `README.md` and a `CONTRACT.md`: different readers, different
|
||||
@@ -104,6 +105,16 @@ and `SOUL.md.template`; the Personalization step of
|
||||
writes the real files. `tools/wikitool doctor` FAILs on a missing one, and on one still
|
||||
carrying the template's sentinel.
|
||||
|
||||
The same `.template` split runs one directory down, for authoring rather than for voice.
|
||||
`kb/CONVENTIONS.md` and each `kb/<name>/COLLECTION.md` bind every page and belong to the
|
||||
instance, so a distribution ships them as templates and the KB-language step of
|
||||
[instructions/setup-instance.md](instructions/setup-instance.md) fills them in, out of a
|
||||
catalogue of ready-made profiles it routes to; `doctor` FAILs on a missing or unfilled
|
||||
`kb/CONVENTIONS.md` the same way.
|
||||
|
||||
Unlike `USER.md`, these two *are* a source of rules: they are as binding as `kb/CONTRACT.md`.
|
||||
What differs is ownership, not authority.
|
||||
|
||||
## Environment
|
||||
|
||||
`ENVIRONMENT.md` records what *this checkout* works through - harness, published skills,
|
||||
@@ -142,15 +153,17 @@ Alongside it, not part of it: `instructions/` (what agents are told to do) and t
|
||||
|-------|----------|--------|
|
||||
| `raw/` | [raw/CONTRACT.md](raw/CONTRACT.md) | Immutability, directory routing, untrusted-content rule |
|
||||
| `types/` | [types/type-spec.md](types/type-spec.md) | Type-spec anatomy, placement, adding a type, template variables |
|
||||
| `kb/` | [kb/CONTRACT.md](kb/CONTRACT.md) | Collections, naming, tone, linking, provenance, confidence |
|
||||
| `kb/` | [kb/CONTRACT.md](kb/CONTRACT.md) + `kb/CONVENTIONS.md` | What the stack enforces about a page (collections, linking, provenance, confidence machinery), and beside it what this instance decided (language, naming, tone, labels, rubric) |
|
||||
| `reports/` | [reports/CONTRACT.md](reports/CONTRACT.md) | Why reports and traces are generated, gitignored, and carried into `kb/log.md` |
|
||||
| `work/` | [work/CONTRACT.md](work/CONTRACT.md) | Workshop runs: run keys, required files, why they are tracked, how a run closes |
|
||||
| `tools/` | [tools/CONTRACT.md](tools/CONTRACT.md) | Full command reference, per-command error contracts, maintenance schedule |
|
||||
| `instructions/` | [instructions/CONTRACT.md](instructions/CONTRACT.md) | Instruction vs. skill, publishing, writing standard |
|
||||
|
||||
**By collection** - then read the contract for the collection you are writing in.
|
||||
[kb/CONTRACT.md](kb/CONTRACT.md) routes between `kb/entities/`, `kb/concepts/`, `kb/sources/`
|
||||
and `kb/comparisons/`, and holds the rules they share.
|
||||
[kb/CONTRACT.md](kb/CONTRACT.md) routes between this instance's collections and holds the rules
|
||||
the stack enforces across all of them; `kb/CONVENTIONS.md` holds the ones this instance chose.
|
||||
Both bind. The difference is who may change the sentence - which is also why a distribution
|
||||
ships the first verbatim and the second only as a `.template`.
|
||||
|
||||
**By task** - skills hold the step-by-step procedures. Sources live in `instructions/<name>/`:
|
||||
|
||||
|
||||
+152
@@ -20,6 +20,158 @@ their date-only headings.
|
||||
|
||||
---
|
||||
|
||||
## 4.0.0 - 2026-09-02 - Prosa ist kein Identifier: Link-Taxonomie als Enum, generierte Regionen mit Markern
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
**Breaking Change:** Beziehungslabel sind Enum-Werte in related: statt Freitext im Body-Bullet, toolgefuehrte Abschnitte liegen zwischen Marker-Paaren statt hinter ihrer Ueberschrift, und xref add schreibt nur noch eine Kante statt beider Richtungen. tools/chemenu/sections.py ist geloescht. Eine bestehende Instanz muss sections: in kb/CONVENTIONS.md auf links/footnotes umstellen, outbound: in jede COLLECTION.md eintragen, die {section.*}-Variablen aus ihren Page-Type-Templates entfernen und den Korpus umstellen - sonst scaffoldet new die Variablen woertlich in neue Seiten. Ablauf: instructions/migrations/4.0.0-link-taxonomy.md
|
||||
|
||||
Der Stack benutzte an drei Stellen **Prosa als Identifier**, und jede hat messbar etwas
|
||||
gekostet. Die Überschrift eines Abschnitts war seine Adresse (`^## Beziehungen$`), was die
|
||||
KB-Sprache zu einer Compiler-Konstante machte *und* das Ende der Region zur Schätzung - sie lief
|
||||
bis zur nächsten Überschrift, davor bis zum Dateiende, und hat auf acht Seiten still Inhalt
|
||||
gelöscht. Das Beziehungslabel stand nur im Body-Bullet, also konnte nichts das Vokabular prüfen:
|
||||
gemessen am Korpus **152 distinkte Label in 337 Bullets** gegen dreizehn dokumentierte, 102 davon
|
||||
genau einmal vorkommend. Und `xref add` spiegelte jede Kante, was `## Siehe auch` mit 555
|
||||
Bullets ohne Label füllte - 353 davon beweisbar redundant.
|
||||
|
||||
**Was jetzt Identifier ist.** Eine Region liegt zwischen `<!-- wikitool:links -->` bzw.
|
||||
`<!-- wikitool:footnotes -->` und wird vollständig aus dem Frontmatter gerendert, Überschrift
|
||||
eingeschlossen. Ein Label ist ein Maschinenwert in `related:` (`- depends-on: Hermes`), gezogen
|
||||
aus `instructions/link-taxonomy.md` und **pro Ziel autorisiert von der Quell-Collection**
|
||||
(`outbound:` im `COLLECTION.md`, Commonplaces ADR-019). Der Body-Bullet ist eine Darstellung
|
||||
dieser Daten, nicht ihr zweiter Aufbewahrungsort.
|
||||
|
||||
**Gelöscht, ersatzlos:** `tools/chemenu/sections.py` komplett, `heading_re`, der
|
||||
Alias-Mechanismus, `PRE_CONVENTIONS_NAMES`, `cite_block_heading`, `provenance.__getattr__`, die
|
||||
`{section.*}`-Template-Variablen, `xref`s Abschnittssuche. Kein Überschriftentext liegt mehr in
|
||||
Python - bis auf zwei kosmetische Fallbacks, und die sind harmlos geworden: der Marker trägt die
|
||||
Identität, also rendert ein falscher Default falsche Wörter statt Struktur zu zerlegen, und der
|
||||
nächste Write repariert es.
|
||||
|
||||
**Kanten sind direktional, und das war keine Geschmacksfrage.** Die per-Collection-Autorisierung
|
||||
ist mit einer automatisch gespiegelten Gegenkante logisch unverträglich: die Spiegelhälfte
|
||||
entsteht in einer Collection, deren Regeln der Autor nie gelesen hat. Entweder schriebe das
|
||||
Werkzeug unautorisierte Kanten, oder die Regel "die Quellcollection entscheidet" löst sich auf.
|
||||
Der Navigationseinwand wird dabei *besser* beantwortet als vorher: `wikitool links show --page`
|
||||
berechnet die Eingangssicht über den Korpus, vollständig und ohne Pflege, und das gerenderte
|
||||
Bullet ist ein gewöhnlicher `[[wikilink]]` - ein Backlink-Panel zeigt es ohnehin. Die erzwungene
|
||||
Gegenkante garantierte nie Vollständigkeit, nur dass jemand daran gedacht hat.
|
||||
|
||||
**Der Orphan-Check meldet dadurch mehr,** und das ist die Prüfung bei der Arbeit: sie misst jetzt
|
||||
Erreichbarkeit statt "ist `xref` gelaufen".
|
||||
|
||||
**`obligation:` trennt zwei Achsen, die vorher eine waren.** `migration_kind:` sagt *wie*
|
||||
gearbeitet wird, neu `obligation: required|offered` *ob* überhaupt. Eine `offered`-Migration ist
|
||||
ein Angebot für eine Datei, die der Instanz gehört - sie blockiert nie, steht nicht in der Kette,
|
||||
und `migrate done` verbucht sie im Ledger, **ohne** `kb_version` zu bewegen. Genau daran hing ein
|
||||
Entwurfsfehler, den erst der Test gezeigt hat: Offers gegen `kb_version` zu filtern hätte jede
|
||||
Offer verschwinden lassen, sobald irgendein unbeteiligter Pflichtschritt lief. Dazu ist die
|
||||
Erkennungshälfte aktiviert, die seit ihrer Einführung ungelesen dalag - die sha256 pro Datei in
|
||||
`.wikitool-release.json` beantwortet jetzt "editiert oder nur empfangen", also ob eine Offer
|
||||
kopiert werden darf oder von Hand abgeglichen werden muss.
|
||||
|
||||
**`types/` teilt sich entlang `root:`.** `root: kb` heißt Wissensseite heißt Instanz: die vier
|
||||
Page-Type-Specs samt Schemas gehen als `.template`, `instruction`/`lint-report`/`type-spec`
|
||||
verbatim. Damit ist die deutsche Prosa in jenen vier Dateien **korrekt statt Migrationsschuld** -
|
||||
es war die richtige Sprache an einem Ort mit falsch deklariertem Eigentümer. Was der Stack von
|
||||
der Type-Schicht noch verlangt, ist eine Zeile: ein Type-Spec `name: source`, dessen Schema
|
||||
`raw_files` fordert. `STACK_REQUIRED_COLLECTIONS` entfällt als separate Liste - die pflichtige
|
||||
Collection wird aus dem `base_dir` dieses Typs abgeleitet.
|
||||
|
||||
**Warum das MAJOR ist.** Vorwärts: `sections:` hat eine andere Form, `outbound:` fehlt, und die
|
||||
in 3.0.0 übernommenen Page-Type-Templates enthalten `{section.*}`-Variablen, die es nicht mehr
|
||||
gibt - `new` schriebe sie wörtlich in neue Seiten. Rückwärts: 4.0.0 schreibt gelabelte Kanten,
|
||||
die 3.0.0s Schema als `type: string` ablehnt. Beide Hälften des Drop-in-Tests fallen.
|
||||
|
||||
**Der Korpus dieser Instanz ist noch nicht umgestellt.** Diese Version liefert die Maschinerie;
|
||||
`lint` meldet die 480 noch ungelabelten Kanten als Findings, nicht als Fehler, weil das genau das
|
||||
Fenster ist, für das `.wikitool-kb.json` existiert. `malformed_edges` und `unbalanced_markers`
|
||||
sind dagegen sofort hart - keines beschreibt eine unkonvertierte Seite, nur eine kaputte. Die
|
||||
Beförderung der beiden anderen kommt, wenn der Korpus sie bestehen kann.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 3.0.0 - 2026-09-02 - Autorenkonventionen nach Eigentum geschnitten: kb/CONVENTIONS.md, deklarierte Collections
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
**Breaking Change:** kb/CONTRACT.md ist um alles gekuerzt, was eine Instanz selbst entscheidet; das steht jetzt in einer neuen, instanzeigenen kb/CONVENTIONS.md, aus der der Compiler die drei toolgefuehrten Abschnittsnamen liest. Eine bestehende Instanz muss diese Datei anlegen, auf jedem kb/*/COLLECTION.md profile: und required_by_stack: deklarieren und kb/CONTRACT.md aus dem Release nachziehen - sonst FAILt doctor und docs verify bricht. Ablauf: instructions/migrations/3.0.0-authoring-conventions.md
|
||||
|
||||
`kb/CONTRACT.md` war eine Datei mit zwei Autoritäten. Der eine Teil ist code-erzwungen und in
|
||||
jeder Instanz gleich; der andere - **§ Language komplett**, das Beziehungslabel-Vokabular, die
|
||||
Tonfall-Beispiele samt deutscher Buzzword-Liste, die Confidence-Rubrik, das ADR-Präfix - ist
|
||||
Konvention, die jede Instanz für sich entscheidet, und wurde trotzdem als bindender Contract
|
||||
verbatim ausgeliefert. Wer bei Schritt 5 von `setup-instance.md` "Englisch" antwortete, hatte
|
||||
danach `kb/CONTRACT.md`, vier Type-Specs **und `tools/chemenu/sections.py`** lokal geändert -
|
||||
und `private-instance.md`s Decision Point sagt für so einen Merge-Konflikt: Upstream-Seite
|
||||
nehmen. Für diese Instanz hieß das: KB-Sprache zurück auf Deutsch.
|
||||
|
||||
**Der Schnitt läuft jetzt danach, wer den Satz ändern darf.** `kb/CONTRACT.md` behält, was
|
||||
`wikitool` erzwingt; neu daneben liegt `kb/CONVENTIONS.md`, die **genauso bindet** und der
|
||||
Instanz gehört. Unterschied ist Eigentum, nicht Autorität - deshalb liefert die Distribution nur
|
||||
`kb/CONVENTIONS.md.template`, exakt der `USER.md`/`SOUL.md`-Split ein Verzeichnis tiefer. Dazu
|
||||
`instructions/kb-profiles.md`: der Katalog erprobter Profile, ausdrücklich **Palette und kein
|
||||
Enum**. Übernommen wird der *Text* in die Instanzdatei, nie ein Verweis auf den Katalog - ein
|
||||
Verweis wäre wieder genau die Konstruktion, die dieser Release beendet.
|
||||
|
||||
**`sections.py` hält keine Überschrift mehr.** `RELATIONSHIPS = "Beziehungen"` war die Stelle,
|
||||
an der die Konvention in Code übergelaufen war: solange sie dort stand, konnte kein Template die
|
||||
Sprache umstellen. Neu ist `tools/chemenu/conventions.py`, das die drei Namen aus
|
||||
`kb/CONVENTIONS.md` liest; `sections.py` löst sie per PEP 562 bei jedem Zugriff auf, wie
|
||||
`config` seine Pfade - ein Modulkonstante hätte den Wert an den Baum gebunden, in dem der Prozess
|
||||
gestartet ist. Aus demselben Grund ist `provenance.CITE_BLOCK_HEADING` ein `__getattr__` und
|
||||
`render_cite_block(heading=None)` löst innerhalb des Aufrufs auf. Der Alias-Mechanismus, den das
|
||||
Modul schon hatte, **ist** der Migrationspfad: erkannt wird die kanonische Form plus die
|
||||
deklarierten `section_aliases:` plus das, was dieser Stack vor der Konventionsdatei geschrieben
|
||||
hat. Ohne Datei antwortet dieser Fallback - richtig für jeden Korpus, der ihn erreichen kann,
|
||||
denn der wurde unter genau diesen Namen geschrieben; `doctor` ist die laute Hälfte davon.
|
||||
|
||||
**Die vier Page-Type-Specs schreiben `## {section.relationships}`** statt einer Überschrift.
|
||||
Neue Template-Variablen `{section.relationships}` / `{section.see_also}` / `{section.footnotes}`,
|
||||
gefüllt aus der Instanzdeklaration. Damit ändert eine anderssprachige Instanz **keine Datei unter
|
||||
`tools/` oder `types/`** mehr - was Schritt 5 von `setup-instance.md` von fünf Editierstellen
|
||||
über drei Schichten auf eine Entscheidung reduziert.
|
||||
|
||||
**`COLLECTION.md` bekommt Frontmatter.** Bisher wurde eine Collection rein an der Dateipräsenz
|
||||
erkannt; die Deklaration brauchte einen Träger, sonst wäre der Ortsschnitt nur durch einen
|
||||
Prosaschnitt ersetzt worden. `profile:` nennt den übernommenen Katalogeintrag (Freitext - eine
|
||||
selbst angelegte Collection hat dort keinen), `required_by_stack:` sagt, ob `wikitool` die
|
||||
Collection *namentlich* auflöst. Das zweite ist **nicht** die Wahl der Instanz: `docs verify`
|
||||
prüft es beidseitig gegen `kb_collections.STACK_REQUIRED_COLLECTIONS`. Heute steht dort genau
|
||||
`sources` - `sources coverage`, die `[^cite-id]`-Auflösung und `kb/provenance.md` hängen an dem
|
||||
Namen, `entities` an keinem.
|
||||
|
||||
**Das zweite Leck der Merge-Prozedur ist zu.** `git checkout HEAD -- kb raw` holte *alles* unter
|
||||
beiden Stages auf den Vor-Merge-Stand - auch `kb/CONTRACT.md` und `raw/CONTRACT.md`. Änderte der
|
||||
Upstream einen davon, warf die Prozedur das Update still weg, und die Kontrollzeile meldete dabei
|
||||
*leer*, bestätigte den Fehler also, statt ihn zu fangen. `private-instance.md` nimmt die
|
||||
Upstream-Seite jetzt für die drei Maschinerie-Pfade unter den Content-Stages zurück
|
||||
(`kb/CONTRACT.md`, `kb/CONVENTIONS.md.template`, `raw/CONTRACT.md`) und schließt sie aus der
|
||||
Kontrollzeile aus. Dieselbe Altlast in der Tarball-Richtung: `INSTALL.md` Schritt 3 fasste `kb/`
|
||||
gar nicht an und zog `kb/CONTRACT.md` damit nie nach - jetzt ausdrücklich benannt.
|
||||
|
||||
**Verworfen, gemessen: `sources/` aus `kb/` herausziehen.** Der Graph ist einwurzelig
|
||||
(`kb_scan.iter_kb_pages` macht ein `rglob` über `kb/`, darauf sitzen Link-Graph, Orphan-Check,
|
||||
`index rebuild` und `search`), und Source-Seiten sind darin der dichteste Knotentyp. Ein Hoist
|
||||
machte jede Graph-Operation dauerhaft zweiwurzelig, um ein Verzeichnis umzubenennen. Vor allem
|
||||
aber kann der *Ort* Eigentum ohnehin nicht kodieren, sobald Collections offen sind: eine selbst
|
||||
angelegte liegt im selben `kb/` wie die Defaults. Eigentum ist eine deklarierte Eigenschaft -
|
||||
daher das Frontmatter oben. Gitea #39 trägt die Ablehnung im Volltext.
|
||||
|
||||
**Warum das MAJOR ist.** Die Rückwärtshälfte des Drop-in-Tests hält - 2.5.0 ignoriert beide neuen
|
||||
Deklarationen folgenlos. Die Vorwärtshälfte nicht: nach dem Kopieren der Maschinerie FAILt
|
||||
`doctor` auf der fehlenden `kb/CONVENTIONS.md`, `docs verify` bricht auf den undeklarierten
|
||||
Collections, und `kb/CONTRACT.md` muss aus dem Release nachgezogen werden. Ein Shim war die
|
||||
Alternative (`doctor` nur WARN, Pflichtfelder tolerant) und wurde verworfen: er hätte genau den
|
||||
Zustand normalisiert, in dem eine Instanz glaubt, sie habe entschieden, während in Wahrheit der
|
||||
Fallback antwortet - für eine englische Instanz hieße das `## Beziehungen` in englischen Seiten.
|
||||
Die Handarbeit ist eine Datei und zwei Frontmatter-Zeilen je Collection; keine einzige `kb/`-Seite
|
||||
ändert sich, weshalb `migrate done 3.0.0 --pages 0` ehrlich und kein Platzhalter ist.
|
||||
|
||||
---
|
||||
|
||||
## 2.5.0 - 2026-09-02 - Versionsstelle: Kompatibilitaet statt Inhaltsmigration, Breaking-Change-Vermerk erzwungen
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
+19
-12
@@ -68,11 +68,14 @@ Zwei Schritte, von denen nur der erste rein menschlich ist:
|
||||
Wiki-Seite (`$WIKI_AUTHOR` überschreibt dies bei Bedarf).
|
||||
- **Remote** (optional) - eine URL, wenn du das Repo auf einen Server pushen willst; sonst
|
||||
bleibt die Instanz lokal, und jedes `publish` läuft mit `--no-push`.
|
||||
- **KB-Sprache** - die exportierte Distribution bringt **Deutsch** mit: die Regel in
|
||||
`kb/CONTRACT.md`, das Vokabular in `instructions/german-terminology.md` und deutsche
|
||||
Abschnittsnamen in den Seitenvorlagen. Das ist eine Entscheidung dieser Ursprungsinstanz,
|
||||
keine Eigenschaft des Musters. Willst du eine andere Sprache, sag es **vor dem ersten
|
||||
Ingest** - danach ist es eine Migration jeder bereits angelegten Seite.
|
||||
- **Autorenkonventionen** - Sprache, Abschnittsnamen, Namensformen, Ton, Beziehungslabels
|
||||
und Confidence-Rubrik stehen in `kb/CONVENTIONS.md`, dazu je Collection die Regeln in
|
||||
`kb/<name>/COLLECTION.md`. Die Distribution bringt davon nur die `.template`-Dateien mit:
|
||||
das sind Entscheidungen *dieser* Instanz, keine Eigenschaft des Musters, und nichts davon
|
||||
liegt unter `tools/` oder `types/`. Fertige Profile - darunter ein vollständiges deutsches -
|
||||
hält `instructions/kb-profiles.md` bereit; es ist eine Palette, kein Enum. Sag die Sprache
|
||||
**vor dem ersten Ingest** - danach ist ein Wechsel der Abschnittsnamen eine Migration jeder
|
||||
bereits angelegten Seite.
|
||||
- **Personalization** - wer diese Instanz bedient (`USER.md`) und wie sie klingt
|
||||
(`SOUL.md`). Die Distribution bringt nur `USER.md.template` und `SOUL.md.template` mit:
|
||||
persönlicher Inhalt gehört nicht in jede exportierte Kopie, aber beide Dateien werden in
|
||||
@@ -177,13 +180,17 @@ dieser Unterschied ist der Zustand, in dem sich jede Instanz mitten im Upgrade b
|
||||
|
||||
2. Release-Tarball herunterladen und entpacken (Weg A), die Release-Notes lesen.
|
||||
3. Die **Maschinerie** aus dem Tarball über die Instanz kopieren: `tools/`, `types/`,
|
||||
`instructions/`, `AGENTS.md`, `VERSION`, `.wikitool-release.json`. Nicht anfassen: `kb/`,
|
||||
`raw/`, `work/`, `.wikitool-kb.json` und `.git/` - das ist die Instanz selbst.
|
||||
4. Achtung bei lokal angepassten Contract-Dateien: wer z. B. die KB-Sprache umgestellt hat
|
||||
(Schritt 5 in `setup-instance.md`), hat `kb/CONTRACT.md` und die Templates unter `types/`
|
||||
verändert. Diese Änderungen vorher sichern und danach wieder einspielen. Welche Dateien das
|
||||
sind, verrät ein Vergleich gegen die sha256-Summen im `files`-Block der alten
|
||||
`.wikitool-release.json`.
|
||||
`instructions/`, `AGENTS.md`, `VERSION`, `.wikitool-release.json` - **und `kb/CONTRACT.md`**.
|
||||
Die letzte Datei liegt unter einem Content-Verzeichnis, ist aber Stack-Eigentum: sie hält,
|
||||
was `wikitool` erzwingt, und ist in jeder Instanz gleich. Nicht anfassen: alles andere unter
|
||||
`kb/` und `raw/`, `work/`, `.wikitool-kb.json` und `.git/` - das ist die Instanz selbst,
|
||||
`kb/CONVENTIONS.md` und die `kb/*/COLLECTION.md` eingeschlossen.
|
||||
4. Achtung bei lokal angepassten Stack-Dateien. Die Autorenkonventionen gehören **nicht** dazu:
|
||||
`kb/CONVENTIONS.md` und die `kb/*/COLLECTION.md` liegen unter `kb/`, werden in Schritt 3
|
||||
also ohnehin nicht angefasst - genau dafür ist der Schnitt da. Wer darüber hinaus etwas
|
||||
unter `tools/`, `types/` oder `instructions/` verändert hat, sichert das vorher und spielt
|
||||
es danach wieder ein. Welche Dateien das sind, verrät ein Vergleich gegen die sha256-Summen
|
||||
im `files`-Block der alten `.wikitool-release.json`.
|
||||
5. **Die Migrationskette abarbeiten.** `tools/wikitool migrate status` listet jetzt alle
|
||||
offenen Migrationen in der Reihenfolge, in der sie laufen müssen - bei einem Sprung über
|
||||
mehrere Versionen sind das mehrere. Für jede: das genannte Dokument unter
|
||||
|
||||
@@ -14,12 +14,15 @@ and maintains a persistent wiki** that compounds over time.
|
||||
English; the compiled pages under `kb/` are not. What stays English inside them is everything that
|
||||
is an *identifier* rather than prose - page titles, section headings, wikilink targets, citation
|
||||
ids, schema enum values, tags, commands, paths and code - so `GitOps Ownership Model` and
|
||||
`## Beziehungen` sit in the same page without contradiction. The rule is
|
||||
[kb/CONTRACT.md § Language](kb/CONTRACT.md#language); the vocabulary behind it is
|
||||
`## Beziehungen` sit in the same page without contradiction. Which lines are identifiers is
|
||||
[kb/CONTRACT.md § Language and identifiers](kb/CONTRACT.md#language-and-identifiers); *which
|
||||
language* the prose is in, and what the tool-owned headings are called, is this instance's own
|
||||
[kb/CONVENTIONS.md](kb/CONVENTIONS.md), and the vocabulary behind it is
|
||||
[instructions/german-terminology.md](instructions/german-terminology.md).
|
||||
|
||||
This is a per-instance decision, not a property of the pattern. A new instance built with
|
||||
`dist export` starts empty and can pick any language by editing that one contract section before
|
||||
This is a per-instance decision, not a property of the pattern - which is why it lives in a file
|
||||
the instance owns rather than in one the stack ships. A new instance built with
|
||||
`dist export` starts empty and picks any language by filling in `kb/CONVENTIONS.md` before
|
||||
the first ingest.
|
||||
|
||||
## Getting started
|
||||
@@ -401,7 +404,7 @@ This wiki is tailored for IT work with:
|
||||
|
||||
- **Entity types** specific to software development and systems
|
||||
- **Relationship types** like `hängt ab von`, `verwendet`, `implementiert` - the vocabulary is in
|
||||
[kb/CONTRACT.md § Linking](kb/CONTRACT.md#linking)
|
||||
[kb/CONVENTIONS.md](kb/CONVENTIONS.md), because it is this instance's rather than the stack's
|
||||
- **Templates** for projects, systems, tools, technologies, ADRs
|
||||
- **Guidelines** for documenting technical decisions
|
||||
- **Cross-reference patterns** for code and architecture
|
||||
|
||||
@@ -58,16 +58,38 @@ whether an instruction is still reachable, which is exactly why the answer means
|
||||
`instructions/dev/` boundary check below asks the opposite question - what would *dangle* in a
|
||||
distributed instance - and does scan README.md, because `dist export` ships it verbatim.
|
||||
|
||||
Two kinds of file use the Manual tier today: [german-terminology.md](german-terminology.md), a
|
||||
vocabulary consulted on demand rather than a procedure, and every migration document (below).
|
||||
Three kinds of file use the Manual tier today: [german-terminology.md](german-terminology.md), a
|
||||
vocabulary consulted on demand rather than a procedure; [kb-profiles.md](kb-profiles.md), the
|
||||
catalogue of authoring profiles an instance may adopt into its own `kb/CONVENTIONS.md` and
|
||||
`COLLECTION.md` files; and every migration document (below).
|
||||
|
||||
## `instructions/migrations/`
|
||||
|
||||
A content migration is a Manual instruction with two extra frontmatter fields
|
||||
A content migration is a Manual instruction with three extra frontmatter fields
|
||||
(`types/instruction.schema.yaml`): `migrates_to:`, the stack version whose content shape it
|
||||
produces, and `migration_kind:` (`mechanical` | `assisted`). It lives at
|
||||
produces; `migration_kind:` (`mechanical` | `assisted`); and `obligation:`
|
||||
(`required` | `offered`, default `required`). It lives at
|
||||
`instructions/migrations/<version>-<slug>.md`.
|
||||
|
||||
`migration_kind:` and `obligation:` are **two axes, not one**. The first says how the work is
|
||||
carried out, the second whether it has to happen at all:
|
||||
|
||||
| `obligation:` | Means | `migrate status` |
|
||||
|---|---|---|
|
||||
| `required` | The content must reach the new shape or it no longer fits the machinery | Counted as outstanding; `migrate done` advances `kb_version` through it, in chain order |
|
||||
| `offered` | A file the instance owns still works as it is, and the stack proposes a better default | Listed separately, never blocks, no ordering rule. `migrate done` records it in the applied ledger and leaves `kb_version` where it is |
|
||||
|
||||
Keeping them apart is what stops `migrate status` crying wolf: an instance nagged about an
|
||||
improvement it declined stops reading the nag that means its content no longer fits its
|
||||
machinery. And because taking an offer deliberately does not move the version, the **applied
|
||||
ledger** - not `kb_version` - is what makes an offer stop being offered; without that record
|
||||
there is no way to tell a taken offer from an ignored one.
|
||||
|
||||
An `offered` migration is what makes an instance-owned file upgradeable at all. `dist export`
|
||||
records a sha256 per shipped file in `.wikitool-release.json`, so `migrate status` can say which
|
||||
of those files the instance edited and which it merely received - the first have to be
|
||||
reconciled by a person, the second can simply be copied over.
|
||||
|
||||
The tier fits exactly: a migration must never be picked up implicitly - it rewrites the corpus -
|
||||
and it is referenced by nothing, because `tools/wikitool migrate status` finds it by reading the
|
||||
directory and comparing `migrates_to:` against this instance's `kb_version`. That is also why
|
||||
@@ -153,7 +175,8 @@ What lives where:
|
||||
|-------|------|
|
||||
| [AGENTS.md](../AGENTS.md) | Invariants and routing - what must always hold |
|
||||
| `instructions/` | How the tooling is *operated* |
|
||||
| [kb/CONTRACT.md](../kb/CONTRACT.md) + each `COLLECTION.md` | How a page is *authored* |
|
||||
| [kb/CONTRACT.md](../kb/CONTRACT.md) | What the stack enforces about a page, in every instance |
|
||||
| `kb/CONVENTIONS.md` + each `COLLECTION.md` | What *this* instance decided about authoring - owned by the instance, shipped only as a `.template` |
|
||||
| [types/](../types/type-spec.md) | What a page structurally *is* |
|
||||
| [tools/CONTRACT.md](../tools/CONTRACT.md) | What each command does and how it fails |
|
||||
|
||||
|
||||
@@ -35,6 +35,11 @@ Do not re-do any of this per test; it is done for you, per test, via `monkeypatc
|
||||
fixture redirects it into `tmp_path`. Tracing is never disabled suite-wide, because two
|
||||
telemetry tests assert that a trace gets written.
|
||||
|
||||
Two in-process caches are cleared alongside the environment, for the same reason: `config`'s
|
||||
resolved paths and `conventions`' parsed `kb/CONVENTIONS.md`. A test that *rewrites* the
|
||||
conventions file mid-test calls `conventions.reset_cache()` itself - the fixture answers for the
|
||||
boundary between tests, not for one inside a test.
|
||||
|
||||
## When to run
|
||||
|
||||
Whenever you add or change a test under `tools/chemenu/tests/`.
|
||||
|
||||
@@ -7,9 +7,13 @@ manual: true
|
||||
|
||||
# German terminology for `kb/`
|
||||
|
||||
Reference vocabulary for [kb/CONTRACT.md](../kb/CONTRACT.md#language)'s rule that pages are
|
||||
written in German. The rule lives there; the word list lives here, because it is lookup material
|
||||
rather than a norm and would otherwise be loaded on every write.
|
||||
Reference vocabulary for [kb/CONVENTIONS.md](../kb/CONVENTIONS.md#language)'s rule that this
|
||||
instance's pages are written in German. The rule lives there; the word list lives here, because
|
||||
it is lookup material rather than a norm and would otherwise be loaded on every write.
|
||||
|
||||
**This file belongs to the `german` language profile, not to the stack.** An instance writing in
|
||||
another language deletes or replaces it - see
|
||||
[kb-profiles.md](kb-profiles.md).
|
||||
|
||||
Derived from translating all 248 pages on 2026-08-29. Every entry below is a decision that was
|
||||
made wrong at least once first - each cost a correction pass across published pages, which is why
|
||||
@@ -98,8 +102,8 @@ none of them structural, so no check found them. It is the one thing to watch fo
|
||||
instructional prose.
|
||||
|
||||
- **Quotations are never reworded**, neither translated nor moved into the impersonal register.
|
||||
- Buzzwords and AI filler are banned by [kb/CONTRACT.md](../kb/CONTRACT.md#tone); the German list
|
||||
is there.
|
||||
- Buzzwords and AI filler are banned by [kb/CONVENTIONS.md](../kb/CONVENTIONS.md#tone); the
|
||||
German list is there.
|
||||
- Dash as ` - `, not `—`.
|
||||
- German number formatting only in prose („10.000 Punkte"). Never inside code, version numbers or
|
||||
measurements (`75-85 px`, `10m`, `0.90`).
|
||||
@@ -108,4 +112,4 @@ instructional prose.
|
||||
|
||||
This is about prose in `kb/`. What is prose and what is an identifier - titles, headings, wikilink
|
||||
targets, cite-ids, enum values, tags, code - is decided by
|
||||
[kb/CONTRACT.md](../kb/CONTRACT.md#language), not here.
|
||||
[kb/CONTRACT.md](../kb/CONTRACT.md#language-and-identifiers), not here.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
type: types/instruction.md
|
||||
name: kb-profiles
|
||||
description: Ready-made answers for kb/CONVENTIONS.md and each COLLECTION.md - the proven collection contracts and language profiles this stack has shipped, offered as a palette to adopt or adapt, never as a binding source.
|
||||
manual: true
|
||||
---
|
||||
# Pick a profile for a collection or for this instance's conventions
|
||||
|
||||
**This page is a palette, not an enum.** Each `kb/<name>/COLLECTION.md` stays authoritative for
|
||||
its own collection and `kb/CONVENTIONS.md` for the instance as a whole; an entry here is a
|
||||
proven starting point, nothing more. Adopting one means *copying its text into* that file - not
|
||||
pointing at this page and inheriting whatever it says later. Nothing in the stack reads this
|
||||
document, and `profile:` in a contract's frontmatter records where the text came from, not where
|
||||
it lives.
|
||||
|
||||
That direction is deliberate and it is the opposite of how this repo used to work. Language,
|
||||
tone, naming and the relationship vocabulary sat in `kb/CONTRACT.md`, a file `dist export` ships
|
||||
verbatim - so every instance that wanted something else edited a stack file, and an upstream
|
||||
merge handed the stack's answer back. What binds is now the instance's; what ships is this
|
||||
catalogue, and it binds nothing.
|
||||
|
||||
## When to run
|
||||
|
||||
- Setting up a new instance: the KB-language step of
|
||||
[setup-instance.md](setup-instance.md) sends you here to fill `kb/CONVENTIONS.md`.
|
||||
- Adding a collection to an existing instance, and wanting a contract that already works rather
|
||||
than a blank one.
|
||||
- Rewriting an existing `COLLECTION.md` or `kb/CONVENTIONS.md` and wanting to see what the
|
||||
alternatives were.
|
||||
|
||||
Not for changing what the *stack* enforces. That is [kb/CONTRACT.md](../kb/CONTRACT.md), and it
|
||||
is not a profile.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Decide what you are filling.** Two different files, and they are not interchangeable:
|
||||
|
||||
| File | Holds | Profiles below |
|
||||
|---|---|---|
|
||||
| `kb/CONVENTIONS.md` | Language, section headings, naming forms, tone, relationship labels, confidence rubric - once per instance | [Language profiles](#language-profiles) |
|
||||
| `kb/<name>/COLLECTION.md` | What one collection holds, its quality goal, its local linking and naming rules | [Collection profiles](#collection-profiles) |
|
||||
|
||||
2. **Copy the entry's text into the file**, then edit it until it is true of this instance.
|
||||
A profile you adopted and then changed is still that profile's `profile:` value - the field
|
||||
records the starting point, not a promise of fidelity.
|
||||
|
||||
3. **Record it.** `profile: <name>` in the file's frontmatter, or `profile: none` for a
|
||||
collection written from scratch. `wikitool docs verify` checks the field is there; it does
|
||||
not check the value against this page, because a collection an instance invented has no
|
||||
entry here to name.
|
||||
|
||||
4. **Set `required_by_stack:` on a collection - and set it correctly.** This one is *not* a
|
||||
choice: it says whether `wikitool` resolves against the collection by name, and
|
||||
`docs verify` checks it against the stack's own list. `sources` is `true`, everything else
|
||||
is `false`. See [kb/CONTRACT.md § Collections](../kb/CONTRACT.md#collections).
|
||||
|
||||
## Language profiles
|
||||
|
||||
A language profile answers all of `kb/CONVENTIONS.md` at once. There is one today, because one
|
||||
is what this repo has actually run.
|
||||
|
||||
### `german`
|
||||
|
||||
The profile this repo's own instance uses, and the reason this catalogue exists: it was the
|
||||
stack's hardcoded behaviour until the conventions file existed.
|
||||
|
||||
| Decides | Value |
|
||||
|---|---|
|
||||
| `language:` | `de` |
|
||||
| `sections:` | `Beziehungen` / `Siehe auch` / `Fußnoten` |
|
||||
| Naming | Human-readable titles with spaces; singular for entities; `adr-NNN-` for decisions; `X vs Y` for comparisons |
|
||||
| Tone | Wikipedia register, with a German buzzword and filler list |
|
||||
| Relationship labels | `hängt ab von` · `verwendet` · `implementiert` · `erweitert` · `ersetzt` · `steht in Konflikt mit` · `benötigt` · `erzeugt` · `konsumiert` · `besitzt` · `pflegt` · `läuft auf` · `verwandt mit` |
|
||||
| Confidence rubric | 0.5 base, +0.2 per supporting source (max +0.6), recency and source-quality bonuses; hedge with "möglicherweise"/"kann" below 0.6, "unsicher"/"unbestätigt" below 0.4 |
|
||||
| Terminology | [german-terminology.md](german-terminology.md) - which English terms stay English, and which have a settled German form |
|
||||
|
||||
**The full text to copy** is this repo's own [kb/CONVENTIONS.md](../kb/CONVENTIONS.md). An
|
||||
instance adopting it takes that file, not this table; the table is what the profile *decides*,
|
||||
so you can tell at a glance whether it is the one you want.
|
||||
|
||||
Adopting it also means keeping `german-terminology.md`. An instance on any other language
|
||||
deletes or replaces that file - it is the profile's lookup material, not the stack's.
|
||||
|
||||
### `english`
|
||||
|
||||
What `kb/CONVENTIONS.md.template` ships as its default, so "adopt `english`" means "fill in the
|
||||
template and change nothing structural". `sections:` are `Relationships` / `See Also` /
|
||||
`Footnotes`, which are also the names this stack wrote before it had a conventions file - so a
|
||||
corpus that predates the split needs no translation pass to adopt this profile.
|
||||
|
||||
There is no worked text for the rest of it. The template's placeholders are the questions;
|
||||
`german` above is what a filled answer looks like.
|
||||
|
||||
### Writing a third one
|
||||
|
||||
A language profile is not a translation of `german`. Two of its sections are judgment about a
|
||||
language rather than vocabulary in it - which foreign technical terms stay untranslated, and how
|
||||
to hedge a low-confidence claim - and those are exactly the two that read as awkward when
|
||||
translated mechanically. Write them, do not convert them.
|
||||
|
||||
The one part that is mechanical: `section_aliases:`. Whatever the corpus used before goes in
|
||||
that list, and the pages then migrate one at a time instead of all at once.
|
||||
|
||||
## Collection profiles
|
||||
|
||||
The four collections this repo runs. Each is a whole `COLLECTION.md`, and **the text to copy is
|
||||
the file itself** - `dist export` ships each one as `kb/<name>/COLLECTION.md.template`, which a
|
||||
new instance adopts by renaming. What follows is what each decides, so you can tell whether you
|
||||
want it.
|
||||
|
||||
### `entities`
|
||||
|
||||
Concrete, pointable things: projects, deployed systems, tools, technologies, people.
|
||||
|
||||
- **Quality goal:** pointability plus currency - what the thing is, where it actually is, and
|
||||
whether that is still true.
|
||||
- **Areas** driven by the `entity_type:` field: `projects/`, `systems/`, `tools/`,
|
||||
`technologies/`, `people/`. Areas, not collections - they inherit the contract and carry no
|
||||
`COLLECTION.md`.
|
||||
- **Per-area emphasis** spelled out, so a system page is not written like a technology page.
|
||||
- `required_by_stack: false`.
|
||||
|
||||
Take it when the wiki is about things that exist. Adapt the area list first: it is the part most
|
||||
likely to be wrong for another domain.
|
||||
|
||||
### `concepts`
|
||||
|
||||
Ideas rather than things: architectures, patterns, protocols, workflows, recurring problems,
|
||||
and the decisions taken about them.
|
||||
|
||||
- **Quality goal:** explanatory sufficiency - the page answers *why it is done this way* without
|
||||
the reader opening the entity pages that use it.
|
||||
- Carries the **ADR shape**: context, decision, consequences, status, and the rule that a
|
||||
superseded decision is never rewritten.
|
||||
- Routes head-to-head arguments out to `comparisons/` rather than hosting them.
|
||||
- `required_by_stack: false`.
|
||||
|
||||
Take it whenever `entities` is taken - the split between the two is what keeps either from
|
||||
becoming an essay.
|
||||
|
||||
### `sources`
|
||||
|
||||
One page per ingested source, carrying the `raw_files:` provenance every citation resolves
|
||||
against.
|
||||
|
||||
- **Quality goal:** faithful compression - what *this source* said, not what was concluded from
|
||||
it. A source page improved beyond its source is no longer evidence.
|
||||
- Titles carry the `Source - ` prefix, applied by `wikitool new source`.
|
||||
- `required_by_stack: **true**`. `sources coverage`, `[^cite-id]` resolution and
|
||||
`kb/provenance.md` resolve against the name `sources`.
|
||||
|
||||
Not optional in the way the others are. An instance may rewrite its authoring rules and may not
|
||||
rename or drop it.
|
||||
|
||||
### `comparisons`
|
||||
|
||||
Structured head-to-head evaluations of two or more things that already have pages.
|
||||
|
||||
- **Quality goal:** decidability - named, checkable dimensions and a stated trade-off, so a
|
||||
reader with a concrete situation can choose.
|
||||
- Every subject must already have a page; a comparison is a view over existing knowledge.
|
||||
- **Exempt from the orphan check** - comparisons are reached through the catalog, not through
|
||||
inbound prose links.
|
||||
- `required_by_stack: false`.
|
||||
|
||||
Skip it in a wiki that records rather than decides. It is the one of the four that is genuinely
|
||||
optional.
|
||||
|
||||
## Decision points
|
||||
|
||||
- **A profile is almost right?** Copy and edit. There is no partial adoption and no override
|
||||
file - the copy *is* the mechanism, and `profile:` still records where it started.
|
||||
- **Two collections want the same profile?** Fine. `profile:` is not unique, and two
|
||||
collections holding different subject matter under the same authoring rules is an ordinary
|
||||
outcome.
|
||||
- **Changing `sections:` after pages exist?** That is a corpus migration, not an edit. Put the
|
||||
old names in `section_aliases:` first, then translate page by page - the tool keeps finding
|
||||
the old headings for as long as the alias stands. See
|
||||
[migrate-corpus.md](migrate-corpus.md).
|
||||
- **Tempted to make this page binding** - to have `COLLECTION.md` say `profile: entities` and
|
||||
nothing else? Do not. That is the arrangement this split was written to end: the instance
|
||||
would be bound by a file the stack ships and upgrades, which is how an upstream merge changes
|
||||
an instance's authoring rules without anyone deciding to.
|
||||
|
||||
## Scope
|
||||
|
||||
Covers what an instance authors under `kb/`. It says nothing about what the stack enforces
|
||||
([kb/CONTRACT.md](../kb/CONTRACT.md)), what a page structurally is
|
||||
([types/type-spec.md](../types/type-spec.md)), or how a command behaves
|
||||
([tools/CONTRACT.md](../tools/CONTRACT.md)). None of those are profiles, and none of them are
|
||||
the instance's to change.
|
||||
@@ -0,0 +1,199 @@
|
||||
---
|
||||
type: types/instruction.md
|
||||
name: link-taxonomy
|
||||
description: The link-label catalogue - every relationship label a page may declare in related:, grouped by register, with the reader need each one names. A palette to authorise from in a COLLECTION.md, never binding on its own.
|
||||
manual: true
|
||||
---
|
||||
# Pick a link label
|
||||
|
||||
**This page is a palette, not an enum.** It lists every label this stack ships with and what
|
||||
each one asserts. What a page may actually *use* is decided by its own collection: each
|
||||
`kb/<name>/COLLECTION.md` authorises a subset per destination, and `wikitool lint` checks
|
||||
`related:` against that authorisation rather than against this file. A collection that
|
||||
authorises six labels has six, however long this list gets.
|
||||
|
||||
A label is an **identifier, not prose**. It is written into `related:` as a machine value and
|
||||
rendered verbatim into the page body, so it is never translated - not in a German wiki, not in
|
||||
any other. Which words a page is *written* in stays [kb/CONVENTIONS.md](../kb/CONVENTIONS.md)'s;
|
||||
this is not one of them.
|
||||
|
||||
## The invariant every label obeys
|
||||
|
||||
Every label completes, with the page carrying the link as the grammatical subject:
|
||||
|
||||
> `[source] <label> [target]`
|
||||
|
||||
The page containing the link asserts something **about** the target. `Hermes depends-on
|
||||
PostgreSQL` reads correctly on Hermes' page; the same fact written on PostgreSQL's page is a
|
||||
different label (`required-by`), not the same one pointing back. Omitted helper verbs ("is",
|
||||
"a") are fine where they do not reverse the endpoints.
|
||||
|
||||
This is Commonplace's ADR-058, adopted wholesale, and it is what makes a label checkable rather
|
||||
than a matter of taste: read the sentence out loud, and if it says the opposite of what you
|
||||
meant, the label is wrong.
|
||||
|
||||
## Direction is authored, never mirrored
|
||||
|
||||
Each direction is a separate decision. A link back from the target is welcome when it
|
||||
independently helps a reader *there* - and unnecessary when it does not. **Do not add a reverse
|
||||
edge merely to mirror the first one.** The inbound view is rendered from the graph by
|
||||
`index rebuild` and `search`, so a reader landing on the target sees what points at it whether
|
||||
or not anyone wrote a second edge.
|
||||
|
||||
That is why most labels below have no inverse. Only two pairs do, because in each the reverse
|
||||
direction is a genuine primary statement someone would write on its own: `depends-on` /
|
||||
`required-by` and `runs-on` / `hosts`.
|
||||
|
||||
## When to run
|
||||
|
||||
Adding or changing a `related:` entry, authorising labels in a `COLLECTION.md`, or judging
|
||||
whether a relationship is worth naming as a formal edge at all.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Decide whether this is an edge.** Not every mention is one. An edge is a reader aid: it
|
||||
says *follow this if you need X*. A subject mentioned once in passing is prose with a
|
||||
`[[wikilink]]`, not a declared relationship. Over-declaring is how a graph becomes a list of
|
||||
everything adjacent to everything.
|
||||
|
||||
2. **Say the sentence.** `[this page] <label> [that page]`. If it reads backwards, you want the
|
||||
other page to carry the edge, or a different label.
|
||||
|
||||
3. **Pick from the register that fits the pair**, below. Prefer the most specific label that is
|
||||
true; fall back outward only when nothing fits.
|
||||
|
||||
4. **Check the collection authorises it** for that destination -
|
||||
`kb/<name>/COLLECTION.md`'s `outbound:` block. If the label you want is not authorised and
|
||||
should be, that is a collection-contract change, made deliberately, not a lint error to
|
||||
route around.
|
||||
|
||||
5. **Write it with the tool**, never by hand:
|
||||
|
||||
```bash
|
||||
tools/wikitool xref add --a "<This Page>" --b "<That Page>" --rel <label>
|
||||
```
|
||||
|
||||
## The catalogue
|
||||
|
||||
### Operational
|
||||
|
||||
Concrete things and how they stand to one another - the register this instance runs on. Mostly
|
||||
entity to entity.
|
||||
|
||||
| label | inverse | asserts |
|
||||
|---|---|---|
|
||||
| `depends-on` | `required-by` | cannot function without the target |
|
||||
| `required-by` | `depends-on` | the target cannot function without this |
|
||||
| `runs-on` | `hosts` | executes on the target as its substrate |
|
||||
| `hosts` | `runs-on` | provides the substrate the target executes on |
|
||||
| `uses` | — | employs the target at runtime, but survives without it |
|
||||
| `produces` | — | emits the target as an artifact or data |
|
||||
| `consumes` | — | reads the target as an artifact or data |
|
||||
| `maintains` | — | carries the upkeep of the target |
|
||||
| `owns` | — | is accountable for the target's existence and decisions |
|
||||
|
||||
`uses` versus `depends-on` is the distinction worth keeping sharp: if removing the target breaks
|
||||
this thing, it is `depends-on`. `owns` versus `maintains`: accountability versus labour, and
|
||||
they are often different people.
|
||||
|
||||
### Realization
|
||||
|
||||
How an idea becomes a running thing. Usually concept to entity or the reverse.
|
||||
|
||||
| label | asserts |
|
||||
|---|---|
|
||||
| `implements` | is a concrete realization of the target |
|
||||
| `operationalized-from` | is the prescriptive form of the target's theory |
|
||||
| `mechanism` | is the mechanism by which the target works |
|
||||
| `procedure` | is the procedure for carrying out the target |
|
||||
| `applies-when` | applies under the condition the target describes |
|
||||
| `operates-on` | acts upon the target as its subject matter |
|
||||
| `invokes` | calls the target as a step within itself |
|
||||
|
||||
### Conceptual
|
||||
|
||||
Inference and comparison between ideas.
|
||||
|
||||
| label | asserts |
|
||||
|---|---|
|
||||
| `extends` | develops the target's argument further |
|
||||
| `grounds` | provides the basis the target rests on |
|
||||
| `rests-on` | takes the target as its premise |
|
||||
| `enables` | is the operational prerequisite that makes the target possible |
|
||||
| `precondition` | must hold before the target applies |
|
||||
| `exemplifies` | is an instance of the general claim the target makes |
|
||||
| `abstracted-from` | generalizes from the target |
|
||||
| `contrasts` | differs from the target in a way worth reading both for |
|
||||
| `compares-with` | is weighed against the target on shared dimensions |
|
||||
| `contradicts` | asserts something the target denies |
|
||||
| `composition` | is composed of the target |
|
||||
| `part-of` | is a component of the target |
|
||||
|
||||
`grounds` / `rests-on` is a genuine pair and both directions are primary statements; they are
|
||||
listed separately rather than as inverses because either page may legitimately carry only its
|
||||
own side.
|
||||
|
||||
### Lineage
|
||||
|
||||
Where something came from, and what replaced it.
|
||||
|
||||
| label | asserts |
|
||||
|---|---|
|
||||
| `supersedes` | replaces the target, which is now historical |
|
||||
| `derived-from` | was produced from the target |
|
||||
| `adapted-from` | was reworked from the target for a different purpose |
|
||||
| `defined-in` | takes its definition from the target |
|
||||
|
||||
A superseded page is never deleted or rewritten - see the collection contract for
|
||||
`kb/concepts/`.
|
||||
|
||||
### Evidence
|
||||
|
||||
The provenance register. Distinct from `sources:` and `[^cite-id]`, which are the *mechanical*
|
||||
provenance path: these two are authored claims about how strongly something is backed.
|
||||
|
||||
| label | asserts |
|
||||
|---|---|
|
||||
| `evidenced-by` | is supported by the target as evidence |
|
||||
| `is-evidence-for` | serves as evidence for the target's claim |
|
||||
|
||||
### Universal
|
||||
|
||||
| label | asserts |
|
||||
|---|---|
|
||||
| `see-also` | nothing more specific applies, and a reader here would still want the target |
|
||||
|
||||
**`see-also` is the last resort and should stay rare.** A collection where it is the commonest
|
||||
label has a vocabulary problem, not a lot of loosely related pages. The previous vocabulary's
|
||||
`verwandt mit` was exactly that, and it is the reason this catalogue exists.
|
||||
|
||||
## Extending it
|
||||
|
||||
Adding a label is a line of data, never a code change:
|
||||
|
||||
1. Add a row here, in the register it belongs to, with the sentence it completes.
|
||||
2. Authorise it in the `COLLECTION.md` of every collection that may use it.
|
||||
|
||||
The registers are advisory groupings for readers, not a schema - nothing checks that a label is
|
||||
used only within its register. Invent an intra-collection label the work needs and propose it
|
||||
here afterwards; the architecture is deliberately loose, because the link theory is still
|
||||
developing.
|
||||
|
||||
## Decision points
|
||||
|
||||
- **Two labels both fit?** Take the more specific one. If they are equally specific and mean
|
||||
different things, the relationship is probably two edges.
|
||||
- **The relationship reads better from the other page?** Write it there. Nothing is lost - the
|
||||
inbound view renders it here.
|
||||
- **You want a reverse edge for navigation?** You do not need one. That is what the rendered
|
||||
inbound view is for, and it is complete in a way an authored mirror never was.
|
||||
- **Nothing fits at all?** Use `see-also` and say so in the commit, or propose a label. Do not
|
||||
stretch a label whose sentence reads false - a wrong edge is worse than a weak one, because
|
||||
it is machine-readable and will be believed.
|
||||
|
||||
## Scope
|
||||
|
||||
Covers labels on `related:` edges between pages. Says nothing about `sources:` (the provenance
|
||||
field, unlabelled by construction), `[^cite-id]` footnotes
|
||||
([kb/CONTRACT.md](../kb/CONTRACT.md#provenance-and-citation)), or `tags:` (search keys, not
|
||||
relationships).
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
type: types/instruction.md
|
||||
name: 3.0.0-authoring-conventions
|
||||
description: 'Adopt the instance-owned authoring conventions introduced in 3.0.0 - write kb/CONVENTIONS.md, declare profile:/required_by_stack: on every COLLECTION.md, and replace kb/CONTRACT.md with the shipped one.'
|
||||
manual: true
|
||||
migrates_to: 3.0.0
|
||||
migration_kind: mechanical
|
||||
---
|
||||
# Adopt this instance's own authoring conventions (3.0.0)
|
||||
|
||||
Until 3.0.0, the rules for writing a page were split by *location*: everything about `kb/` sat
|
||||
in `kb/CONTRACT.md`, a file every distribution ships verbatim. Half of it was never the stack's
|
||||
to decide - the language pages are written in, the three tool-owned section headings, the naming
|
||||
forms, the tone, the relationship labels, the confidence rubric - so an instance that wanted
|
||||
something else edited a file the stack also ships, and an upstream merge handed the stack's
|
||||
answer back.
|
||||
|
||||
3.0.0 splits it by *ownership* instead. `kb/CONTRACT.md` keeps only what `wikitool` enforces;
|
||||
everything else moves into a new `kb/CONVENTIONS.md` that belongs to this instance, and each
|
||||
`kb/<name>/COLLECTION.md` now declares what it is. The compiler reads its section headings from
|
||||
that file rather than from `tools/chemenu/sections.py`.
|
||||
|
||||
**No page changes.** Not one line under `kb/entities/`, `kb/concepts/`, `kb/sources/` or
|
||||
`kb/comparisons/` is touched. What changes are the contracts beside them, which is why this is
|
||||
`mechanical` and takes minutes rather than a workshop.
|
||||
|
||||
## When to run
|
||||
|
||||
After installing 3.0.0 machinery over an instance that was on 2.x, when `tools/wikitool doctor`
|
||||
reports `FAIL conventions` or `tools/wikitool docs verify` reports a `COLLECTION.md` with no
|
||||
frontmatter. `tools/wikitool migrate status` names this document.
|
||||
|
||||
**Until it has run, the compiler answers out of a fallback.** `xref add` and `cite add` write
|
||||
`## Beziehungen` / `## Siehe auch` / `## Fußnoten` - what this stack hardcoded before the
|
||||
conventions file existed. That is correct for a corpus written under them and wrong for any
|
||||
other, so run this before the next `wiki-ingest` or `wiki-manage`, not afterwards.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Replace `kb/CONTRACT.md` from the release.** It is machinery that happens to live under a
|
||||
content directory, and the tarball update path used to skip it (see `INSTALL.md`, which now
|
||||
names it explicitly). The 3.0.0 version is roughly half the length of the 2.x one - the
|
||||
removed half is what step 2 is about to write into a file of yours.
|
||||
|
||||
```bash
|
||||
cp <unpacked-release>/kb/CONTRACT.md kb/CONTRACT.md
|
||||
```
|
||||
|
||||
A private instance cloned from an upstream takes it with the merge instead - see
|
||||
[private-instance.md](../private-instance.md), whose update procedure now re-takes the
|
||||
upstream side for exactly this path.
|
||||
|
||||
2. **Write `kb/CONVENTIONS.md`.** Two ways in, and the first is almost always right:
|
||||
|
||||
- **This instance writes German pages** (it did, unless you changed it): copy the release's
|
||||
`kb/CONVENTIONS.md.template` and fill it from the `german` profile in
|
||||
[kb-profiles.md](../kb-profiles.md) - whose worked full text is the origin repo's own
|
||||
`kb/CONVENTIONS.md`. Everything in it was already true of your corpus; it was simply
|
||||
written down somewhere you did not own.
|
||||
- **You had changed the language**, and therefore hold local edits to `kb/CONTRACT.md`,
|
||||
`types/*.md` and `tools/chemenu/sections.py`: those edits are what this file replaces. Copy
|
||||
the canonical heading names out of your old `sections.py` into `sections:`, the labels and
|
||||
tone rules out of your old `kb/CONTRACT.md`, then **discard the local edits under `tools/`
|
||||
and `types/`** and take the shipped versions. That is the whole point of the change: there
|
||||
is nothing left to patch there.
|
||||
|
||||
The minimum the tool needs is the frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
language: de
|
||||
profile: german
|
||||
sections:
|
||||
relationships: Beziehungen
|
||||
see_also: Siehe auch
|
||||
footnotes: Fußnoten
|
||||
---
|
||||
```
|
||||
|
||||
Set `sections:` to the names **your existing pages already carry**, not to what you would
|
||||
prefer. Changing them is a separate, real corpus migration; `section_aliases:` is how it is
|
||||
done page by page ([migrate-corpus.md](../migrate-corpus.md)).
|
||||
|
||||
Drop the `wikitool:template-unfilled` sentinel line while filling it in - `doctor` FAILs on a
|
||||
renamed-but-unanswered template exactly as it does for `USER.md`.
|
||||
|
||||
3. **Declare each collection.** Two frontmatter lines at the top of every
|
||||
`kb/<name>/COLLECTION.md`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
profile: <the entry in instructions/kb-profiles.md this contract came from, or none>
|
||||
required_by_stack: false
|
||||
---
|
||||
```
|
||||
|
||||
`required_by_stack: true` on `kb/sources/` and **nowhere else**. It is not a preference:
|
||||
`sources coverage`, `[^cite-id]` resolution and `kb/provenance.md` resolve against that name,
|
||||
and `docs verify` checks the field against the stack's own list in both directions.
|
||||
|
||||
For the four default collections, the shipped `kb/<name>/COLLECTION.md.template` files carry
|
||||
the right values already.
|
||||
|
||||
4. **Verify.** All three must pass:
|
||||
|
||||
```bash
|
||||
tools/wikitool doctor # `conventions` must be OK
|
||||
tools/wikitool docs verify
|
||||
tools/wikitool lint
|
||||
```
|
||||
|
||||
`migrate verify` is deliberately not in that list: it compares pages, and no page changed.
|
||||
Running it would report nothing and prove nothing.
|
||||
|
||||
5. **Record it.**
|
||||
|
||||
```bash
|
||||
tools/wikitool migrate done 3.0.0 --pages 0
|
||||
```
|
||||
|
||||
`--pages 0` is honest, not a placeholder - see the note under step 1.
|
||||
|
||||
## How to tell a migrated instance from an unmigrated one
|
||||
|
||||
`kb/CONVENTIONS.md` exists, carries no `wikitool:template-unfilled` line, and names all three
|
||||
slots under `sections:`; every `kb/*/COLLECTION.md` opens with a frontmatter block; and
|
||||
`kb/CONTRACT.md` has a `## Language and identifiers` heading rather than a `## Language` one.
|
||||
`doctor` answers all of that in one call.
|
||||
|
||||
## Decision points
|
||||
|
||||
- **`doctor` says `conventions: FAIL` after step 2?** It prints which slot is missing. The three
|
||||
keys are `relationships`, `see_also` and `footnotes` - the *slot* names are fixed, only their
|
||||
values are yours.
|
||||
- **A collection this instance invented, with no profile behind it?** `profile: none`. The field
|
||||
records where the text came from; it is free text and `docs verify` does not check it against
|
||||
the catalogue, because an invented collection has no entry there to name.
|
||||
- **Tempted to point `profile:` at the catalogue instead of copying the text?** Do not. An
|
||||
adopted profile is a copy; a reference would put your binding authoring rules in a file the
|
||||
stack ships and upgrades, which is the arrangement 3.0.0 exists to end.
|
||||
- **Your old `kb/CONTRACT.md` had local edits you still want?** They belong in
|
||||
`kb/CONVENTIONS.md` now. If something you edited has no home there, it was a stack rule you
|
||||
overrode - file it as an issue against the origin repo rather than re-applying it.
|
||||
|
||||
## Scope
|
||||
|
||||
One instance's contracts, once. It changes no page, no frontmatter on a page, and nothing under
|
||||
`raw/`. The machinery half of the 3.0.0 upgrade - copying `tools/`, `types/`, `instructions/`,
|
||||
`AGENTS.md`, `VERSION` and `.wikitool-release.json` - is `INSTALL.md`'s, and has to have
|
||||
happened before step 1.
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
type: types/instruction.md
|
||||
name: 4.0.0-link-taxonomy
|
||||
description: Move every relationship from free-text prose in a body bullet to a labelled edge in related:, and every tool-owned body region from heading-matching to a marker pair.
|
||||
manual: true
|
||||
migrates_to: 4.0.0
|
||||
migration_kind: assisted
|
||||
obligation: required
|
||||
---
|
||||
# Move relationships into the data, and generated regions behind markers (4.0.0)
|
||||
|
||||
Until 4.0.0 the stack used **prose as an identifier** in three places, and each one cost
|
||||
something measurable:
|
||||
|
||||
| Was the identifier | Cost |
|
||||
|---|---|
|
||||
| A section's heading text (`## Beziehungen`) | The KB language was a compiler constant, and the region's *end* was a guess. Content sitting after it was silently deleted on eight pages |
|
||||
| A relationship label in a body bullet (`- **hängt ab von:**`) | Nothing could check the vocabulary, so it drifted to **152 distinct labels** across 337 bullets against thirteen that were documented |
|
||||
| The reciprocal half of every edge | `xref add` mirrored every link, which made per-collection label authorisation impossible and filled `## Siehe auch` with 555 unlabelled bullets, 353 of them provably redundant |
|
||||
|
||||
4.0.0 replaces all three. A region is delimited by a marker pair and rendered from frontmatter;
|
||||
a label is a machine value in `related:`, drawn from a catalogue and authorised per destination
|
||||
by the source collection; an edge is authored in one direction and the inbound view is computed.
|
||||
|
||||
**This one touches pages.** Unlike 3.0.0 it is not a contract reshuffle: every `related:` entry
|
||||
and every tool-owned body region changes. It is `assisted` because there is no mapping table -
|
||||
mapping free-text German onto a 35-label catalogue is a judgment call per edge, and a large
|
||||
minority of the old labels are reverse directions that under the new model are not stored at all.
|
||||
|
||||
## When to run
|
||||
|
||||
After installing 4.0.0 over an instance on 3.x. `tools/wikitool migrate status` names it, and
|
||||
`lint` reports `unlabelled_edges` for every unconverted edge - that count reaching zero is how
|
||||
you know the run is finished.
|
||||
|
||||
**Nothing breaks while it is outstanding.** Unlabelled edges and undelimited regions are read,
|
||||
not rejected: `links.py` treats a bare title as an edge whose label is not declared yet, and
|
||||
`provenance.split_cite_block` falls back to the pre-marker layout. That is deliberate - a corpus
|
||||
has to stay readable while it is being converted - and it is why the two lint findings are
|
||||
advisory until step 6 promotes them.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Rewrite `kb/CONVENTIONS.md`'s `sections:` block.** Three slots become two, because the
|
||||
See Also region is gone:
|
||||
|
||||
```yaml
|
||||
sections:
|
||||
links: <your heading for declared relationships>
|
||||
footnotes: <your heading for citation definitions>
|
||||
```
|
||||
|
||||
Delete `section_aliases:` if you have one - nothing matches on heading text any more, so
|
||||
there is nothing to alias. The heading is now a *rendering* value: changing it re-renders
|
||||
the words above each region on the next write and can no longer split a page.
|
||||
|
||||
2. **Add an `outbound:` block to every `kb/<name>/COLLECTION.md`.** Which labels a page may use,
|
||||
per destination collection, with `any` as a wildcard:
|
||||
|
||||
```yaml
|
||||
outbound:
|
||||
entities: [depends-on, runs-on, uses, see-also]
|
||||
concepts: [implements, see-also]
|
||||
```
|
||||
|
||||
The catalogue to draw from is [link-taxonomy.md](../link-taxonomy.md); the four contracts in
|
||||
the origin repo are worked examples. **The source collection decides** - that is what makes a
|
||||
35-label palette usable, and it is why the reverse edge can no longer be written
|
||||
automatically. A destination you do not list authorises nothing, which is a real answer.
|
||||
|
||||
3. **Fix your page type-spec templates.** If you adopted the 3.0.0 templates, they contain
|
||||
`## {section.relationships}` and `## {section.see_also}`. Those variables no longer exist and
|
||||
would be written into new pages literally. **Delete both sections from the `## Template`
|
||||
block** - a template must not scaffold a tool-owned region at all: it is generated between
|
||||
markers on the first `xref add` / `cite add` and re-rendered on every write.
|
||||
|
||||
4. **Convert the corpus**, following [migrate-corpus.md](../migrate-corpus.md). Cut it into
|
||||
units sized against the iteration budget; the origin repo used four, ~45 pages each. Per page:
|
||||
|
||||
- For each labelled bullet under the old relationships heading: say the sentence
|
||||
`[this page] <label> [target]` and pick the catalogue label that makes it true. If it only
|
||||
reads true **backwards**, the edge belongs on the other page - move it there rather than
|
||||
inventing an inverse label the catalogue does not have.
|
||||
- For each bare `- [[X]]` bullet under the old See Also heading: drop it if a labelled edge
|
||||
already connects the pair. Otherwise decide - a real label, or dropped with the reason
|
||||
recorded. **Do not convert them to `see-also` in bulk.** That is the one shortcut this
|
||||
migration explicitly refuses: it would start the new taxonomy with most of its edges on its
|
||||
weakest label, which is the sediment the change exists to remove.
|
||||
- Write edges with `tools/wikitool xref add --a "<A>" --b "<B>" --rel <label>`, never by
|
||||
hand. The body region is rendered from `related:`; editing inside a marker pair is
|
||||
overwritten without warning.
|
||||
- `cite sync` converts a page's old footnote block into a marked region in passing.
|
||||
|
||||
5. **Check each unit mechanically before anything else:**
|
||||
|
||||
```bash
|
||||
tools/wikitool migrate verify --from <pre-migration rev> --path kb/<area> --fail-on-error
|
||||
```
|
||||
|
||||
It compares wikilink and citation **counts**, footnote definitions, H1, structural
|
||||
frontmatter, and - new in 4.0.0 - the **count of marker pairs per region**. A dropped marker
|
||||
is otherwise silent: the region becomes ordinary prose and the next write appends a second
|
||||
one beside it.
|
||||
|
||||
6. **Record it, then tighten the checks:**
|
||||
|
||||
```bash
|
||||
tools/wikitool lint # unlabelled_edges and unauthorised_labels must be 0
|
||||
tools/wikitool migrate done 4.0.0 --pages <N>
|
||||
```
|
||||
|
||||
Only once `lint` reports zero of both is the run finished. The two findings are advisory
|
||||
during the window and become hard errors afterwards - the same path
|
||||
`legacy_citation_markers` took after the citation migration.
|
||||
|
||||
## How to tell a migrated page from an unmigrated one
|
||||
|
||||
Its `related:` entries are `- <label>: <title>` rather than bare titles, and its relationship
|
||||
and footnote sections sit between `<!-- wikitool:links -->` / `<!-- wikitool:footnotes -->`
|
||||
marker pairs. `tools/wikitool links show --page "<Title>"` prints `unlabelled` for every edge
|
||||
still waiting, and `lint`'s `unlabelled_edges` count is the corpus-wide version of the same
|
||||
question.
|
||||
|
||||
## Decision points
|
||||
|
||||
- **A label you want is not in the catalogue?** Add it - a row in `link-taxonomy.md` and an
|
||||
entry in the authorising `COLLECTION.md`. No code change is involved, and the registers are
|
||||
advisory groupings rather than a schema. Do not stretch a label whose sentence reads false: a
|
||||
wrong edge is worse than a weak one, because it is machine-readable and will be believed.
|
||||
- **`related:` holds an entry with no body bullet to derive a label from?** Expected - the
|
||||
origin repo found 480 edges against 337 bullets, because frontmatter and body had already
|
||||
drifted apart while the label lived only in prose. Read the page and decide; that drift is
|
||||
itself part of what this migration repairs.
|
||||
- **A page loses its last inbound edge?** The orphan check will now report it, and that is the
|
||||
check working: directional edges mean a page nothing points at is genuinely unreachable, where
|
||||
the old mirrored model always manufactured a back-link. Either something should point at it,
|
||||
or it is reached through the catalog and that is fine.
|
||||
- **Tempted to keep writing reverse edges for navigation?** Do not. `links show` computes the
|
||||
inbound view, and the rendered bullet on the asserting page is an ordinary `[[wikilink]]`, so
|
||||
a backlink panel in an editor already shows it.
|
||||
|
||||
## Scope
|
||||
|
||||
The corpus under `kb/`, plus the three instance-owned declarations in steps 1-3. It does not
|
||||
touch `raw/`, and it learns nothing new: the same knowledge is restated in a form that can be
|
||||
checked. Installing the 4.0.0 machinery itself is `INSTALL.md`'s and must have happened first.
|
||||
@@ -100,17 +100,36 @@ So the merge has to be scoped. That is the procedure below, and it is not option
|
||||
[setup-instance.md](setup-instance.md), then [bootstrap.md](bootstrap.md) for the venv and
|
||||
the skills.
|
||||
|
||||
A clone inherits the upstream's `kb/CONVENTIONS.md` and `kb/*/COLLECTION.md` rather than
|
||||
templates, because it inherits the upstream's whole tree. They are yours from this point on:
|
||||
rewrite them if this instance writes its pages differently - the update procedure below
|
||||
restores them on every merge, so the change sticks. [kb-profiles.md](kb-profiles.md) has the
|
||||
alternatives.
|
||||
|
||||
## Taking a stack update
|
||||
|
||||
Take the machinery, never the content. The merge is held open, the content stages are forced
|
||||
back to your own state, and only then does it close:
|
||||
back to your own state, and only then does it close.
|
||||
|
||||
**Three files under those stages are machinery, not content**, and forcing them back is how an
|
||||
upstream contract change gets silently discarded:
|
||||
|
||||
| Path | Why it must take the upstream side |
|
||||
|---|---|
|
||||
| `kb/CONTRACT.md` | The stack's own knowledge-layer contract. Every rule in it is enforced by `wikitool`; an instance never edits it |
|
||||
| `kb/CONVENTIONS.md.template` | The template your `kb/CONVENTIONS.md` was filled from. The filled file is yours; the template is the stack's |
|
||||
| `raw/CONTRACT.md` | The raw stage's contract, for the same reason as the first row |
|
||||
|
||||
Everything else under `kb/` and `raw/` is yours, `kb/CONVENTIONS.md` and each
|
||||
`kb/<name>/COLLECTION.md` included - they bind your corpus, and they are exactly what the
|
||||
restore below is protecting.
|
||||
|
||||
```bash
|
||||
BEFORE=$(git rev-parse HEAD)
|
||||
git fetch upstream
|
||||
|
||||
# --no-commit holds the merge open; it may report conflicts under kb/ or raw/,
|
||||
# which the next three lines are about to make irrelevant.
|
||||
# which the next four lines are about to make irrelevant.
|
||||
git merge --no-commit --no-ff upstream/main || true
|
||||
|
||||
# Whatever the merge did to the content stages, undo it. HEAD is still your
|
||||
@@ -119,18 +138,32 @@ git rm -rq --cached --ignore-unmatch kb raw
|
||||
rm -rf kb raw
|
||||
git checkout HEAD -- kb raw
|
||||
|
||||
# ...then take the upstream side back for the machinery that lives among it.
|
||||
# MERGE_HEAD is still resolvable while the merge is open.
|
||||
git checkout MERGE_HEAD -- kb/CONTRACT.md kb/CONVENTIONS.md.template raw/CONTRACT.md
|
||||
|
||||
git commit --no-edit
|
||||
```
|
||||
|
||||
Then **check that it worked**, rather than trusting that it did:
|
||||
Then **check that it worked**, rather than trusting that it did. The same three paths are
|
||||
excluded here, spelled out rather than held in a variable so that the check can be read on its
|
||||
own and copied on its own:
|
||||
|
||||
```bash
|
||||
git diff --name-only $BEFORE HEAD -- kb raw # must print nothing
|
||||
git diff --name-only "$BEFORE" HEAD -- kb raw \
|
||||
| grep -vE '^(kb/CONTRACT\.md|kb/CONVENTIONS\.md\.template|raw/CONTRACT\.md)$'
|
||||
```
|
||||
|
||||
Must print nothing.
|
||||
|
||||
An empty result is the proof that the update touched machinery only. A non-empty one means a
|
||||
path slipped through - inspect it before going further.
|
||||
|
||||
**The exclusion is not cosmetic.** Without it the check reports *empty* for an update that just
|
||||
ate a `kb/CONTRACT.md` change - it would be confirming the failure it exists to catch. If one of
|
||||
the three paths does not appear in the diff at all, that is fine: it means upstream did not
|
||||
touch it.
|
||||
|
||||
Then, as after any stack change: `doctor`, `docs verify`, `instructions verify`, `migrate status`,
|
||||
`lint`. A `migrate status` with outstanding links means the update crossed a compatibility
|
||||
boundary - follow [migrate-corpus.md](migrate-corpus.md) before doing anything else.
|
||||
@@ -157,11 +190,20 @@ merge above. Nothing is lost by the detour: the fix has to pass that CI either w
|
||||
above overwrites those stages with your own afterwards, so the conflict resolves itself.
|
||||
Never resolve one by hand with `git add -A` - that is exactly how the upstream version, which
|
||||
git left sitting in your working tree, gets committed into your instance.
|
||||
- **`git diff` after the merge shows something under `kb/` or `raw/`?** Stop. The scoping step
|
||||
did not take. Do not publish; find out which path came through and where from.
|
||||
- **`git diff` after the merge shows something under `kb/` or `raw/`?** Stop - unless it is one
|
||||
of the three machinery paths the check excludes, which is the update working as intended. For
|
||||
anything else the scoping step did not take: do not publish; find out which path came through
|
||||
and where from.
|
||||
- **Conflict in `tools/`, `types/` or `instructions/`?** You changed the stack locally, which
|
||||
step "Where stack development happens" says not to do. Take the upstream side and re-file the
|
||||
change as an issue there.
|
||||
- **...but you changed how *your pages* are written?** That is not a stack change and the rule
|
||||
above does not apply to it. Language, section headings, naming forms, tone, relationship
|
||||
labels and the confidence rubric live in `kb/CONVENTIONS.md`, and each collection's authoring
|
||||
rules in `kb/<name>/COLLECTION.md` - all under `kb/`, all yours, all restored by the merge
|
||||
procedure rather than overwritten by it. If you find yourself editing `tools/` or `types/` to
|
||||
change an authoring convention, that is a stack bug: file it, because the split exists
|
||||
precisely so you do not have to.
|
||||
|
||||
## Scope
|
||||
|
||||
|
||||
@@ -60,24 +60,71 @@ bereit für den ersten `Ingest`.
|
||||
- Nicht genannt: lokal bleiben - dann braucht **jeder** spätere `tools/wikitool publish`
|
||||
ein `--no-push` (dessen Branch-Prüfung dabei ohnehin entfällt, siehe Schritt 2).
|
||||
|
||||
5. **Entscheidungspunkt - KB-Sprache.** Frage den Nutzer, in welcher Sprache die Seiten unter
|
||||
`kb/` geschrieben werden sollen. Diese Instanz erbt aus dem Quell-Repo **Deutsch** - sowohl die
|
||||
Regel in [kb/CONTRACT.md](../kb/CONTRACT.md#language) als auch das Vokabular in
|
||||
[german-terminology.md](german-terminology.md) und die deutschen Abschnittsnamen in
|
||||
`tools/chemenu/sections.py`. Das ist eine Entscheidung der Ursprungsinstanz, keine
|
||||
Eigenschaft des Musters, und sie wird hier nicht stillschweigend weitergereicht.
|
||||
5. **Entscheidungspunkt - Autorenkonventionen.** Die Distribution bringt keine ausgefüllten
|
||||
Konventionen mit, sondern `kb/CONVENTIONS.md.template` und je Collection ein
|
||||
`kb/<name>/COLLECTION.md.template`. Beide **binden**, sobald sie übernommen sind, und beide
|
||||
gehören dieser Instanz - deshalb liefert der Stack nur die Vorlage. Die eine Entscheidung
|
||||
dahinter ist: **in welcher Sprache und in welchem Ton schreibt diese Instanz ihre Seiten?**
|
||||
|
||||
- **Deutsch bestätigt:** nichts zu tun.
|
||||
- **Andere Sprache:** *vor dem ersten Ingest* umstellen, denn danach ist es eine Migration
|
||||
jeder vorhandenen Seite. Zu ändern sind der Abschnitt "Language" in `kb/CONTRACT.md`, die
|
||||
Tonfall-Beispiele und Hedge-Wörter darunter, die vier Page-Type-Templates in `types/`, die
|
||||
kanonischen Namen in `sections.py` (die bisherigen als Alias behalten) und die
|
||||
Beziehungslabels in `kb/CONTRACT.md` § Linking. `german-terminology.md` wird dann ersetzt
|
||||
oder gelöscht.
|
||||
Ablauf:
|
||||
|
||||
Unverändert bleibt in jedem Fall die eigentliche Regel: **jede Zeile einer Seite ist Prosa
|
||||
oder Identifier, und nur Prosa wird übersetzt.** Titel, Wikilink-Ziele, Cite-IDs, Enum-Werte,
|
||||
Tags, Befehle und Pfade folgen keiner KB-Sprache.
|
||||
1. Die Collection-Contracts **und die Page-Type-Specs** übernehmen - Kopien, keine Frage an
|
||||
den Nutzer, denn was dort steht ist als Ausgangspunkt unabhängig von der Sprache brauchbar:
|
||||
|
||||
```bash
|
||||
for template in kb/*/COLLECTION.md.template types/*.template; do
|
||||
cp "$template" "${template%.template}"
|
||||
done
|
||||
```
|
||||
|
||||
Die `.template`-Dateien bleiben liegen; sie sind die Vorlage für den nächsten Export.
|
||||
|
||||
Unter `types/` betrifft das genau die Type-Specs mit `root: kb` - `entity`, `concept`,
|
||||
`source`, `comparison` - samt ihrer `.schema.yaml`. Sie beschreiben Seiten, die *diese*
|
||||
Instanz schreibt, also gehören sie ihr: Prosa, Template und Sprache dürfen umgeschrieben
|
||||
werden. `instruction`, `lint-report` und `type-spec` beschreiben Stack-Artefakte und
|
||||
kommen unverändert.
|
||||
|
||||
2. Den Nutzer nach der KB-Sprache fragen. `kb/CONVENTIONS.md.template` ist auf **Englisch**
|
||||
voreingestellt; [kb-profiles.md](kb-profiles.md) hält daneben ein vollständiges
|
||||
deutsches Profil bereit, und dessen Volltext ist die `kb/CONVENTIONS.md` des Quell-Repos.
|
||||
Der Profilkatalog ist eine **Palette, kein Enum**: übernommen wird der Text *in* die
|
||||
Instanzdatei, nicht ein Verweis auf den Katalog.
|
||||
|
||||
3. `kb/CONVENTIONS.md.template` nach `kb/CONVENTIONS.md` kopieren, entlang des gewählten
|
||||
Profils ausfüllen - Sprache, Abschnittsnamen, Namensformen, Ton, Beziehungslabels,
|
||||
Confidence-Rubrik - und dabei die Sentinel-Zeile (`wikitool:template-unfilled`) entfernen.
|
||||
Die Platzhalter in geschweiften Klammern **sind** der Fragenkatalog.
|
||||
|
||||
4. Bei einer anderen Sprache als der des Quell-Repos: `german-terminology.md` löschen oder
|
||||
durch das eigene Vokabular ersetzen - sie ist Material des deutschen Profils, nicht des
|
||||
Stacks.
|
||||
|
||||
**Vor dem ersten Ingest entscheiden.** Die `sections:`-Namen in `kb/CONVENTIONS.md` sind die
|
||||
Überschriften, die `xref` und `cite` in jede Seite schreiben; sie danach zu ändern ist eine
|
||||
Migration jeder vorhandenen Seite (`section_aliases:` trägt die alten Namen, siehe
|
||||
[migrate-corpus.md](migrate-corpus.md)).
|
||||
|
||||
**Nichts davon liegt in einer Stack-Datei.** Der Compiler liest die Abschnittsnamen aus
|
||||
`kb/CONVENTIONS.md`; die vier Page-Type-Specs gehören ab Schritt 1 dieser Instanz. Eine
|
||||
anderssprachige Instanz übersetzt sie einfach - das ist kein lokaler Patch an etwas
|
||||
Ausgeliefertem mehr, sondern Arbeit an den eigenen Dateien, und ein Upgrade nimmt sie ihr
|
||||
nicht wieder weg.
|
||||
|
||||
Was der Stack von `types/` überhaupt noch verlangt, ist eine Zeile: es muss einen Type-Spec
|
||||
mit `name: source` geben, dessen Schema `raw_files` fordert. Daran hängt der gesamte
|
||||
`raw/`→`kb/`-Provenance-Pfad (`sources coverage`, `[^cite-id]`-Auflösung, `kb/provenance.md`),
|
||||
und `docs verify` prüft genau das - nicht mehr.
|
||||
|
||||
Unverändert bleibt in jedem Fall die Regel, die dem Stack gehört: **jede Zeile einer Seite
|
||||
ist Prosa oder Identifier, und nur Prosa wird übersetzt** ([kb/CONTRACT.md § Language and
|
||||
identifiers](../kb/CONTRACT.md#language-and-identifiers)). Titel, Wikilink-Ziele, Cite-IDs,
|
||||
Enum-Werte, Tags, Befehle und Pfade folgen keiner KB-Sprache.
|
||||
|
||||
`tools/wikitool doctor` prüft das Ergebnis in Schritt 12 (`conventions`): eine fehlende
|
||||
Datei ist ein `FAIL`, eine mit Sentinel oder ohne vollständigen `sections:`-Block ebenso.
|
||||
`docs verify` prüft zusätzlich `profile:` und `required_by_stack:` auf jedem
|
||||
`COLLECTION.md`.
|
||||
|
||||
6. **Entscheidungspunkt - Personalization.** Die Distribution bringt
|
||||
`USER.md.template` und `SOUL.md.template` mit, aber keine ausgefüllten Fassungen: wer diese
|
||||
|
||||
@@ -61,8 +61,9 @@ pages should never have cost the concept contract. Field-level requirements alwa
|
||||
article also pass `--set source_url=<upstream URL>`; `raw_files:` must still point at the
|
||||
local copy. Then write the Summary / Key Takeaways / Action Items prose from step 4 - in the
|
||||
KB language, whatever the source's own language is, quoting verbatim passages in the
|
||||
original. The rule and what is exempt from it:
|
||||
[kb/CONTRACT.md](../../kb/CONTRACT.md#language).
|
||||
original. Which language that is: [kb/CONVENTIONS.md](../../kb/CONVENTIONS.md#language).
|
||||
What is exempt from it, in any language:
|
||||
[kb/CONTRACT.md](../../kb/CONTRACT.md#language-and-identifiers).
|
||||
|
||||
Fill `## Not Extracted` in the same pass: what you read and deliberately did not promote,
|
||||
with the reason. Nothing in the repository can re-derive that judgment, and without it the
|
||||
@@ -70,8 +71,9 @@ pages should never have cost the concept contract. Field-level requirements alwa
|
||||
|
||||
6. **Create or update entity pages.** Read
|
||||
[kb/entities/COLLECTION.md](../../kb/entities/COLLECTION.md) and
|
||||
[kb/CONTRACT.md](../../kb/CONTRACT.md) first - the second is where tone, naming, provenance
|
||||
and citation are defined.
|
||||
[kb/CONTRACT.md](../../kb/CONTRACT.md) plus
|
||||
[kb/CONVENTIONS.md](../../kb/CONVENTIONS.md) first - the second is where provenance and
|
||||
citation are defined, the third where this instance's tone and naming forms are.
|
||||
|
||||
New:
|
||||
|
||||
|
||||
@@ -13,10 +13,12 @@ integrating into an existing one.
|
||||
|
||||
**Before the first `wikitool` call:** [session-setup.md](../session-setup.md).
|
||||
|
||||
**Read before drafting:** [kb/CONTRACT.md](../../kb/CONTRACT.md) - naming, tone, linking,
|
||||
provenance and confidence - together with the target collection's own `COLLECTION.md`, which
|
||||
carries its quality goal and what is local to that subtree. Field-level requirements come from
|
||||
`tools/wikitool types describe <type>`.
|
||||
**Read before drafting:** [kb/CONTRACT.md](../../kb/CONTRACT.md) - linking, provenance and the
|
||||
confidence machinery, all of which the tool enforces - and
|
||||
[kb/CONVENTIONS.md](../../kb/CONVENTIONS.md), which is where this instance's language, naming
|
||||
forms, tone and relationship labels are, together with the target collection's own
|
||||
`COLLECTION.md`, which carries its quality goal and what is local to that subtree. Field-level
|
||||
requirements come from `tools/wikitool types describe <type>`.
|
||||
|
||||
## Creating a page
|
||||
|
||||
@@ -46,7 +48,7 @@ carries its quality goal and what is local to that subtree. Field-level requirem
|
||||
subjects - so the prose connects to existing pages instead of restating them.
|
||||
|
||||
5. **Draft.** Fill in the generated skeleton's TODO sections, following the tone rules in
|
||||
[kb/CONTRACT.md](../../kb/CONTRACT.md#tone). If `provenance:` is `sourced` or `mixed`, cite
|
||||
[kb/CONVENTIONS.md](../../kb/CONVENTIONS.md#tone). If `provenance:` is `sourced` or `mixed`, cite
|
||||
hard facts as you write them with `tools/wikitool cite add --page "<Title>" --source
|
||||
"Source - X"`, which also adds `X` to `sources:` - paste the `[^cite-id]` marker it prints.
|
||||
|
||||
|
||||
+110
-82
@@ -7,12 +7,24 @@ material in `raw/`, and is expected to stay correct without being re-derived.
|
||||
**Quality goal:** a page should answer a future question *without* re-reading the source it
|
||||
came from. If answering still requires the raw file, the page is incomplete.
|
||||
|
||||
This file holds the rules that apply in **every** collection. Each `kb/<name>/COLLECTION.md`
|
||||
declares that it inherits them and adds only what is local to its own subtree - read this file
|
||||
together with the target collection's contract before writing or editing a page.
|
||||
This file holds the rules that apply in **every** collection **and in every instance**. That
|
||||
second half is the cut: what is written here is enforced by `tools/wikitool` or follows from
|
||||
how it works, so it is identical everywhere and `dist export` ships it verbatim.
|
||||
|
||||
**What an instance decides for itself is next door, in
|
||||
[kb/CONVENTIONS.md](CONVENTIONS.md)** - the language pages are written in, the headings its two
|
||||
generated regions render under, the naming forms, the tone, the confidence rubric. That file binds exactly as this one does; it is simply owned by the instance
|
||||
rather than by the stack, so the distribution ships only its `.template` and the instance writes
|
||||
the real one. Read both, plus the target collection's `kb/<name>/COLLECTION.md` (also
|
||||
instance-owned), before writing or editing a page.
|
||||
|
||||
The split is by **who may change the sentence**, not by what it is about. Language, tone and
|
||||
naming used to sit here, which meant every instance that answered "not German" to
|
||||
`setup-instance.md` was locally editing a file the stack also ships - and a merge from upstream
|
||||
would quietly hand it back.
|
||||
|
||||
Structural facts (which frontmatter fields exist, which are required, what the body skeleton
|
||||
looks like) are *not* here - they belong to the type-specs and are printed by
|
||||
looks like) are in neither - they belong to the type-specs and are printed by
|
||||
`tools/wikitool types describe <type>`. Never hand-write frontmatter; scaffold with
|
||||
`tools/wikitool new <type> --name "<Name>" --set field=value ...`.
|
||||
|
||||
@@ -21,7 +33,15 @@ looks like) are *not* here - they belong to the type-specs and are printed by
|
||||
`kb/` is a **namespace, not a collection**. It carries no `COLLECTION.md` of its own.
|
||||
|
||||
A directory under `kb/` is a **collection** exactly when it contains a `COLLECTION.md`. That
|
||||
file is the local authoring contract for every page in the subtree.
|
||||
file is the local authoring contract for every page in the subtree, and it belongs to the
|
||||
instance: it declares in its frontmatter which profile from
|
||||
[instructions/kb-profiles.md](../instructions/kb-profiles.md) it adopted, and whether the stack
|
||||
resolves against it by name.
|
||||
|
||||
| Field | Means |
|
||||
|---|---|
|
||||
| `profile:` | Which catalogue entry this contract started from, or `none`. Free text - the catalogue is a palette, not an enum, and a collection an instance invented has no entry to name |
|
||||
| `required_by_stack:` | Whether `wikitool` itself depends on this collection *by name*. Not the instance's to choose: `docs verify` checks it against the stack's own list. `kb/sources/` is `true` - `sources coverage`, `[^cite-id]` resolution and `kb/provenance.md` all resolve against that name - and everything else is `false` |
|
||||
|
||||
- A subdirectory *inside* a collection is an **area**. It inherits the enclosing contract and
|
||||
must not carry a `COLLECTION.md` of its own - `kb/entities/systems/` is an area of
|
||||
@@ -40,9 +60,11 @@ file is the local authoring contract for every page in the subtree.
|
||||
| `kb/sources/` | One summary page per ingested source, carrying its `raw_files:` provenance | [sources/COLLECTION.md](sources/COLLECTION.md) |
|
||||
| `kb/comparisons/` | Structured comparisons of two or more existing pages | [comparisons/COLLECTION.md](comparisons/COLLECTION.md) |
|
||||
|
||||
**Adding a collection:** `mkdir kb/<name>` and write a `kb/<name>/COLLECTION.md`. Collections
|
||||
The four rows above are this instance's collections, not a fixed set. **Adding one:**
|
||||
`mkdir kb/<name>` and write a `kb/<name>/COLLECTION.md` with the two fields above. Collections
|
||||
are discovered by contract presence, so no code change is needed. A collection only becomes
|
||||
*writable* once some type-spec declares a matching `base_dir:`.
|
||||
*writable* once some type-spec declares a matching `base_dir:`. Renaming or dropping one is the
|
||||
instance's call too - except where `required_by_stack: true` says otherwise.
|
||||
|
||||
**Where a page goes** is decided by its type-spec, never by hand - see
|
||||
[types/type-spec.md](../types/type-spec.md).
|
||||
@@ -61,53 +83,41 @@ Never hand-edit these; they are produced by `tools/wikitool`:
|
||||
To *find* a page, search rather than read the catalog: `tools/wikitool search "<text>"`, or
|
||||
`tools/wikitool search --field <predicate>` for a structured query over frontmatter.
|
||||
|
||||
## Naming
|
||||
## Titles are identifiers
|
||||
|
||||
- Human-readable titles with spaces: `Hybrid Search.md`, `Gitea Actions.md` - not kebab-case.
|
||||
- Singular for entities: `ha-core.md`, not `ha-cores.md`.
|
||||
- Comparison pages read as a comparison: `Go vs Rust.md`.
|
||||
- ADRs are prefixed: `adr-001-use-go-modules.md`.
|
||||
- The filename stem *is* the page title, and `[[wikilinks]]` must match it exactly.
|
||||
- Prefer readability over convention when the two conflict.
|
||||
**The filename stem *is* the page title, and `[[wikilinks]]` must match it exactly.** That is
|
||||
not a naming preference; it is the wiki's only way to address a page. `wikitool lint` reports an
|
||||
H1 that stops matching its title, `rename`/`rm` rewrite every reference to a stem, and a
|
||||
`[^cite-id]` resolves through one.
|
||||
|
||||
What to name a thing: projects use their repository or common name; systems a descriptive
|
||||
name; tools the tool's own name; technologies their standard spelling and capitalization;
|
||||
people a full name or common handle.
|
||||
Which *form* those titles take - spaces or kebab-case, singular or plural, what prefixes a
|
||||
decision record - is the instance's, in
|
||||
[kb/CONVENTIONS.md § Naming](CONVENTIONS.md#naming).
|
||||
|
||||
## Every page should
|
||||
|
||||
- [ ] Carry a clear, descriptive title and a summary near the top
|
||||
- [ ] Use consistent terminology with the rest of the wiki
|
||||
- [ ] Link to every entity and concept it mentions, and be linked to in return
|
||||
- [ ] Link to the entities and concepts it mentions, and declare an edge where the relationship
|
||||
is worth naming - in the direction this page asserts it, not in both
|
||||
- [ ] Cite its hard facts (see [Provenance and citation](#provenance-and-citation))
|
||||
- [ ] Duplicate no existing page
|
||||
- [ ] Appear in the catalog (guaranteed by `wikitool index rebuild`)
|
||||
|
||||
## Tone
|
||||
## Quotation cap
|
||||
|
||||
Wikipedia style: factual, neutral, specific.
|
||||
At most 2 blockquoted lines per page. `wikitool lint` reports overages as advisory, since
|
||||
exceeding the cap can be a legitimate judgment call - but the page should carry the knowledge
|
||||
itself, not delegate it to quotations. The cap is about how much of the page you let quotes
|
||||
carry; it does not apply to text you are citing verbatim from a source.
|
||||
|
||||
- No buzzwords ("bahnbrechend", "hochmodern", "leistungsstark", "revolutioniert").
|
||||
- No AI filler ("es sei angemerkt", "es ist wichtig zu betonen", "in der heutigen Zeit").
|
||||
- No em-dash asides carrying parenthetical reasoning.
|
||||
- At most 2 blockquoted lines per page. `wikitool lint` reports overages as advisory, since
|
||||
exceeding the cap can be a legitimate judgment call - but the page should carry the
|
||||
knowledge itself, not delegate it to quotations. The cap is about how much of the page you
|
||||
let quotes carry; it does not apply to text you are citing verbatim from a source.
|
||||
The register those lines are written in - what counts as a buzzword, what filler is refused -
|
||||
is the instance's, in [kb/CONVENTIONS.md § Tone](CONVENTIONS.md#tone).
|
||||
|
||||
Good: "MQTT ist ein leichtgewichtiges Publish-Subscribe-Protokoll für Geräte mit knappen
|
||||
Ressourcen."
|
||||
## Language and identifiers
|
||||
|
||||
Bad: "MQTT ist ein bahnbrechendes, hochmodernes Protokoll, das die IoT-Kommunikation
|
||||
revolutioniert - und es sei angemerkt, dass es ein Publish-Subscribe-Muster verwendet."
|
||||
|
||||
## Language
|
||||
|
||||
Pages are written in **German**. This binds `kb/` and the authoring surface that shapes it -
|
||||
the page type-specs `types/entity.md`, `types/concept.md`, `types/source.md` and
|
||||
`types/comparison.md`. `raw/` is untouched ([raw/CONTRACT.md](../raw/CONTRACT.md)), and the
|
||||
control plane stays English: AGENTS.md, the stage contracts including this one, `instructions/`,
|
||||
and the type-specs for non-page artifacts.
|
||||
*Which* language pages are written in is [kb/CONVENTIONS.md](CONVENTIONS.md)'s to say. What
|
||||
follows here is the part that is not a choice, because the tool resolves against it.
|
||||
|
||||
Every line of a page is either **prose** or an **identifier**. Only prose is translated.
|
||||
|
||||
@@ -118,55 +128,75 @@ source page's Summary / Key Takeaways / Action Items / Not Extracted, and `summa
|
||||
|
||||
| Identifier | Why |
|
||||
|---|---|
|
||||
| Page titles, and the H1 that repeats one | A title is the wiki's only identifier for a page and follows the subject's own established name - see [Naming](#naming). `wikitool lint` reports an H1 that stops matching its title |
|
||||
| Page titles, and the H1 that repeats one | A title is the wiki's only identifier for a page and follows the subject's own established name - see [Titles are identifiers](#titles-are-identifiers). `wikitool lint` reports an H1 that stops matching its title |
|
||||
| The subtype value on the generated `**Typ:**` line | It renders a schema enum value (`technology`, `workflow`), which `search --field` filters on. The label is prose; the value is not |
|
||||
| `tags:` | Search keys, not prose |
|
||||
| Commands, paths, config keys, hostnames, code | They are what they are |
|
||||
| Quotations | Quoted verbatim in the source's own language |
|
||||
|
||||
Established English technical terms stay English inside German prose - "GitOps", "Ownership
|
||||
Model", "Reverse Proxy", "Pull Request". Translate a term only where the German one is genuinely
|
||||
the more common usage. A coined German equivalent nobody else writes makes the page harder to
|
||||
find, not more idiomatic.
|
||||
|
||||
Which terms those are, which have a settled German form, and the register the prose is written in:
|
||||
[instructions/german-terminology.md](../instructions/german-terminology.md). It is lookup material,
|
||||
not a second rule - every entry in it is a decision that was made wrong once first.
|
||||
Which foreign technical terms stay untranslated inside that prose is a judgment call the
|
||||
instance records - see [kb/CONVENTIONS.md § Language](CONVENTIONS.md#language).
|
||||
|
||||
**A source in another language** is still summarized in the KB language: a source page is
|
||||
evidence *about* a source, not a substitute for it. Quote verbatim in the original language and
|
||||
record the raw file's language in `source_language:`.
|
||||
|
||||
### Section headings
|
||||
### Generated regions
|
||||
|
||||
Three headings are a vocabulary the tool owns rather than prose an author picks: `xref add`
|
||||
writes into Relationships and See Also, and `cite add` owns the trailing Footnotes block. They
|
||||
follow the KB language like everything else - `## Beziehungen`, `## Siehe auch`, `## Fußnoten` -
|
||||
and `tools/chemenu/sections.py` is the single place naming them.
|
||||
Two regions of a page body are **generated**, not authored: the links region `xref` owns and the
|
||||
footnotes region `cite` owns. Each sits between a marker pair:
|
||||
|
||||
Each has aliases the tool still *recognizes* but no longer writes, which is what lets the corpus
|
||||
be translated page by page: a page still carrying `## Relationships` is found and appended to
|
||||
correctly, and `cite sync` leaves an untranslated `## Footnotes` heading alone rather than
|
||||
retitling it. Renaming a heading is the translation pass's job, never a side effect of another
|
||||
command. Any *other* heading an author adds is ordinary prose and is translated with the rest.
|
||||
```markdown
|
||||
<!-- wikitool:links -->
|
||||
## Beziehungen
|
||||
|
||||
- **depends-on:** [[Hermes]]
|
||||
<!-- /wikitool:links -->
|
||||
```
|
||||
|
||||
The marker is what the tool locates the region by, and everything between the markers -
|
||||
**heading included** - is replaced wholesale on the next write. An author never edits inside
|
||||
them; anything left there is overwritten without warning, exactly as in `kb/index.md`. A region
|
||||
with nothing to show is absent rather than empty.
|
||||
|
||||
The heading is therefore a *rendering* value, taken from `kb/CONVENTIONS.md`'s `sections:`. No
|
||||
heading text exists in the compiler, and nothing matches on it: changing the declaration
|
||||
re-renders the words on the next write and cannot split a page.
|
||||
|
||||
That is not how it used to work. The tool located these regions by matching their heading text,
|
||||
which made a translated heading a structural fact - and made the region's *end* a guess. It ran
|
||||
to the next heading, and before that to the end of the file, which silently deleted whatever sat
|
||||
after it on eight pages. Any *other* heading a page carries is ordinary prose.
|
||||
|
||||
## Linking
|
||||
|
||||
Every page links to what it mentions, in both directions. Cross-references are created with
|
||||
`tools/wikitool xref add --a "<A>" --b "<B>" --rel-a "<label>" --rel-b "<label>"`, never by
|
||||
hand-editing the `related:` array or the Relationships/See Also bullets.
|
||||
**An edge is authored in one direction**, on the page that asserts it, and carries a label that
|
||||
is a machine value rather than prose:
|
||||
|
||||
Use a typed relationship label rather than a generic one:
|
||||
```yaml
|
||||
related:
|
||||
- depends-on: Hermes
|
||||
```
|
||||
|
||||
`hängt ab von` · `verwendet` · `implementiert` · `erweitert` · `ersetzt` · `steht in Konflikt mit`
|
||||
· `benötigt` · `erzeugt` · `konsumiert` · `besitzt` · `pflegt` · `läuft auf` · `verwandt mit`
|
||||
(last resort)
|
||||
Created with `tools/wikitool xref add --a "<A>" --b "<B>" --rel <label>`, never by hand-editing
|
||||
`related:` or the rendered bullet. Say the sentence before choosing the label - `[A] <label>
|
||||
[B]` - and if it only reads true backwards, the edge belongs on the other page.
|
||||
|
||||
The labels are prose written into a `- **label:** [[Title]]` bullet; no code matches on them, so
|
||||
an untranslated page's English label is stale wording, not a broken reference.
|
||||
**A reverse edge is a separate decision, not a mirror.** Write one when it independently helps a
|
||||
reader at the other end; do not write one to make the graph symmetric. Navigation does not
|
||||
depend on it either way: `index rebuild` renders the inbound view from the graph, completely and
|
||||
without maintenance.
|
||||
|
||||
A page is expected to have at least one inbound link; `wikitool lint` reports orphans.
|
||||
Comparison pages are exempt - they are reached through the catalog.
|
||||
Which labels exist is [instructions/link-taxonomy.md](../instructions/link-taxonomy.md), a
|
||||
palette that binds nothing. Which of them a page may *use* is its own collection's `outbound:`
|
||||
block, per destination - the **source** collection decides, because the rules that govern an
|
||||
edge are the rules of the collection asserting it. `xref add` refuses an unauthorised label and
|
||||
`lint` reports one.
|
||||
|
||||
A page is expected to have at least one inbound edge; `wikitool lint` reports orphans.
|
||||
Comparison pages are exempt - they are reached through the catalog. Directional edges mean more
|
||||
pages qualify than under the old mirrored model, and that is the check measuring reachability
|
||||
rather than measuring whether `xref` ran.
|
||||
|
||||
Renaming a page, deleting one, or dropping a single reference are tool operations with their
|
||||
own procedure: see [instructions/page-lifecycle.md](../instructions/page-lifecycle.md).
|
||||
@@ -187,15 +217,16 @@ Every claim is either traceable to a raw file or explicitly marked as not.
|
||||
command, or config value. `tools/wikitool cite add --page "<Title>" --source "Source - X"
|
||||
[--file <qualifier>]` mints the id, upserts its `[[Source - X]]` (or
|
||||
`[[Source - X|storage-model.md]]` for a multi-file source) definition in the page's trailing
|
||||
`## Footnotes` block, and adds `Source - X` to `sources:` - it prints the marker to paste at
|
||||
Footnotes block (named per [Section headings](#section-headings)), and adds `Source - X` to
|
||||
`sources:` - it prints the marker to paste at
|
||||
the fact; placing it is still manual. Never hand-type a cite-id (AGENTS.md invariant 1). This
|
||||
differs from a plain `[[Source - X]]` link, which only means "related to".
|
||||
- **Notation inside code is notation, not a reference.** A `[^cite-id]` or a `[[wikilink]]`
|
||||
written in backticks or a fenced block is read as an example: the citation does not count and
|
||||
the link does not exist. That is what lets a page document this stack's own syntax. It also
|
||||
means a marker appended to a line *inside* a fence cites nothing - put it on a
|
||||
`Quelle: [^cite-id]` line under the block, where it renders as a footnote instead of
|
||||
travelling with the command when someone copies it.
|
||||
means a marker appended to a line *inside* a fence cites nothing - put it on a source line
|
||||
under the block (`<source-word>: [^cite-id]`, in the KB language), where it renders as a
|
||||
footnote instead of travelling with the command when someone copies it.
|
||||
- A source cited inline must also appear in the page's frontmatter `sources:` list;
|
||||
`wikitool lint` checks this in both directions, and hard-errors on a leftover pre-migration
|
||||
`^[[...]]` marker, an undefined `[^cite-id]` reference, or an orphaned Footnotes definition.
|
||||
@@ -215,23 +246,20 @@ one - and never file the synthesized version back into the wiki.
|
||||
`confidence` is *derived* from it by `tools/wikitool confidence decay` and must never be
|
||||
edited directly.
|
||||
|
||||
Base score for a single source is 0.5, adjusted by:
|
||||
|
||||
- **+0.2 per supporting source** (max +0.6)
|
||||
- **+0.2** if confirmed <30 days ago, **+0.1** if <90 days
|
||||
- **+0.1** for official documentation, **+0.05** for a reputable secondary source
|
||||
- **+0.1** if multiple independent sources agree
|
||||
|
||||
Re-assess a page with `tools/wikitool touch --page "<Title>" --confidence-base <value>`.
|
||||
|
||||
In prose, hedge according to the score: below 0.6 write "möglicherweise"/"kann"; below 0.4
|
||||
write "unsicher"/"unbestätigt".
|
||||
What the number *means* - the base score, what raises it and by how much, and how to hedge in
|
||||
prose below a threshold - is a rubric rather than a mechanism, so it is
|
||||
[kb/CONVENTIONS.md § Confidence rubric](CONVENTIONS.md#confidence-rubric)'s.
|
||||
|
||||
## What does not belong here
|
||||
|
||||
- Raw source material - it stays immutable under `raw/`.
|
||||
- Type definitions, frontmatter contracts, or templates - those live in `types/`.
|
||||
- Procedures for operating the tooling - those live in `instructions/`.
|
||||
- **Anything an instance would have to rewrite for itself** - language, naming forms, tone,
|
||||
relationship labels, the confidence rubric. Those are `kb/CONVENTIONS.md`'s, and a sentence
|
||||
of that kind here is a sentence the stack ships over the instance's own answer.
|
||||
- Rules that apply to only one collection - those belong in that collection's
|
||||
`COLLECTION.md`.
|
||||
- Hand-edited generated files - see [Generated files](#generated-files).
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
language: de
|
||||
profile: german
|
||||
sections:
|
||||
links: Beziehungen
|
||||
footnotes: Fußnoten
|
||||
---
|
||||
|
||||
# kb/ - Authoring Conventions of This Instance
|
||||
|
||||
The decisions [kb/CONTRACT.md](CONTRACT.md) deliberately does not make. The contract holds what
|
||||
the code enforces and is identical in every instance; this file holds what *this* instance
|
||||
chose, and no other instance has to agree with a word of it.
|
||||
|
||||
**It binds all the same.** Everything below applies to every page under `kb/`, exactly as the
|
||||
contract does. The difference is ownership, not authority: a rule here is changed by editing
|
||||
this file, a rule there by changing the stack.
|
||||
|
||||
Adopted from the `german` profile in
|
||||
[instructions/kb-profiles.md](../instructions/kb-profiles.md). That catalogue is a palette, not
|
||||
an enum - what is written here is what holds, whether or not a profile says the same thing.
|
||||
|
||||
The frontmatter above is the one machine-read part. `sections:` names the headings the two
|
||||
**generated regions** render under - the links region `wikitool xref` owns and the footnotes
|
||||
region `wikitool cite` owns. Each sits between a marker pair, and the marker is what the tool
|
||||
locates it by, so the heading here is a display value: changing it re-renders the words above
|
||||
those regions and nothing else. Nothing matches on this text.
|
||||
|
||||
## Language
|
||||
|
||||
Pages are written in **German**. This binds `kb/` and the authoring surface that shapes it -
|
||||
the page type-specs `types/entity.md`, `types/concept.md`, `types/source.md` and
|
||||
`types/comparison.md`. `raw/` is untouched ([raw/CONTRACT.md](../raw/CONTRACT.md)), and the
|
||||
control plane stays English: `AGENTS.md`, the stage contracts, this file, `instructions/`, and
|
||||
the type-specs for non-page artifacts.
|
||||
|
||||
Which line is prose and which is an identifier - and therefore what is translated at all - is
|
||||
the contract's rule, not this file's: see
|
||||
[kb/CONTRACT.md § Language and identifiers](CONTRACT.md#language-and-identifiers).
|
||||
|
||||
Established English technical terms stay English inside German prose - "GitOps", "Ownership
|
||||
Model", "Reverse Proxy", "Pull Request". Translate a term only where the German one is genuinely
|
||||
the more common usage. A coined German equivalent nobody else writes makes the page harder to
|
||||
find, not more idiomatic.
|
||||
|
||||
Which terms those are, which have a settled German form, and the register the prose is written
|
||||
in: [instructions/german-terminology.md](../instructions/german-terminology.md). It is lookup
|
||||
material, not a second rule - every entry in it is a decision that was made wrong once first.
|
||||
|
||||
### Section headings
|
||||
|
||||
The two generated regions render under `## Beziehungen` and `## Fußnoten`. An author never
|
||||
writes inside them - they are rebuilt from frontmatter on every write, exactly like
|
||||
`kb/index.md` - and never has to write the heading either. Any *other* heading on a page is
|
||||
ordinary prose and is translated with the rest.
|
||||
|
||||
There is no `## Siehe auch` region any more. It was the reciprocal half of a bidirectional
|
||||
`xref add`; under authored directional edges, `see-also` is a *label* inside the links region.
|
||||
|
||||
## Naming
|
||||
|
||||
- Human-readable titles with spaces: `Hybrid Search.md`, `Gitea Actions.md` - not kebab-case.
|
||||
- Singular for entities: `ha-core.md`, not `ha-cores.md`.
|
||||
- Comparison pages read as a comparison: `Go vs Rust.md`.
|
||||
- ADRs are prefixed: `adr-001-use-go-modules.md`.
|
||||
- Prefer readability over convention when the two conflict.
|
||||
|
||||
What to name a thing: projects use their repository or common name; systems a descriptive
|
||||
name; tools the tool's own name; technologies their standard spelling and capitalization;
|
||||
people a full name or common handle.
|
||||
|
||||
The one naming fact that is *not* a choice, and therefore lives in the contract: the filename
|
||||
stem is the page title, and `[[wikilinks]]` must match it exactly.
|
||||
|
||||
## Tone
|
||||
|
||||
Wikipedia style: factual, neutral, specific.
|
||||
|
||||
- No buzzwords ("bahnbrechend", "hochmodern", "leistungsstark", "revolutioniert").
|
||||
- No AI filler ("es sei angemerkt", "es ist wichtig zu betonen", "in der heutigen Zeit").
|
||||
- No em-dash asides carrying parenthetical reasoning.
|
||||
|
||||
Good: "MQTT ist ein leichtgewichtiges Publish-Subscribe-Protokoll für Geräte mit knappen
|
||||
Ressourcen."
|
||||
|
||||
Bad: "MQTT ist ein bahnbrechendes, hochmodernes Protokoll, das die IoT-Kommunikation
|
||||
revolutioniert - und es sei angemerkt, dass es ein Publish-Subscribe-Muster verwendet."
|
||||
|
||||
The blockquote cap is not here: `wikitool lint` reports it, so it is the contract's.
|
||||
|
||||
## Relationship labels
|
||||
|
||||
**Not this file's to list, and not localized.** A label is a machine value in `related:`, drawn
|
||||
from [instructions/link-taxonomy.md](../instructions/link-taxonomy.md) and authorised per
|
||||
destination in each `kb/<name>/COLLECTION.md`'s `outbound:` block. `- **depends-on:** [[Hermes]]`
|
||||
is what a German page carries, and that is deliberate: the label is an identifier, so translating
|
||||
it would make the graph's semantics depend on the prose again.
|
||||
|
||||
## Confidence rubric
|
||||
|
||||
`confidence_base` is set by hand and `confidence` is derived from it - that mechanism is the
|
||||
contract's. What the number *means* is this instance's:
|
||||
|
||||
Base score for a single source is 0.5, adjusted by:
|
||||
|
||||
- **+0.2 per supporting source** (max +0.6)
|
||||
- **+0.2** if confirmed <30 days ago, **+0.1** if <90 days
|
||||
- **+0.1** for official documentation, **+0.05** for a reputable secondary source
|
||||
- **+0.1** if multiple independent sources agree
|
||||
|
||||
In prose, hedge according to the score: below 0.6 write "möglicherweise"/"kann"; below 0.4
|
||||
write "unsicher"/"unbestätigt".
|
||||
|
||||
## Keeping this file honest
|
||||
|
||||
Change it when a convention actually changes. `sections:` is safe to change at any time - the
|
||||
regions are located by their markers and re-rendered under the new words on the next write.
|
||||
`wikitool doctor` FAILs on a missing or unfilled file, and `wikitool docs verify` refuses a
|
||||
`sections:` block that does not name both regions.
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
# wikitool:template-unfilled - delete this line once the file is answered.
|
||||
language: en
|
||||
profile: none
|
||||
sections:
|
||||
links: Relationships
|
||||
footnotes: Footnotes
|
||||
---
|
||||
|
||||
# kb/ - Authoring Conventions of This Instance
|
||||
|
||||
The decisions [kb/CONTRACT.md](CONTRACT.md) deliberately does not make. The contract holds what
|
||||
the code enforces and is identical in every instance; this file holds what *this* instance
|
||||
chooses, and no other instance has to agree with a word of it.
|
||||
|
||||
**It binds all the same.** Everything below applies to every page under `kb/`, exactly as the
|
||||
contract does. The difference is ownership, not authority: a rule here is changed by editing
|
||||
this file, a rule there by changing the stack.
|
||||
|
||||
Ready-made answers to every section below - including a complete German profile - are in
|
||||
[instructions/kb-profiles.md](../instructions/kb-profiles.md). That catalogue is a palette, not
|
||||
an enum: adopt an entry, adapt it, or write your own. What is written *here* is what holds.
|
||||
|
||||
The frontmatter above is the one machine-read part. `sections:` names the headings the two
|
||||
generated regions render under. Safe to change at any time - each region is located by its
|
||||
marker pair, so a rename re-renders words and nothing else.
|
||||
|
||||
## Language
|
||||
|
||||
Pages are written in **{language}**. This binds `kb/` and the authoring surface that shapes it -
|
||||
the page type-specs `types/entity.md`, `types/concept.md`, `types/source.md` and
|
||||
`types/comparison.md`, whose `## Template` blocks are the body skeleton every new page starts
|
||||
from. `raw/` is untouched ([raw/CONTRACT.md](../raw/CONTRACT.md)), and the control plane stays
|
||||
English: `AGENTS.md`, the stage contracts, this file, `instructions/`, and the type-specs for
|
||||
non-page artifacts.
|
||||
|
||||
Which line is prose and which is an identifier - and therefore what is translated at all - is
|
||||
the contract's rule, not this file's: see
|
||||
[kb/CONTRACT.md § Language and identifiers](CONTRACT.md#language-and-identifiers).
|
||||
|
||||
{Which established foreign-language technical terms stay untranslated inside this instance's
|
||||
prose, and where the vocabulary for that is looked up. Delete this paragraph if the KB language
|
||||
is the one those terms are already in.}
|
||||
|
||||
### Section headings
|
||||
|
||||
The two generated regions render under the frontmatter's headings. An author never writes inside
|
||||
them - they are rebuilt from frontmatter on every write. Any *other* heading is ordinary prose.
|
||||
|
||||
## Naming
|
||||
|
||||
- {Title form - words and spaces, or kebab-case, or the subject's own spelling.}
|
||||
- {Singular or plural for entities.}
|
||||
- {How a comparison page's title reads.}
|
||||
- {The ADR prefix, if this instance files decisions as pages.}
|
||||
- {What to name a thing: projects, systems, tools, technologies, people.}
|
||||
|
||||
The one naming fact that is *not* a choice, and therefore lives in the contract: the filename
|
||||
stem is the page title, and `[[wikilinks]]` must match it exactly.
|
||||
|
||||
## Tone
|
||||
|
||||
{The register pages are written in, in one line.}
|
||||
|
||||
- {Words and constructions this instance refuses, with examples in the KB language.}
|
||||
|
||||
Good: {one sentence that is what this instance wants.}
|
||||
|
||||
Bad: {the same sentence written the way it must not be.}
|
||||
|
||||
## Relationship labels
|
||||
|
||||
**Not this file's to list, and not localized.** A label is a machine value in `related:`, drawn
|
||||
from [instructions/link-taxonomy.md](../instructions/link-taxonomy.md) and authorised per
|
||||
destination in each `kb/<name>/COLLECTION.md`'s `outbound:` block.
|
||||
|
||||
## Confidence rubric
|
||||
|
||||
`confidence_base` is set by hand and `confidence` is derived from it - that mechanism is the
|
||||
contract's. What the number *means* is this instance's:
|
||||
|
||||
{the base score, what raises it, and by how much}
|
||||
|
||||
{How to hedge in prose at a low score, in the KB language.}
|
||||
|
||||
## Keeping this file honest
|
||||
|
||||
Change it when a convention actually changes. `wikitool doctor` FAILs on a missing or unfilled
|
||||
file, and `wikitool docs verify` refuses a `sections:` block that does not name both regions.
|
||||
@@ -1,3 +1,10 @@
|
||||
---
|
||||
profile: comparisons
|
||||
outbound:
|
||||
any: [compares-with, contrasts, see-also]
|
||||
required_by_stack: false
|
||||
---
|
||||
|
||||
# kb/comparisons/ - Collection Contract
|
||||
|
||||
Structured head-to-head evaluations of two or more things that already have pages here. A
|
||||
@@ -7,8 +14,10 @@ comparison exists so that neither subject's own page has to argue against the ot
|
||||
That needs named, checkable dimensions and a stated trade-off; a page that lists differences
|
||||
without saying what they cost has described, not compared.
|
||||
|
||||
Inherits [kb/CONTRACT.md](../CONTRACT.md) - naming, tone, linking, provenance and confidence
|
||||
are defined there and are not restated here.
|
||||
Inherits [kb/CONTRACT.md](../CONTRACT.md) for the rules the stack enforces - linking mechanics,
|
||||
provenance, citation, the confidence machinery - and
|
||||
[kb/CONVENTIONS.md](../CONVENTIONS.md) for what this instance decided: language, naming forms,
|
||||
tone, relationship labels, the confidence rubric. Neither is restated here.
|
||||
|
||||
## Types offered
|
||||
|
||||
@@ -16,8 +25,9 @@ are defined there and are not restated here.
|
||||
|
||||
## Naming
|
||||
|
||||
The title reads as a comparison: `Go vs Rust.md`, `Traefik vs nginx.md`. Order the subjects as
|
||||
they are most commonly spoken, not alphabetically.
|
||||
The title form is [kb/CONVENTIONS.md § Naming](../CONVENTIONS.md#naming)'s. What is local here
|
||||
is the ordering: name the subjects as they are most commonly spoken together, not
|
||||
alphabetically.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -29,11 +39,22 @@ they are most commonly spoken, not alphabetically.
|
||||
- State the trade-off, not a winner. Where a recommendation is genuinely warranted, scope it:
|
||||
"for X workload", not "better".
|
||||
|
||||
## Authorised labels
|
||||
|
||||
The `outbound:` block above is what `wikitool lint` and `xref add` check: which labels a page in
|
||||
this collection may use, per destination. The catalogue they are drawn from - and what each one
|
||||
asserts - is [instructions/link-taxonomy.md](../../instructions/link-taxonomy.md), which binds
|
||||
nothing on its own.
|
||||
|
||||
Narrow for the opposite reason: a comparison's substance is its table, and its links to the compared subjects are the one relationship it asserts.
|
||||
|
||||
Adding a label here is a deliberate contract change, not a way around a refusal.
|
||||
|
||||
## Outbound linking
|
||||
|
||||
A comparison links to every subject with `related to`, and each subject links back. Comparison
|
||||
pages are **exempt from the orphan check** - they are reached through `index.md` rather than
|
||||
through inbound prose links.
|
||||
A comparison links to every subject with `compares-with`. The subjects do not have to link back:
|
||||
a comparison is reached through the catalog, and each subject's inbound view renders the edge
|
||||
anyway. Comparison pages are **exempt from the orphan check** for the same reason.
|
||||
|
||||
## What does not belong here
|
||||
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
---
|
||||
profile: concepts
|
||||
outbound:
|
||||
concepts: [extends, grounds, rests-on, enables, precondition, exemplifies, abstracted-from, contrasts, compares-with, contradicts, composition, part-of, supersedes, derived-from, adapted-from, see-also]
|
||||
entities: [operationalized-from, mechanism, procedure, applies-when, operates-on, invokes, exemplifies, see-also]
|
||||
sources: [evidenced-by, derived-from, adapted-from, defined-in, see-also]
|
||||
comparisons: [compares-with, see-also]
|
||||
required_by_stack: false
|
||||
---
|
||||
|
||||
# kb/concepts/ - Collection Contract
|
||||
|
||||
Ideas rather than things: architectures, patterns, protocols, workflows, recurring problems,
|
||||
@@ -8,8 +18,10 @@ records *what*.
|
||||
without the reader having to open the entity pages that use it. If the explanation only makes
|
||||
sense once you already know the system, it is on the wrong page.
|
||||
|
||||
Inherits [kb/CONTRACT.md](../CONTRACT.md) - naming, tone, linking, provenance and confidence
|
||||
are defined there and are not restated here.
|
||||
Inherits [kb/CONTRACT.md](../CONTRACT.md) for the rules the stack enforces - linking mechanics,
|
||||
provenance, citation, the confidence machinery - and
|
||||
[kb/CONVENTIONS.md](../CONVENTIONS.md) for what this instance decided: language, naming forms,
|
||||
tone, relationship labels, the confidence rubric. Neither is restated here.
|
||||
|
||||
## Types offered
|
||||
|
||||
@@ -17,8 +29,8 @@ are defined there and are not restated here.
|
||||
|
||||
## Decisions and ADRs
|
||||
|
||||
An architectural decision is a concept page prefixed `adr-NNN-`, e.g.
|
||||
`adr-001-use-go-modules.md`. It records:
|
||||
An architectural decision is a concept page, prefixed as
|
||||
[kb/CONVENTIONS.md § Naming](../CONVENTIONS.md#naming) says. It records:
|
||||
|
||||
- **Context** - what forced a decision.
|
||||
- **Decision** - what was chosen.
|
||||
@@ -26,8 +38,19 @@ An architectural decision is a concept page prefixed `adr-NNN-`, e.g.
|
||||
- **Status** - proposed / accepted / deprecated / superseded.
|
||||
- Links to every entity the decision affects.
|
||||
|
||||
A superseded ADR is never deleted or rewritten; a new one supersedes it and both link to the
|
||||
other with `replaces` / `replaced by`.
|
||||
A superseded ADR is never deleted or rewritten. The new one declares `supersedes` pointing at
|
||||
it; the old one needs no edge back, because its inbound view renders the replacement.
|
||||
|
||||
## Authorised labels
|
||||
|
||||
The `outbound:` block above is what `wikitool lint` and `xref add` check: which labels a page in
|
||||
this collection may use, per destination. The catalogue they are drawn from - and what each one
|
||||
asserts - is [instructions/link-taxonomy.md](../../instructions/link-taxonomy.md), which binds
|
||||
nothing on its own.
|
||||
|
||||
The widest authorisation in this instance, because argumentation is what concept pages do. Note that the operational labels are absent: a concept does not `depend-on` anything - the entity implementing it does.
|
||||
|
||||
Adding a label here is a deliberate contract change, not a way around a refusal.
|
||||
|
||||
## Outbound linking
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
| [[Issue Label Scheme]] | decision | Zweiachsiges Pflicht-Labelschema fuer das Gitea-Board: prio/1..3 und size/XS..L, bewusst keine dritte Achse; die Regel liegt in instructions/dev/, weil sie keine ausgelieferte Instanz erreichen darf | 2026-08-31 |
|
||||
| [[Iteration and Cost Limits]] | workflow | Im Code durchgesetzte Obergrenze von 60 wikitool-Aufrufen je Session, Loop-Breaker bei 3 identischen Wiederholungen, Slot-Erstattung, ein gemessenes Kalibrierungsband, und Retrieval sowie der MCP-Leseserver bleiben ausgenommen | 2026-09-02 |
|
||||
| [[KB Migration]] | workflow | Migration des KB-Inhalts entlang einer geordneten Versionskette; abgegrenzt gegen offene Instanz-Aktionen, die in den doctor-Check gehoeren statt in die Kette | 2026-08-31 |
|
||||
| [[KB Stack Versioning]] | decision | Semantische Versionierung des Wiki-Stacks: VERSION beschreibt die Maschinerie, Kompatibilitaet ist die linkeste Nicht-Null-Komponente, und drei getrennte Dateien trennen Maschinerie, Herkunft und Content-Form | 2026-08-30 |
|
||||
| [[KB Stack Versioning]] | decision | Semantische Versionierung des Wiki-Stacks: VERSION beschreibt die Maschinerie, Kompatibilitaet (Drop-in-Ersatz) und Inhaltsmigration sind seit 2.5.0 getrennte, unabhaengig geprueft Fragen | 2026-09-02 |
|
||||
| [[Knowledge Compounding]] | workflow | Effekt, bei dem Wissen im Wiki an Wert gewinnt, weil jede neue Quelle an bestehende, untereinander verwiesene Seiten anknüpft und sie ergänzt. | 2026-08-29 |
|
||||
| [[Knowledge Graph]] | architecture | Typisierte Schicht aus Entities und Beziehungen über den Wiki-Seiten, die eine reichere Wissensdarstellung und graphbasierte Abfragen ermöglicht. | 2026-08-29 |
|
||||
| [[Lint Workflow]] | workflow | Deterministischer Health-Check rund um wikitool lint; seit 1.7.2 maskiert es Code vor dem Notation-Match und zaehlt Zitat-Bloecke statt Zeilen | 2026-09-01 |
|
||||
|
||||
@@ -3,13 +3,13 @@ type: types/concept.md
|
||||
concept_type: decision
|
||||
tags: [versioning, semver, release, stack]
|
||||
created: 2026-08-30
|
||||
modified: 2026-08-30
|
||||
modified: 2026-09-02
|
||||
related: [wikitool, Issue Label Scheme]
|
||||
sources: [Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30, Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31]
|
||||
sources: [Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30, Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31, Source - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02]
|
||||
confidence: 0.70
|
||||
confidence_base: 0.70
|
||||
provenance: sourced
|
||||
summary: 'Semantische Versionierung des Wiki-Stacks: VERSION beschreibt die Maschinerie, Kompatibilitaet ist die linkeste Nicht-Null-Komponente, und drei getrennte Dateien trennen Maschinerie, Herkunft und Content-Form'
|
||||
summary: 'Semantische Versionierung des Wiki-Stacks: VERSION beschreibt die Maschinerie, Kompatibilitaet (Drop-in-Ersatz) und Inhaltsmigration sind seit 2.5.0 getrennte, unabhaengig geprueft Fragen'
|
||||
---
|
||||
# KB Stack Versioning
|
||||
|
||||
@@ -42,9 +42,18 @@ deshalb eine ausdrückliche Handlung.
|
||||
Caret-Ranges
|
||||
verwenden[^s-conversation-versioning-ci-cd-and-content-migration-session-2026-08-30]. Sie gilt
|
||||
einheitlich für `0.x` und `1.x`, sodass unter `0.x` der Schritt `0.1.x` -> `0.2.0` dasselbe
|
||||
Migrationssignal trägt wie `MAJOR` ab `1.0.0`. Der Code für den `compat_key` ist deshalb
|
||||
einheitlich formuliert und musste beim Wechsel auf `1.0.0` nicht angefasst
|
||||
Signal trägt wie `MAJOR` ab `1.0.0`. Der Code für den `compat_key` ist deshalb einheitlich
|
||||
formuliert und musste beim Wechsel auf `1.0.0` nicht angefasst
|
||||
werden[^s-conversation-versioning-ci-cd-and-content-migration-session-2026-08-30].
|
||||
- **Kompatibilität und Inhaltsmigration sind zwei unabhängige Fragen, seit `2.5.0` auch zwei
|
||||
getrennte Marker.** Kompatibilität fragt, ob die neue Version ein Drop-in-Ersatz ist -
|
||||
vorwärts ohne Handarbeit, rückwärts noch downgradebar; Inhaltsmigration fragt, ob `kb/` sich
|
||||
bewegen muss. Ein Grenzübertritt kann `kb/` unangetastet lassen und trotzdem MAJOR sein - der
|
||||
`2.0.0`-Rebranding-Bump ist das Beispiel: Update-Pfad, Release-Artefaktname und
|
||||
Paket-Import-Name brachen, keine Seite tat es. `version bump` verlangt deshalb bei jedem
|
||||
Grenzübertritt `--breaking "<was aufhört zu funktionieren>"`, unabhängig von
|
||||
`--no-migration`/einem Migrationsdokument; beide Zeilen landen getrennt im
|
||||
`CHANGES.md`-Eintrag[^s-version-part-nomenclature-and-breaking-change-gate-session-2026-09-02].
|
||||
- **`x.y.z` ist die maximale Granularität. Keine Pre-Release-Suffixe.** Eine zweite
|
||||
Ordnungsregel müsste vom Release-Feed, von der Migrationskette und von der
|
||||
Kompatibilitätsprüfung gleichermaßen befolgt
|
||||
@@ -64,7 +73,11 @@ deshalb eine ausdrückliche Handlung.
|
||||
Instanz[^s-conversation-versioning-ci-cd-and-content-migration-session-2026-08-30].
|
||||
- **Die Grenze wird an zwei Stellen erzwungen:** in `version bump` und in `docs verify`, ergänzt
|
||||
um einen `kb-version`-Check in
|
||||
`doctor`[^s-conversation-versioning-ci-cd-and-content-migration-session-2026-08-30].
|
||||
`doctor`[^s-conversation-versioning-ci-cd-and-content-migration-session-2026-08-30]. Seit
|
||||
`2.5.0` prüft `docs verify` dort zwei unabhängige Dinge - `check_migration_for_boundary` (hat
|
||||
der Korpus sich bewegt) und `check_breaking_change_for_boundary` (wurde der Bruch benannt) -,
|
||||
weil ein Grenzübertritt die eine Prüfung bestehen und an der anderen scheitern
|
||||
kann[^s-version-part-nomenclature-and-breaking-change-gate-session-2026-09-02].
|
||||
|
||||
## Beispiele
|
||||
|
||||
@@ -105,7 +118,9 @@ kann.
|
||||
- [[wikitool]]
|
||||
- [[Issue Label Scheme]]
|
||||
- [[Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31]]
|
||||
- [[Source - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02]]
|
||||
|
||||
## Fußnoten
|
||||
|
||||
[^s-conversation-versioning-ci-cd-and-content-migration-session-2026-08-30]: [[Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30]]
|
||||
[^s-version-part-nomenclature-and-breaking-change-gate-session-2026-09-02]: [[Source - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02]]
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
---
|
||||
profile: entities
|
||||
outbound:
|
||||
entities: [depends-on, required-by, runs-on, hosts, uses, produces, consumes, maintains, owns, part-of, composition, supersedes, see-also]
|
||||
concepts: [implements, exemplifies, rests-on, applies-when, operates-on, invokes, see-also]
|
||||
sources: [evidenced-by, defined-in, see-also]
|
||||
comparisons: [compares-with, see-also]
|
||||
required_by_stack: false
|
||||
---
|
||||
|
||||
# kb/entities/ - Collection Contract
|
||||
|
||||
Concrete things that exist: a project, a deployed system, a CLI tool, a technology, a person or
|
||||
@@ -7,8 +17,10 @@ an organization. If it can be pointed at, it is an entity.
|
||||
is, where it actually is, and whether that is still true. An entity page that describes a
|
||||
system correctly but names no host, path, version or status has not earned its keep.
|
||||
|
||||
Inherits [kb/CONTRACT.md](../CONTRACT.md) - naming, tone, linking, provenance and confidence
|
||||
are defined there and are not restated here.
|
||||
Inherits [kb/CONTRACT.md](../CONTRACT.md) for the rules the stack enforces - linking mechanics,
|
||||
provenance, citation, the confidence machinery - and
|
||||
[kb/CONVENTIONS.md](../CONVENTIONS.md) for what this instance decided: language, naming forms,
|
||||
tone, relationship labels, the confidence rubric. Neither is restated here.
|
||||
|
||||
## Types offered
|
||||
|
||||
@@ -36,6 +48,17 @@ These are areas, not collections: they inherit this contract and carry no `COLLE
|
||||
- **People** - role, affiliation, and the projects or decisions they are connected to. Nothing
|
||||
personal beyond what the source states.
|
||||
|
||||
## Authorised labels
|
||||
|
||||
The `outbound:` block above is what `wikitool lint` and `xref add` check: which labels a page in
|
||||
this collection may use, per destination. The catalogue they are drawn from - and what each one
|
||||
asserts - is [instructions/link-taxonomy.md](../../instructions/link-taxonomy.md), which binds
|
||||
nothing on its own.
|
||||
|
||||
Operational labels dominate here because an entity's relationships are mostly to other concrete things. `implements` points *out* to a concept; the concept does not point back unless that direction is a statement of its own.
|
||||
|
||||
Adding a label here is a deliberate contract change, not a way around a refusal.
|
||||
|
||||
## Outbound linking
|
||||
|
||||
An entity links to the technologies it uses, the systems it runs on, the projects that depend
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|------|------|---------|----------------|
|
||||
| [[andybalholm-edl]] | project | Go-basierte EDL-Bibliothek für die Kommunikation mit eingebetteten Geräten. | 2026-08-29 |
|
||||
| [[BCDModule]] | project | Go-Modul, das die Entscheidungslogik für die Batterieladung umsetzt. | 2026-08-29 |
|
||||
| [[Chemenu]] | project | Deterministischer Wissenskompiler (raw/ -> kb/); seit 2.0.0 unter dem Namen Chemenu, seit 2026-09-01 oeffentlich, seit 2.4.0 mit einem MCP-Leseserver als zweitem Konsumenten | 2026-09-02 |
|
||||
| [[Chemenu]] | project | Deterministischer Wissenskompiler (raw/ -> kb/); seit 2.0.0 unter dem Namen Chemenu; Issue 26 zur Versionsstellen-Nomenklatur in 2.5.0 geschlossen | 2026-09-02 |
|
||||
| [[goresponsiveness]] | project | Go-Werkzeug zur Messung von Anwendungsleistung und Responsiveness. | 2026-08-29 |
|
||||
| [[ha-core]] | project | Kern-Integrationsbibliothek für Home-Assistant-E3DC-Systeme; stellt die E3DC-Kommunikationsprotokolle und den Home-Assistant-Integrationscode bereit. | 2026-08-29 |
|
||||
| [[hacs-e3dc]] | project | Home Assistant Custom Component zur Überwachung von E3DC-Energiesystemen. | 2026-08-29 |
|
||||
@@ -98,6 +98,6 @@
|
||||
| [[Proton]] | tool | Wine-basierte Kompatibilitätsschicht von Valve; lässt Windows-Spiele über Steam unter Linux laufen, mit optimierter DirectX-Übersetzung. | 2026-08-29 |
|
||||
| [[qmd]] | tool | Lokale Suchmaschine fuer Markdown-Dateien: TypeScript/Node.js/Bun, SQLite-FTS5-BM25 plus sqlite-vec-Vektorsuche plus node-llama-cpp-LLM-Reranking. | 2026-09-02 |
|
||||
| [[Steam]] | tool | Valves Plattform für digitalen Spielevertrieb und Spielebibliothek auf dem PC. | 2026-08-29 |
|
||||
| [[wikitool]] | tool | Deterministisches CLI fuer alle mechanischen Wiki-Operationen; seit 2.0.0 im Paket chemenu, seit 2.4.0 zusaetzlich als MCP-Leseserver erreichbar | 2026-09-02 |
|
||||
| [[wikitool]] | tool | Deterministisches CLI fuer alle mechanischen Wiki-Operationen; seit 2.0.0 im Paket chemenu, seit 2.4.0 zusaetzlich als MCP-Leseserver erreichbar, seit 2.5.0 mit Breaking-Change-Pflichtmarker | 2026-09-02 |
|
||||
| [[Wine]] | tool | Kompatibilitätsschicht, die Windows-API-Aufrufe nach POSIX übersetzt und Windows-Anwendungen unter Linux, BSD und macOS ohne Virtualisierung oder Emulation ausführt. | 2026-08-29 |
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ tags: [wiki, llm, knowledge-base]
|
||||
created: 2026-08-04
|
||||
modified: 2026-09-02
|
||||
related: [Personalization Plane, Issue Label Scheme, Optional Instance Context File, Delete Rather Than Anonymize, Dual Licensing by File Plan, Publish-Remote Gate, MCP-Leseserver]
|
||||
sources: [Source - Copilot Skill Restructure Instructions, Source - Conversation - AGENTS.md Skill Restructuring Session 2026-08-04, Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30, Source - Conversation - Comma Bug Budget Refund and Lint Report Path Session 2026-08-31, Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31, Source - Conversation - Write-Once Frontmatter Fields and touch --set Session 2026-08-31, Source - Conversation - Gate Counting and Measured Calibration Session 2026-08-31, Source - Conversation - Two Round-Trip Defects Found by an Ingest Session 2026-08-31, Source - Conversation - Hardening the Test Suite Against Silent Environment Dependencies Session 2026-08-31, 'Source - Public Release, Corpus Purge and History Squash Session 2026-09-01', Source - Publish-Remote Gate and Issue Triage Session 2026-09-01, Source - Private-Instance Merge Correction and Issue 30 Session 2026-09-01, Source - MCP Read Server Implementation Session 2026-09-02]
|
||||
sources: [Source - Copilot Skill Restructure Instructions, Source - Conversation - AGENTS.md Skill Restructuring Session 2026-08-04, Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30, Source - Conversation - Comma Bug Budget Refund and Lint Report Path Session 2026-08-31, Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31, Source - Conversation - Write-Once Frontmatter Fields and touch --set Session 2026-08-31, Source - Conversation - Gate Counting and Measured Calibration Session 2026-08-31, Source - Conversation - Two Round-Trip Defects Found by an Ingest Session 2026-08-31, Source - Conversation - Hardening the Test Suite Against Silent Environment Dependencies Session 2026-08-31, 'Source - Public Release, Corpus Purge and History Squash Session 2026-09-01', Source - Publish-Remote Gate and Issue Triage Session 2026-09-01, Source - Private-Instance Merge Correction and Issue 30 Session 2026-09-01, Source - MCP Read Server Implementation Session 2026-09-02, Source - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02]
|
||||
confidence: 0.90
|
||||
confidence_base: 0.90
|
||||
provenance: mixed
|
||||
summary: Deterministischer Wissenskompiler (raw/ -> kb/); seit 2.0.0 unter dem Namen Chemenu, seit 2026-09-01 oeffentlich, seit 2.4.0 mit einem MCP-Leseserver als zweitem Konsumenten
|
||||
summary: Deterministischer Wissenskompiler (raw/ -> kb/); seit 2.0.0 unter dem Namen Chemenu; Issue 26 zur Versionsstellen-Nomenklatur in 2.5.0 geschlossen
|
||||
---
|
||||
# Chemenu
|
||||
|
||||
@@ -159,7 +159,8 @@ Repositorys selbst und keine Aussagen aus einer Rohdatenquelle; der Änderungsda
|
||||
auf den alten Repo-Pfad und lässt sich per Invariante 1 nicht von Hand reparieren. Die
|
||||
erste Einschätzung lautete `1.9.0` und wurde von Torben korrigiert; die Lücke in der Doku,
|
||||
die dazu führte - MAJOR ist dort als Inhaltsmigration statt als Kompatibilitätsbruch
|
||||
beschrieben - liegt als Issue #26. Verzeichnet in `CHANGES.md` (`2.0.0`).
|
||||
beschrieben - war Issue #26 und wurde in `2.5.0` geschlossen (siehe [[KB Stack Versioning]]).
|
||||
Verzeichnet in `CHANGES.md` (`2.0.0`).
|
||||
- 2026-08-31 - `1.8.1` (Commit `a243a4a`, Korrektur `2b7b3cb`): Test-Coverage wird in CI
|
||||
gemessen und als Artefakt ausgewiesen, ohne `--cov-fail-under` - siehe
|
||||
Messen vor Schwelle. Die Messung deckte einen `dist export`-Fehler auf: Coverage-Ausgabe
|
||||
@@ -228,6 +229,7 @@ Repositorys selbst und keine Aussagen aus einer Rohdatenquelle; der Änderungsda
|
||||
- [[Publish-Remote Gate]]
|
||||
- [[Source - Private-Instance Merge Correction and Issue 30 Session 2026-09-01]]
|
||||
- [[MCP-Leseserver]]
|
||||
- [[Source - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02]]
|
||||
|
||||
## Fußnoten
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ tags: [cli, automation, deterministic, wiki-management]
|
||||
created: 2026-08-03
|
||||
modified: 2026-09-02
|
||||
related: [Semantic Lint Automation, Session Orientation, Iteration and Cost Limits, KB Stack Versioning, KB Migration, Personalization Plane, Detect-Repair Asymmetry, Write-Once Frontmatter Fields, Denylist over Allowlist, Command Round-Trip Integrity, Green Suite Blind Spot, Ambient Environment Dependency, Structural Enforcement over Documented Rule, Optional Instance Context File, MCP-Leseserver]
|
||||
sources: [Source - LLM Improvements Codex Analysis, Source - LLM Improvements Sonnet Analysis, Source - Copilot Skill Restructure Instructions, Source - Conversation - AGENTS.md Skill Restructuring Session 2026-08-04, Source - LLM Improvements Production Agent Gaps 2026, Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30, Source - Conversation - Comma Bug Budget Refund and Lint Report Path Session 2026-08-31, Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31, Source - Conversation - Auto Mode and Tool Choice Session 2026-08-31, Source - Conversation - Write-Once Frontmatter Fields and touch --set Session 2026-08-31, Source - Conversation - Gate Counting and Measured Calibration Session 2026-08-31, Source - Conversation - Two Round-Trip Defects Found by an Ingest Session 2026-08-31, Source - Conversation - Hardening the Test Suite Against Silent Environment Dependencies Session 2026-08-31, Source - Conversation - ENVIRONMENT.md as an Optional Third Session-Level File Session 2026-08-31, 'Source - Public Release, Corpus Purge and History Squash Session 2026-09-01', Source - Publish-Remote Gate and Issue Triage Session 2026-09-01, Source - Private-Instance Merge Correction and Issue 30 Session 2026-09-01, Source - MCP Read Server Implementation Session 2026-09-02]
|
||||
sources: [Source - LLM Improvements Codex Analysis, Source - LLM Improvements Sonnet Analysis, Source - Copilot Skill Restructure Instructions, Source - Conversation - AGENTS.md Skill Restructuring Session 2026-08-04, Source - LLM Improvements Production Agent Gaps 2026, Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30, Source - Conversation - Comma Bug Budget Refund and Lint Report Path Session 2026-08-31, Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31, Source - Conversation - Auto Mode and Tool Choice Session 2026-08-31, Source - Conversation - Write-Once Frontmatter Fields and touch --set Session 2026-08-31, Source - Conversation - Gate Counting and Measured Calibration Session 2026-08-31, Source - Conversation - Two Round-Trip Defects Found by an Ingest Session 2026-08-31, Source - Conversation - Hardening the Test Suite Against Silent Environment Dependencies Session 2026-08-31, Source - Conversation - ENVIRONMENT.md as an Optional Third Session-Level File Session 2026-08-31, 'Source - Public Release, Corpus Purge and History Squash Session 2026-09-01', Source - Publish-Remote Gate and Issue Triage Session 2026-09-01, Source - Private-Instance Merge Correction and Issue 30 Session 2026-09-01, Source - MCP Read Server Implementation Session 2026-09-02, Source - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02]
|
||||
confidence: 0.90
|
||||
confidence_base: 0.90
|
||||
provenance: sourced
|
||||
summary: Deterministisches CLI fuer alle mechanischen Wiki-Operationen; seit 2.0.0 im Paket chemenu, seit 2.4.0 zusaetzlich als MCP-Leseserver erreichbar
|
||||
summary: Deterministisches CLI fuer alle mechanischen Wiki-Operationen; seit 2.0.0 im Paket chemenu, seit 2.4.0 zusaetzlich als MCP-Leseserver erreichbar, seit 2.5.0 mit Breaking-Change-Pflichtmarker
|
||||
---
|
||||
# wikitool
|
||||
|
||||
@@ -86,8 +86,15 @@ wikitool bietet die folgenden Befehlskategorien:
|
||||
- **Veröffentlichung:** `publish` - zählt seit `1.5.0` nur noch Dateien, die eine Entscheidung tragen: Pfade unter `work/` und generierte Dateien (`kb/index.md`, `kb/log.md`, `kb/provenance.md`, jede `INDEX.md`) werden committet und gepusht, aber nicht gegen die Schwelle gezählt; die Weigerungszeile weist beide Gründe getrennt aus[^s-conversation-gate-counting-and-measured-calibration-session-2026-08-31]. Siehe [[Mass-Update Gate]]
|
||||
- **Budget:** `budget status`, `budget reset` - Obergrenze seit `1.2.0` 60 Aufrufe je Sitzung; ein Aufruf, der über `_util.fail()` abgelehnt wurde, bekommt seinen Slot zurück und bleibt trotzdem in `recent`, damit der Loop-Breaker ihn sieht[^s-conversation-comma-bug-budget-refund-and-lint-report-path-session-2026-08-31]. Siehe [[Iteration and Cost Limits]]
|
||||
- **Versionierung:** `version bump`, `version check` - `bump` schreibt die Stack-Version in die
|
||||
Wurzeldatei `VERSION` und verweigert einen `MAJOR`-Sprung ohne Migrationsdokument, sofern er
|
||||
nicht ausdrücklich mit `--no-migration "<Begründung>"` gesetzt wird; `check` ist der einzige
|
||||
Wurzeldatei `VERSION`. Kompatibilität (ist die neue Version ein Drop-in-Ersatz - vorwärts ohne
|
||||
Handarbeit, rückwärts noch downgradebar) und Inhaltsmigration sind seit `2.5.0` zwei getrennte
|
||||
Fragen: ein Grenzübertritt verlangt zwingend `--breaking "<was aufhört zu funktionieren>"`,
|
||||
verweigert auf jedem anderen Bump, und *zusätzlich* entweder ein Migrationsdokument oder
|
||||
`--no-migration "<Begründung>"`, wenn `kb/` unangetastet bleibt. Beide Marker landen als
|
||||
eigene Zeile im `CHANGES.md`-Eintrag (`**Breaking Change:**` vor `**Migration:**`) und werden
|
||||
von `docs verify` unabhängig
|
||||
voneinander geprüft[^s-version-part-nomenclature-and-breaking-change-gate-session-2026-09-02].
|
||||
Siehe [[KB Stack Versioning]]. `check` ist der einzige
|
||||
Befehl, der einen Netzaufruf machen darf - ohne Schlüssel, mit Timeout und injizierbarem
|
||||
Fetch, damit Tests nie ein Netz
|
||||
berühren[^s-conversation-versioning-ci-cd-and-content-migration-session-2026-08-30]
|
||||
@@ -151,6 +158,19 @@ ist[^s-conversation-versioning-ci-cd-and-content-migration-session-2026-08-30].
|
||||
|
||||
## Historie
|
||||
|
||||
- 2026-09-02 - `2.5.0` (Commit `31662dc`, 806 Tests grün, 6 neu): Gitea-Issue #26 geschlossen.
|
||||
`instructions/dev/version-parts.md` (neu, `instructions/dev/` - kein Verweis aus einem
|
||||
ausgelieferten Artefakt, `instructions verify` hätte einen dangelnden Verweis nach
|
||||
`dist export` gemeldet) trennt die Kompatibilitäts- von der Migrationsfrage: Drop-in-Test in
|
||||
beiden Richtungen, Katalog der Brüche mit unangetastetem `kb/` (Update-Pfad, Artefaktname,
|
||||
Import-Name, Flag/Envvar, Shape einer maschinengelesenen Datei), `2.0.0` als Fallbeispiel.
|
||||
`version bump` bekommt `--breaking "<was aufhört zu funktionieren>"`, bei jedem
|
||||
Grenzübertritt Pflicht und auf jedem anderen Bump verweigert; `docs verify` prüft das über
|
||||
eine zweite, von der Migrationsprüfung unabhängige Regel. `stack-dev` bekommt einen
|
||||
Entscheidungspunkt: kein Grenzübertritt aus eigener Initiative, erst Bruch, Handarbeit je
|
||||
Instanz und Alternativen (Shim, aufschieben/bündeln, kompatibel/brechend mit
|
||||
Deprecation-Fenster aufspalten) vorlegen, dann
|
||||
Freigabe[^s-version-part-nomenclature-and-breaking-change-gate-session-2026-09-02].
|
||||
- 2026-09-02 - `2.2.3`-`2.4.1`: die vierstufige Sequenz aus Issue #36 (Publish-Remote-Gate
|
||||
scharf, Lesepfad gehärtet, Root-Auflösung und Bibliotheksgrenze, [[MCP-Leseserver]]) plus
|
||||
Menschendoku. `.wikitool-remotes.json` war trotz gegenteiliger Dokumentation nicht gesetzt -
|
||||
@@ -303,3 +323,4 @@ ist[^s-conversation-versioning-ci-cd-and-content-migration-session-2026-08-30].
|
||||
[^s-public-release-corpus-purge-and-history-squash-session-2026-09-01]: [[Source - Public Release, Corpus Purge and History Squash Session 2026-09-01]]
|
||||
[^s-publish-remote-gate-and-issue-triage-session-2026-09-01]: [[Source - Publish-Remote Gate and Issue Triage Session 2026-09-01]]
|
||||
[^s-mcp-read-server-implementation-session-2026-09-02]: [[Source - MCP Read Server Implementation Session 2026-09-02]]
|
||||
[^s-version-part-nomenclature-and-breaking-change-gate-session-2026-09-02]: [[Source - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02]]
|
||||
|
||||
+3
-3
@@ -13,11 +13,11 @@ The page tables live in a generated `INDEX.md` inside each collection, linked be
|
||||
|
||||
## Statistics
|
||||
|
||||
- **Total Pages:** 179
|
||||
- **Total Pages:** 180
|
||||
- **Comparisons:** 1
|
||||
- **Concepts:** 80
|
||||
- **Entities:** 72
|
||||
- **Sources:** 26
|
||||
- **Sources:** 27
|
||||
- **Last Updated:** 2026-09-02
|
||||
|
||||
---
|
||||
@@ -29,7 +29,7 @@ The page tables live in a generated `INDEX.md` inside each collection, linked be
|
||||
| `comparisons/` | 1 | [comparisons/INDEX.md](comparisons/INDEX.md) |
|
||||
| `concepts/` | 80 | [concepts/INDEX.md](concepts/INDEX.md) |
|
||||
| `entities/` | 72 | [entities/INDEX.md](entities/INDEX.md) |
|
||||
| `sources/` | 26 | [sources/INDEX.md](sources/INDEX.md) |
|
||||
| `sources/` | 27 | [sources/INDEX.md](sources/INDEX.md) |
|
||||
|
||||
### entities/
|
||||
|
||||
|
||||
@@ -91,3 +91,9 @@ Direkt gegen tobi/qmd auf GitHub geprueft: TypeScript statt der geratenen 'Go od
|
||||
Aufgaben-Checkbox fuer die qmd.md-Korrektur nachgezogen (war [ ], ist erledigt) und ein Korrektur-Hinweis ergaenzt: der Fidelity-Block des Rohtranskripts kuendigte ein zweites Transkript fuer die qmd-Korrektur an, das nie geschrieben wurde - die Korrektur lief stattdessen als eigene Quellen-Verifikation. raw/ ist unveraenderlich, die Korrektur steht deshalb auf der Source-Seite.
|
||||
|
||||
---
|
||||
|
||||
## [2026-09-02] ingest | raw/notes/Conversation Transcript - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02.md
|
||||
|
||||
Neue Source-Seite, Korrektur der veralteten MAJOR-als-Migration-Formulierung in wikitool.md und KB Stack Versioning.md, Nachtrag in Chemenu.md (Issue #26 geschlossen).
|
||||
|
||||
---
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
---
|
||||
profile: sources
|
||||
outbound:
|
||||
any: [is-evidence-for, defined-in, see-also]
|
||||
required_by_stack: true
|
||||
---
|
||||
|
||||
# kb/sources/ - Collection Contract
|
||||
|
||||
One page per ingested source. A source page is the bridge between the untrusted material in
|
||||
@@ -9,8 +16,14 @@ concluded from it. Where the source is wrong, say what it claims and let the sub
|
||||
carry the correction. A source page that has been improved beyond its source is no longer
|
||||
evidence for anything.
|
||||
|
||||
Inherits [kb/CONTRACT.md](../CONTRACT.md) - naming, tone, linking, provenance and confidence
|
||||
are defined there and are not restated here.
|
||||
Inherits [kb/CONTRACT.md](../CONTRACT.md) for the rules the stack enforces - linking mechanics,
|
||||
provenance, citation, the confidence machinery - and
|
||||
[kb/CONVENTIONS.md](../CONVENTIONS.md) for what this instance decided: language, naming forms,
|
||||
tone, relationship labels, the confidence rubric. Neither is restated here.
|
||||
|
||||
**This collection is `required_by_stack`.** `sources coverage`, `[^cite-id]` resolution and
|
||||
`kb/provenance.md` resolve against it by name, so unlike every other collection it may not be
|
||||
renamed or dropped - its authoring rules below are the instance's, its existence is not.
|
||||
|
||||
## Types offered
|
||||
|
||||
@@ -27,6 +40,17 @@ The `raw_files:`/`source_url:`/citation rules are shared and live in
|
||||
- `tools/wikitool sources trace --raw <path>` answers "what did we learn from this?";
|
||||
`tools/wikitool sources coverage` lists raw files no source page claims yet.
|
||||
|
||||
## Authorised labels
|
||||
|
||||
The `outbound:` block above is what `wikitool lint` and `xref add` check: which labels a page in
|
||||
this collection may use, per destination. The catalogue they are drawn from - and what each one
|
||||
asserts - is [instructions/link-taxonomy.md](../../instructions/link-taxonomy.md), which binds
|
||||
nothing on its own.
|
||||
|
||||
Deliberately narrow. A source page is evidence *about* a source; almost everything it would want to say is already carried by `raw_files:`, `sources:` and `[^cite-id]`, which are the mechanical provenance path rather than authored edges.
|
||||
|
||||
Adding a label here is a deliberate contract change, not a way around a refusal.
|
||||
|
||||
## Outbound linking
|
||||
|
||||
A source page links to every entity and concept it produced or updated.
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
# kb/sources/ - Index
|
||||
|
||||
26 page(s). Regenerated by `wikitool index rebuild`.
|
||||
27 page(s). Regenerated by `wikitool index rebuild`.
|
||||
|
||||
## All
|
||||
|
||||
@@ -33,5 +33,6 @@
|
||||
| [[Source - Public Release, Corpus Purge and History Squash Session 2026-09-01]] | notes | Sitzung, die den Chemenu-Stack von einer privaten Testinstanz in ein oeffentliches Repo ueberfuehrt: Korpus geloescht statt anonymisiert, Git-History auf einen Commit gesquashed, AGPL-3.0/CC-BY-4.0-Dual-Lizenz gewaehlt, dist export um einen Leak-Canary gehaertet. | 2026-09-01 |
|
||||
| [[Source - Publish-Remote Gate and Issue Triage Session 2026-09-01]] | notes | Sitzung, die ein drittes, Token-loses Gate fuer publish baut, instructions/private-instance.md schreibt, sechs Gitea-Issues auf den Rename und die neue Architektur nachzieht und die Actions-Run-Historie entfernen laesst. | 2026-09-01 |
|
||||
| [[Source - qmd - GitHub Repository]] | document | GitHub-API-Metadaten, package.json und README-Auszuege von tobi/qmd: TypeScript/Node/Bun statt Go oder Rust, BM25 (SQLite FTS5) plus Vektor-Suche (sqlite-vec) plus LLM-Reranking ueber node-llama-cpp. | 2026-09-02 |
|
||||
| [[Source - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02]] | notes | Sitzung, die die Versionsstelle als Kompatibilitaets- statt Migrationsfrage praezisiert und einen Freigabe-Ablauf fuer Breaking Changes in stack-dev einfuehrt | 2026-09-02 |
|
||||
| [[Source - Wine]] | notes | Wine-Konfiguration für Arch Linux: pacman-NoExtract-Einstellungen und Bottles-Runtime-Optionen einschließlich Proton- und Lutris-Varianten. | 2026-08-01 |
|
||||
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
---
|
||||
type: types/source.md
|
||||
source_type: notes
|
||||
author: Torben Nehmer
|
||||
raw_files: [raw/notes/Conversation Transcript - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02.md]
|
||||
source_language: de
|
||||
date: 2026-09-02
|
||||
tags: []
|
||||
entities: [wikitool, Chemenu]
|
||||
concepts: [KB Stack Versioning]
|
||||
summary: Sitzung, die die Versionsstelle als Kompatibilitaets- statt Migrationsfrage praezisiert und einen Freigabe-Ablauf fuer Breaking Changes in stack-dev einfuehrt
|
||||
---
|
||||
# Source: Version Part Nomenclature and Breaking Change Gate Session 2026-09-02
|
||||
|
||||
**Autor:** Torben Nehmer
|
||||
**Datum:** 2026-09-02
|
||||
**Raw-Dateien:** raw/notes/Conversation Transcript - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02.md
|
||||
**Typ:** Notes
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Umsetzung von Gitea-Issue #26: Die Doku des Stacks führte für die Wahl der Versionsstelle zwei
|
||||
Fragen zusammen, die nicht dieselbe sind - ob der Korpus migriert werden muss, und ob der
|
||||
Wechsel ein Drop-in-Ersatz ist. Nur `version bump --help` unterschied korrekt; die drei
|
||||
prosaischen Stellen (`stack-dev`, `version.py`-Docstring, `INSTALL.md`) beschrieben MAJOR als
|
||||
Migrationsfrage. Der `2.0.0`-Rebranding-Bump hatte genau daran zuerst `1.9.0` statt `--major`
|
||||
angesetzt.
|
||||
|
||||
Der Nutzer schärfte die Regel während der Sitzung zu einem konkreten zweiseitigen Test nach:
|
||||
"die neue version ist kein drop-in replacement. Sobald irgendwie Hand angelegt werden muss, sei
|
||||
es durch den user oder durch ein Migrationsscript, ist es ein major version change. selbiges
|
||||
gilt, wenn ein update nicht rückgängig gemacht werden kann [...] in allen Fällen muss bei einem
|
||||
Major version change ein 'Breaking Change' vermerkt werden. breaking changes sind damit teuer.
|
||||
passe stack-dev so an, dass in diesen Fällen zwingend der user informiert, Alternativen
|
||||
aufgezeigt und eine freigabe eingeholt wird." Zwei Auswahlentscheidungen davor: Durchsetzung im
|
||||
Code statt reiner Prosa (weil Prosa bereits einmal gedriftet war), und die Freigabe als
|
||||
"Decision point" statt als Gate-Sprache, um die drei echten code-erzwungenen Gates nicht zu
|
||||
verwässern.
|
||||
|
||||
## Kernaussagen
|
||||
|
||||
- Kompatibilität (Drop-in-Ersatz, vorwärts wie rückwärts) und Inhaltsmigration sind zwei
|
||||
unabhängige Fragen; MAJOR beantwortet die erste, `--no-migration`/ein Migrationsdokument die
|
||||
zweite.
|
||||
- Ein Grenzübertritt kann `kb/` völlig unangetastet lassen und trotzdem MAJOR sein - Katalog:
|
||||
Update-Pfad, Release-Artefaktname, Paket-Import-Name, ein umbenanntes Kommando/Flag/Envvar,
|
||||
die Shape einer maschinengelesenen Datei.
|
||||
- Ein Breaking Change ist teuer (jede bestehende Instanz zahlt einmal, von Hand) und deshalb
|
||||
genehmigungspflichtig: Bruch, Handarbeit je Instanz und Alternativen (Shim, aufschieben und
|
||||
bündeln, aufspalten mit Deprecation-Fenster) vorlegen, dann Freigabe abwarten.
|
||||
- Reine Prosa-Regeln drifted - deshalb wurde `--breaking` als Pflichtflag samt zweiter, von der
|
||||
Migrationsprüfung unabhängiger `docs verify`-Prüfung eingeführt, nicht nur eine Textänderung.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
Keine offenen Aufgaben aus dieser Sitzung - Issue #26 wurde in derselben Sitzung geschlossen,
|
||||
mit Verweis auf Commit `31662dc` (`2.5.0`).
|
||||
|
||||
## Nicht übernommen
|
||||
|
||||
- Die vollständige Katalog-Tabelle und der `2.0.0`-Fallbeispiel-Text aus
|
||||
`instructions/dev/version-parts.md` werden hier nicht wiederholt - die Datei ist die
|
||||
autoritative Quelle (Instruktions-Layer, `manual`-artig durch die `instructions/dev/`-Grenze),
|
||||
diese Source-Seite fasst nur zusammen, was zur Entscheidung führte.
|
||||
- Der genaue Wortlaut der Tool-Fehlermeldungen (`version bump`-Refusals) steht im Transkript
|
||||
selbst; hier nur die Regel dahinter.
|
||||
|
||||
## Verwandte Entities
|
||||
|
||||
- [[wikitool]]
|
||||
- [[Chemenu]]
|
||||
|
||||
## Verwandte Concepts
|
||||
|
||||
- [[KB Stack Versioning]]
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
# Conversation Transcript - Version Part Nomenclature and Breaking Change Gate Session
|
||||
|
||||
> Source: Claude Code session (`claude-sonnet-5`), chemenu workspace
|
||||
> Collected: 2026-09-02
|
||||
> Participant: Torben Nehmer
|
||||
> Fidelity: **faithful summary transcript, not a verbatim log.** The user's instructions and
|
||||
> clarifications are quoted verbatim; the agent's own reasoning and file-reading steps are
|
||||
> paraphrased; tool output blocks (`docs verify`, `pytest`, `version bump --dry-run`, the
|
||||
> Mass-Update Gate refusal) are real, copied from the actual run, not reconstructed.
|
||||
> No second-hand material - no subagent was used.
|
||||
> No credentials appeared.
|
||||
> Single topic, not cut.
|
||||
|
||||
Closes Gitea issue #26 (already closed in-session) with `2.5.0`. Commit `31662dc` on `main`.
|
||||
Covers picking the version part for a stack change, and adding a user-approval gate for
|
||||
breaking changes to `stack-dev`.
|
||||
|
||||
## Turn 1 - `/stack-dev kümmere dich um #26`
|
||||
|
||||
The user invoked the `stack-dev` skill with the argument `kümmere dich um #26`. The agent read
|
||||
issue #26 via `gitea-mcp` (`torben/chemenu#26`, no comments yet).
|
||||
|
||||
**The issue's own account** (quoted from its body, since it is the source of the whole
|
||||
session): the stack's documentation conflated two questions when choosing a version bump part -
|
||||
"Muss der Korpus migriert werden?" and "Ist der Wechsel rückwärtskompatibel?" - and every place
|
||||
an agent would consult before a bump stated only the first:
|
||||
|
||||
| Ort | Wortlaut |
|
||||
|---|---|
|
||||
| `instructions/dev/stack-dev/SKILL.md`, Schritt 3 | `--major` ⇔ "Existing content must be migrated" |
|
||||
| `tools/chemenu/version.py`, module docstring | "from `1.0.0` on the same rule reads as the familiar 'MAJOR means migration'" |
|
||||
| `INSTALL.md` § Version und Updates | "ab `1.0.0` liest sich dieselbe Regel als das gewohnte 'MAJOR heißt Migration'" |
|
||||
|
||||
Only `version bump --help` had it right ("A bump that crosses the compatibility boundary
|
||||
additionally requires a migration document ... or `--no-migration`") - the one place read only
|
||||
after the decision is already made.
|
||||
|
||||
The issue's worked example was the `2.0.0` rebranding (`torben/llm-wiki-test1` →
|
||||
`torben/chemenu`): no page in `kb/` changed, so the first attempt at that bump was `1.9.0`. Three
|
||||
things broke anyway - the release-feed URL baked into every existing instance's
|
||||
`.wikitool-release.json` (unrepairable by hand, per invariant 1), the release artefact's
|
||||
filename, and the Python package's import name. The correct bump was `--major --no-migration`.
|
||||
The issue's "Was 'fertig' heißt" checklist asked for: rewording the three prose locations to
|
||||
lead with compatibility rather than migration, a decision aid enumerating which kinds of break
|
||||
force MAJOR even with `kb/` untouched, and a check that the three locations do not end up
|
||||
contradicting each other (invariant 8). It explicitly scoped out teaching `docs verify` to judge
|
||||
*whether* a bump chose the right part - "das ist eine Urteilsfrage und kein hartes Orakel."
|
||||
|
||||
The agent read the three prose locations plus `tools/chemenu/commands/version_cmd.py` (the
|
||||
existing `--no-migration` enforcement) and `docs_verify.py` (`check_migration_for_boundary`), to
|
||||
scope the fix before writing anything.
|
||||
|
||||
## Turn 2 - scope questions, then the user's clarification
|
||||
|
||||
Before writing, the agent asked two `AskUserQuestion` questions:
|
||||
|
||||
1. Whether the "Breaking Change" marker and approval requirement should be prose-only or also
|
||||
enforced in the tool. Options offered: "Prosa + Marker im Code (Empfehlung)" vs. "Nur Prosa".
|
||||
**User picked the enforced option** ("Prosa + Marker im Code (Empfehlung)") - a
|
||||
`--breaking` flag on `version bump`, checked by `docs verify`, on the reasoning that a
|
||||
pure-prose rule is exactly what drifted in the first place.
|
||||
2. Whether the pre-bump approval step should read like one of the codebase's actual gates
|
||||
(`instructions/gates.md` language: "anhalten ... auf ausdrückliche Freigabe warten") or as a
|
||||
softer "Decision points" entry. **User picked the softer framing** ("Als Entscheidungspunkt")
|
||||
- explicitly *not* gate language, to avoid diluting the weight of the three real,
|
||||
code-enforced gates (Mass-Update, Publish-Remote, Iteration Budget) by association.
|
||||
|
||||
The user then added the clarification that drove the rest of the session, quoted in full:
|
||||
|
||||
> "genaue nomenklatur für major version change: die neue version ist kein drop-in replacement.
|
||||
> Sobald irgendwie Hand angelegt werden muss, sei es durch den user oder durch ein
|
||||
> Migrationsscript, ist es ein major version change. selbiges gilt, wenn ein update nicht
|
||||
> rückgängig gemacht werden kann (Szenario: User aktualisiert den Stack von vA nach vB, vB hat
|
||||
> beim User ein Problem, kann jetzt vA nicht mehr einfach so als Downgrade installiert werden,
|
||||
> ist es ein breaking change)
|
||||
> in allen Fällen muss bei einem Major version change ein "Breaking Change" vermerkt werden.
|
||||
> breaking changes sind damit teuer. passe stack-dev so an, dass in diesen Fällen zwingend der
|
||||
> user informiert, Alternativen aufgezeigt und eine freigabe eingeholt wird."
|
||||
|
||||
This sharpened the issue's "compatibility" question into a concrete two-sided test - **forward**
|
||||
(can the new version be copied over with zero hand-work, by user or script) and **backward** (can
|
||||
the old version still be reinstalled afterwards) - and made explicit that either half failing,
|
||||
independent of `kb/`, is what MAJOR actually means.
|
||||
|
||||
## Turn 3 - what was written
|
||||
|
||||
**`instructions/dev/version-parts.md` (new).** The single source for the rule. Structure:
|
||||
the two-sided drop-in test (step 1); a catalogue of breaks that cross the boundary with `kb/`
|
||||
untouched - update path, release-artefact name, package import name, a renamed
|
||||
command/flag/envvar, the shape of a machine-read file like `.wikitool-kb.json` (step 2); the
|
||||
plain compatible-part table for patch/minor (step 3); the mandatory stop-and-inform-the-user
|
||||
step before any boundary-crossing bump, with three named alternatives (avoid via a
|
||||
compatibility shim, defer and batch with the next crossing, split into a compatible half now
|
||||
and a deprecated-then-removed half later) plus a recommendation (step 4); `--breaking` usage
|
||||
(step 5); the pre-existing migration-document-or-`--no-migration` step, now explicitly
|
||||
independent of compatibility (step 6); writing the entry body (step 7). Closes with the `2.0.0`
|
||||
case study as the worked example, matching the issue's own account.
|
||||
|
||||
Placed under `instructions/dev/` (not linked from any distributed artifact) because
|
||||
`tools/wikitool dist export` prunes that directory wholesale - the agent's first draft linked to
|
||||
it from `tools/CONTRACT.md` and the `version.py` docstring, which `tools/wikitool instructions
|
||||
verify` correctly rejected:
|
||||
|
||||
```
|
||||
ERROR Instruction layer issues:
|
||||
- version-parts.md: lives under instructions/dev/ but is referenced from
|
||||
outside it and outside a dist:strip block - `dist export` removes
|
||||
instructions/dev/ wholesale, so that reference would dangle in a distributed
|
||||
instance. Remove the reference, or wrap it in a <!-- dist:strip-start/end -->
|
||||
block if it belongs only to this dev instance.
|
||||
```
|
||||
|
||||
**Rejected approach:** wrapping the reference in `<!-- dist:strip-start/end -->` markers so it
|
||||
would still resolve in this repo. Not used - the agent instead rewrote the three shipped
|
||||
locations (`tools/CONTRACT.md`, `version.py` docstring, `version_cmd.py` docstring) to state the
|
||||
short form of the rule standalone, with no pointer to the dev-only file, since a shipped
|
||||
instance never has it to point to.
|
||||
|
||||
**`instructions/dev/stack-dev/SKILL.md`.** Step 3's table changed from "Existing content must
|
||||
be migrated → `--major`" to "Not a drop-in replacement ... → `--major`", with a pointer to
|
||||
`version-parts.md` for the full test and catalogue. A new "Decision points" entry: if a change
|
||||
turns out not to be a drop-in replacement, stop - do not bump across the boundary on the
|
||||
session's own initiative; show the user the concrete break, what each instance must do, and the
|
||||
three alternatives from `version-parts.md` step 4, then wait for a go-ahead. Written in the
|
||||
softer "Decision points" register per the user's second answer above, not gate language.
|
||||
|
||||
**`tools/chemenu/version.py`.** Module docstring reworded: "MAJOR means migration" → "MAJOR
|
||||
breaks", with a new paragraph stating the two questions are independent and naming both markers.
|
||||
New constant `BREAKING_CHANGE_MARKER = "**Breaking Change:**"`, alongside the existing
|
||||
`MIGRATION_NONE_MARKER`. `insert_changes_entry()` gained a `breaking_reason` parameter, writing
|
||||
the `**Breaking Change:**` line *before* the migration line - the break is what an operator acts
|
||||
on first.
|
||||
|
||||
**`tools/chemenu/commands/version_cmd.py`.** `bump` gained `--breaking TEXT`. Refused on a
|
||||
non-boundary-crossing bump; required on a boundary-crossing one, with a refusal message that
|
||||
also second-guesses the part choice itself: *"If that sentence is hard to write because nothing
|
||||
actually breaks - no hand-work on update, and the old version can still be put back - then the
|
||||
bump is probably not --major."* Verified live:
|
||||
|
||||
```
|
||||
$ tools/wikitool version bump --major --title "smoke" --dry-run
|
||||
ERROR 2.4.1 -> 3.0.0 crosses the compatibility boundary, so it is not a drop-in
|
||||
replacement - re-run with --breaking "<what stops working, and what an instance
|
||||
must do about it>".
|
||||
If that sentence is hard to write because nothing actually breaks - no
|
||||
hand-work on update, and the old version can still be put back - then the
|
||||
bump is probably not --major.
|
||||
```
|
||||
|
||||
```
|
||||
$ tools/wikitool version bump --minor --title "smoke" --breaking "x" --dry-run
|
||||
ERROR --breaking only applies to a bump that crosses the compatibility boundary;
|
||||
2.4.1 -> 2.5.0 does not.
|
||||
```
|
||||
|
||||
**`tools/chemenu/commands/docs_verify.py`.** New `check_breaking_change_for_boundary()`,
|
||||
independent of the existing `check_migration_for_boundary()` on purpose - the two check
|
||||
different things (did the corpus move vs. was the operator told the swap isn't drop-in), and a
|
||||
crossing with `kb/` untouched can satisfy the first while failing the second. Wired into
|
||||
`verify()` alongside the existing check.
|
||||
|
||||
**Tests.** `test_version_cmd.py`: nine existing calls updated for the new `breaking=` parameter,
|
||||
plus three new tests (`test_a_boundary_crossing_bump_without_breaking_is_refused`,
|
||||
`test_breaking_records_what_stops_working_in_the_changelog` - including the ordering assertion
|
||||
that `BREAKING_CHANGE_MARKER` precedes `MIGRATION_NONE_MARKER` in the rendered entry -
|
||||
`test_breaking_is_refused_on_a_compatible_bump`). `test_docs_verify.py`: four new tests mirroring
|
||||
the existing migration-boundary tests but for the breaking-change marker. Full suite: 806 passed.
|
||||
|
||||
**`tools/CONTRACT.md` and `INSTALL.md`.** Both reworded to lead with the drop-in question and
|
||||
treat migration as the second, independent one; `INSTALL.md` additionally tells the reader which
|
||||
two lines to look for in release notes (`Breaking Change:` and `Migration:`) before applying an
|
||||
update.
|
||||
|
||||
**`CHANGES.md`.** New `2.5.0` entry written after the bump, body filled in by the agent (the
|
||||
tool leaves it empty by design) - includes the "what deliberately did not change" note that
|
||||
`docs verify` still does not judge *whether* the chosen part was correct, matching the issue's
|
||||
explicit scope-out.
|
||||
|
||||
## Turn 4 - publish, twice
|
||||
|
||||
`tools/wikitool version bump --minor --title "..."` was run first (11 changed files, drop-in in
|
||||
both directions - the new requirement only binds the *next* boundary crossing, not
|
||||
retroactively). `tools/wikitool publish --message "..."` then hit the **Mass-Update Gate**
|
||||
(11 counted files ≥ threshold 10):
|
||||
|
||||
```
|
||||
NEEDS USER CLEARANCE Mass-Update Gate: this publish would commit and push 11
|
||||
counted files (>= threshold 10) to origin/main. ...
|
||||
```
|
||||
|
||||
Per the gate's own instructions, the agent reproduced the full file-by-area breakdown and the
|
||||
`--confirm <token>` line in its reply and ran nothing further that turn. The user replied
|
||||
"freigegeben" for both the earlier publish authorization ("publish ist freigegeben") and, in a
|
||||
separate turn, this specific token. The agent then ran `tools/wikitool publish --confirm
|
||||
95ae372d5677 --message '...'`, which pushed commit `31662dc` to `origin/main`, and verified
|
||||
`git rev-parse HEAD origin/main` matched afterward.
|
||||
|
||||
## Turn 5 - issue closeout and this capture
|
||||
|
||||
The user asked to update issue #26 "wie vorgeschlagen" (per the agent's own end-of-turn
|
||||
suggestion), run `instructions/capture-session.md`, and in the same pass correct
|
||||
`kb/entities/tools/wikitool.md`. The agent posted a comment on #26 summarizing what shipped
|
||||
(including the parts that went beyond the issue's own checklist - the `--breaking` flag and the
|
||||
second `docs verify` check, added because plain prose was judged likely to drift again) and
|
||||
closed the issue. This transcript and the `kb/` correction are the remaining two steps of that
|
||||
request.
|
||||
|
||||
## Outcome
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Version | `2.4.1` → `2.5.0` (`--minor`: new capability, still drop-in both directions) |
|
||||
| Commit | `31662dc` on `main`, pushed to `origin` |
|
||||
| Files changed | 11 (+432/-33): `instructions/dev/version-parts.md` (new), `instructions/dev/stack-dev/SKILL.md`, `tools/CONTRACT.md`, `tools/chemenu/version.py`, `tools/chemenu/commands/version_cmd.py`, `tools/chemenu/commands/docs_verify.py`, `tools/chemenu/tests/test_version_cmd.py`, `tools/chemenu/tests/test_docs_verify.py`, `CHANGES.md`, `INSTALL.md`, `VERSION` |
|
||||
| Tests | 806 passed (`tools/chemenu/tests/`), including 6 new |
|
||||
| Verification | `tools/wikitool docs verify` OK, `tools/wikitool instructions verify` OK (17 instructions, 6 skills, 12 published copies match), `tools/wikitool doctor` clean (only the expected `WIKITOOL_SESSION_ID` WARN) |
|
||||
| Issues | #26 closed, comment `torben/chemenu#26` (issuecomment-474) |
|
||||
| CI | Not yet observed in this session - a `VERSION` move on `main` triggers a tagged release per `.gitea/workflows/release.yml`; not polled |
|
||||
+15
-13
@@ -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 |
|
||||
@@ -67,20 +68,20 @@ tools/wikitool <command> --help
|
||||
| `instructions sync [--force]` | Publish every `instructions/<name>/SKILL.md` into `.agents/skills/` and `.claude/skills/` as **copies**, and delete published skills whose source is gone. Both targets are gitignored, so a fresh clone runs this once - see `instructions/bootstrap.md`. Re-running is also how a drifted copy is repaired: the source always wins. `--force` is required only to replace a target directory that is not a published skill at all (no `SKILL.md` in it) |
|
||||
| `instructions verify` | Check the instruction layer: flat instructions validate against `types/instruction.schema.yaml`, each `SKILL.md` carries the frontmatter its harness reads, every published copy is byte-identical to its source, no instruction is left that nothing references, and nothing under `instructions/dev/` is referenced from outside it (a `<!-- dist:strip-start/end -->` block is exempt - see [instructions/CONTRACT.md](../instructions/CONTRACT.md)). Missing *every* copy is reported as "run sync", not as drift - that is a clean checkout |
|
||||
| `instructions list [--json]` | List the flat instructions with their descriptions. This is how the layer is discovered; `search` deliberately covers `kb/` only |
|
||||
| `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 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) |
|
||||
| `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`, every `kb/*/COLLECTION.md` (no pages, no areas), empty `raw/{articles,documents,notes,assets}/`, `VERSION`, `USER.md.template`/`SOUL.md.template` (the templates ship; a filled `USER.md`/`SOUL.md` never does - the root allowlist is what makes that automatic), 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 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 |
|
||||
| `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 |
|
||||
|
||||
## Design notes
|
||||
|
||||
@@ -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` |
|
||||
|
||||
+27
-11
@@ -39,11 +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/
|
||||
kb_collections.py collection discovery (a directory with COLLECTION.md)
|
||||
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
|
||||
@@ -110,15 +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.** `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. They are named once in `sections.py`, and each 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).
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
repo's machinery.
|
||||
|
||||
`export` copies the pipeline's schema/compiler/control-plane layers (types/,
|
||||
tools/, instructions/, the stage contracts, every kb/*/COLLECTION.md) into an
|
||||
empty target, with no kb/ pages, no raw/ content, and no git history - see
|
||||
instructions/setup-instance.md for what happens after. It never calls git.
|
||||
tools/, instructions/, the stage contracts) into an empty target, with no kb/
|
||||
pages, no raw/ content, and no git history - see instructions/setup-instance.md
|
||||
for what happens after. It never calls git.
|
||||
|
||||
The two binding-but-instance-owned documents under kb/ - each collection's
|
||||
COLLECTION.md and kb/CONVENTIONS.md - cross as `.template` and are adopted by a
|
||||
rename, the same split USER.md/SOUL.md use at the repo root.
|
||||
|
||||
Three independent exclusion mechanisms feed the plan, for three different
|
||||
shapes of "does not belong in someone else's instance":
|
||||
@@ -35,7 +39,7 @@ from typing import Callable, NamedTuple, Optional, Union
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import config, kb_collections, kb_state, version as version_mod
|
||||
from chemenu import config, conventions, kb_collections, kb_state, version as version_mod
|
||||
from chemenu.commands._util import fail, rel_path, success, today_iso
|
||||
|
||||
app = typer.Typer(help="Build a distributable copy of the wiki machinery.")
|
||||
@@ -260,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] = {}
|
||||
@@ -282,19 +340,39 @@ 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
|
||||
))
|
||||
for hook_dir in HOOK_DIRS:
|
||||
plan.update(_copy_tree(config.ROOT / hook_dir, hook_dir, frozenset()))
|
||||
|
||||
# `kb/CONTRACT.md` is stack-owned and ships verbatim; everything beside it
|
||||
# under `kb/` is the instance's own and ships only as a `.template`. That is
|
||||
# the personalization split (`USER.md`/`SOUL.md`) one directory down, and
|
||||
# the reason for it is the same: a distribution can say what the file
|
||||
# decides, never what this instance decided.
|
||||
kb_contract = config.KB_DIR / "CONTRACT.md"
|
||||
if kb_contract.is_file():
|
||||
plan["kb/CONTRACT.md"] = _read_planned_file(kb_contract, "kb/CONTRACT.md")
|
||||
|
||||
conventions_template = config.KB_DIR / conventions.CONVENTIONS_TEMPLATE
|
||||
if conventions_template.is_file():
|
||||
rel = f"kb/{conventions.CONVENTIONS_TEMPLATE}"
|
||||
plan[rel] = _read_planned_file(conventions_template, rel)
|
||||
|
||||
# A collection contract is instance-owned too, but unlike `USER.md` the
|
||||
# shipped content is not *wrong* for the receiver - it is the profile this
|
||||
# repo's own collections adopted, and a fine starting point. So the file
|
||||
# itself ships, under the template name: one source of truth here, and a
|
||||
# receiving instance that has to rename it before it counts. Keeping a
|
||||
# separate `.template` beside each contract would have meant maintaining two
|
||||
# near-identical copies of the same text, which is the drift AGENTS.md
|
||||
# invariant 8 exists to prevent.
|
||||
for collection in kb_collections.iter_kb_collections():
|
||||
rel = f"kb/{collection.name}/COLLECTION.md"
|
||||
plan[rel] = _read_planned_file(collection / "COLLECTION.md", rel)
|
||||
source = collection / kb_collections.CONTRACT_NAME
|
||||
rel = f"kb/{collection.name}/{kb_collections.CONTRACT_NAME}.template"
|
||||
plan[rel] = _read_planned_file(source, rel)
|
||||
|
||||
for relative in CONTRACT_ONLY_STAGES:
|
||||
source = config.ROOT / relative
|
||||
@@ -339,17 +417,39 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
|
||||
# IP literals) was considered and rejected - the project's own host legitimately
|
||||
# appears in INSTALL.md and version.py, so such a scan would either whitelist
|
||||
# the very string it is looking for or cry wolf on every export.
|
||||
#
|
||||
# `COLLECTION.md` and `CONVENTIONS.md` are deliberately *not* on the allowed
|
||||
# list any more. Both bind, and both are the instance's to write, so they cross
|
||||
# the boundary as `.template` and are adopted by a rename - a plan carrying the
|
||||
# filled name would hand a new instance this one's authoring conventions as
|
||||
# though they were the stack's.
|
||||
_CONTENT_PREFIXES = ("kb/", "raw/")
|
||||
_CONTENT_ALLOWED_NAMES = ("CONTRACT.md", "COLLECTION.md", "log.md", ".gitkeep")
|
||||
_CONTENT_ALLOWED_NAMES = (
|
||||
"CONTRACT.md",
|
||||
f"{kb_collections.CONTRACT_NAME}.template",
|
||||
conventions.CONVENTIONS_TEMPLATE,
|
||||
"log.md",
|
||||
".gitkeep",
|
||||
)
|
||||
_INSTANCE_OWNED_KB_FILES = (kb_collections.CONTRACT_NAME, conventions.CONVENTIONS_FILENAME)
|
||||
|
||||
|
||||
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]
|
||||
if name in config.PERSONALIZATION_FILES or name == config.ENVIRONMENT_FILE:
|
||||
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:
|
||||
@@ -394,7 +494,8 @@ def export_command(
|
||||
AGENTS.md/README.md (dev-instance-only marker blocks removed),
|
||||
instructions/ (no instructions/dev/), types/, tools/ (no venv/caches),
|
||||
the .github/hooks/+.vibe session-tracing config plus .claude/settings.json,
|
||||
every kb/*/COLLECTION.md (no pages, no areas), empty
|
||||
kb/CONTRACT.md plus a COLLECTION.md.template per collection and
|
||||
kb/CONVENTIONS.md.template (no pages, no areas), empty
|
||||
raw/{articles,documents,notes,assets}/, VERSION, the USER.md/SOUL.md
|
||||
personalization templates (never the filled files), and a
|
||||
.wikitool-release.json stamp. The --source-*/--release-url/--update-url
|
||||
|
||||
@@ -37,7 +37,7 @@ from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import config, kb_collections, version as version_mod
|
||||
from chemenu import config, conventions, kb_collections, version as version_mod
|
||||
from chemenu.commands._util import fail, rel_path, success
|
||||
|
||||
app = typer.Typer(help="Verify documentation that mirrors the code or repo layout.")
|
||||
@@ -114,6 +114,12 @@ REQUIRED_TRACKED_PATHS = (
|
||||
"instructions/CONTRACT.md",
|
||||
"instructions/wiki-query/SKILL.md",
|
||||
"ENVIRONMENT.md.template",
|
||||
# The one `.template` that lives under a content directory. It is what a
|
||||
# distribution ships in place of this instance's own `kb/CONVENTIONS.md`, so
|
||||
# an ignore rule reaching it would produce exports whose receiving instance
|
||||
# has nothing to fill in - and `find_leaks` refuses to substitute the filled
|
||||
# file, correctly, so the export would simply be missing it.
|
||||
"kb/CONVENTIONS.md.template",
|
||||
)
|
||||
|
||||
CLI_README = config.ROOT / "tools" / "CONTRACT.md"
|
||||
@@ -207,13 +213,22 @@ def check_cli_readme() -> list[str]:
|
||||
|
||||
|
||||
def check_collection_contracts() -> list[str]:
|
||||
"""The three structural rules that define what a collection is.
|
||||
"""The structural rules that define what a collection is, plus what each one
|
||||
has to declare about itself.
|
||||
|
||||
Collections are discovered by contract presence rather than listed here, so
|
||||
`mkdir kb/<name>` + a COLLECTION.md is all it takes to add one. That only
|
||||
works if the inverse is also checked: a directory under kb/ *without* a
|
||||
contract is an unclaimed subtree whose pages obey no local rules, and a
|
||||
contract outside kb/ quietly widens "collection" back out to "any directory".
|
||||
|
||||
Presence alone stopped being enough once the contracts became
|
||||
instance-owned. A `COLLECTION.md` an instance wrote can be about anything,
|
||||
so the two facts the stack still needs from it - which profile it adopted,
|
||||
and whether the stack resolves against it by name - are declared in its
|
||||
frontmatter and checked here (`kb_collections.declaration_issues`), together
|
||||
with the shape of `kb/CONVENTIONS.md`, whose section names the compiler
|
||||
reads.
|
||||
"""
|
||||
issues = []
|
||||
|
||||
@@ -243,6 +258,52 @@ def check_collection_contracts() -> list[str]:
|
||||
if not (config.ROOT / relative_path).exists():
|
||||
issues.append(f"{relative_path} is missing - it is the authoring contract for its stage")
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from typing import Optional
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from chemenu import config, kb_collections, version as version_mod
|
||||
from chemenu import config, conventions, kb_collections, version as version_mod
|
||||
from chemenu.commands import git_publish, instructions_cmd
|
||||
from chemenu.commands._util import rel_path
|
||||
from chemenu.session import ENV_VAR as SESSION_ENV_VAR
|
||||
@@ -213,6 +213,50 @@ def check_personalization() -> Check:
|
||||
return Check("personalization", "OK", f"{', '.join(config.PERSONALIZATION_FILES)} present and filled")
|
||||
|
||||
|
||||
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 the headings its two generated regions
|
||||
render under, the tone examples, the confidence rubric, the naming forms.
|
||||
|
||||
`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 = (
|
||||
"Copy kb/CONVENTIONS.md.template to kb/CONVENTIONS.md and answer it - the KB-language "
|
||||
"step of instructions/setup-instance.md walks it, and instructions/kb-profiles.md has "
|
||||
"the ready-made profiles to adopt"
|
||||
)
|
||||
if not path.is_file():
|
||||
return Check(
|
||||
"conventions", "FAIL",
|
||||
f"kb/{conventions.CONVENTIONS_FILENAME} is missing - this instance has not "
|
||||
"declared how its pages are written",
|
||||
fix,
|
||||
)
|
||||
issues = conventions.declaration_issues()
|
||||
if issues:
|
||||
return Check("conventions", "FAIL", "; ".join(issues), fix)
|
||||
declared = conventions.language() or "unspecified"
|
||||
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}, "
|
||||
f"sections {headings}",
|
||||
)
|
||||
|
||||
|
||||
def check_environment() -> Check:
|
||||
"""Whether this checkout records the environment it works through.
|
||||
|
||||
@@ -399,6 +443,7 @@ def run_doctor() -> list[Check]:
|
||||
check_skills(),
|
||||
check_structure(),
|
||||
check_personalization(),
|
||||
check_conventions(),
|
||||
check_environment(),
|
||||
check_publish_remotes(),
|
||||
check_generated_files(),
|
||||
@@ -411,9 +456,9 @@ def doctor_command(
|
||||
json_out: bool = typer.Option(False, "--json", help="Print the checks as JSON"),
|
||||
):
|
||||
"""Check that this instance is correctly configured: dependencies, author,
|
||||
git identity/remote, published skills, structure, personalization,
|
||||
generated files, and session scoping. Read-only. Exits 1 only if a check
|
||||
FAILs."""
|
||||
git identity/remote, published skills, structure, personalization, KB
|
||||
conventions, generated files, and session scoping. Read-only. Exits 1 only
|
||||
if a check FAILs."""
|
||||
checks = run_doctor()
|
||||
|
||||
if json_out:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -324,7 +324,12 @@ def new_page_command(
|
||||
|
||||
path = target_dir / f"{page_title}.md"
|
||||
body = _apply_template_variables(
|
||||
template, {**frontmatter, "name": name, "today": today.isoformat()}
|
||||
template,
|
||||
{
|
||||
**frontmatter,
|
||||
"name": name,
|
||||
"today": today.isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
write_page(path, frontmatter, body)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""What this instance decided, read from `kb/CONVENTIONS.md`.
|
||||
|
||||
`kb/CONTRACT.md` and this file answer two different questions. The contract
|
||||
holds what the code enforces - what a collection is, which files are generated,
|
||||
how `provenance:` and `confidence_base` work - and is identical in every
|
||||
instance, so `dist export` ships it verbatim. `kb/CONVENTIONS.md` holds what
|
||||
each instance decides for itself: the language its pages are written in, the
|
||||
relationship-label vocabulary, the tone examples, the confidence rubric, the
|
||||
ADR prefix. The distribution ships only `kb/CONVENTIONS.md.template`, exactly
|
||||
the split `USER.md`/`SOUL.md` already use one directory up.
|
||||
|
||||
Only one part of it is machine-read, and it is the part that used to be Python:
|
||||
the three section headings `xref add` and `cite add` write. While
|
||||
`RELATIONSHIPS = "Beziehungen"` sat in `sections.py`, an instance writing its
|
||||
pages in any other language had to edit the compiler to say so - which made the
|
||||
KB language a stack property in code while every document called it an instance
|
||||
decision.
|
||||
|
||||
**A missing conventions file is not an error here.** It is the state an
|
||||
instance is in between installing this machinery and running the migration that
|
||||
writes the file, and every command has to keep working through it. The fallback
|
||||
is `PRE_CONVENTIONS_NAMES` - not "the stack's language", but *what this stack
|
||||
hardcoded before the file existed*, which is by construction what any corpus
|
||||
reaching that state was written with. `wikitool doctor` is what says the file is
|
||||
missing; degrading loudly here would take out `doctor` itself.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from chemenu import config
|
||||
from chemenu.frontmatter_io import read_page
|
||||
|
||||
CONVENTIONS_FILENAME = "CONVENTIONS.md"
|
||||
CONVENTIONS_TEMPLATE = f"{CONVENTIONS_FILENAME}.template"
|
||||
|
||||
# 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"
|
||||
LANGUAGE_KEY = "language"
|
||||
|
||||
# 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",
|
||||
}
|
||||
|
||||
|
||||
def conventions_file() -> Path:
|
||||
return config.KB_DIR / CONVENTIONS_FILENAME
|
||||
|
||||
|
||||
# (path, mtime_ns, size) -> frontmatter. `heading_re()` is called once per page
|
||||
# per lint run, so re-reading the file each time would put a stat+parse on a
|
||||
# per-page path for a document that changes about once per instance. Keyed on
|
||||
# the stat rather than on the path alone, so a test that rewrites the file
|
||||
# inside one process is not answered out of the cache.
|
||||
_CACHE: dict[tuple[str, int, int], dict[str, Any]] = {}
|
||||
|
||||
|
||||
def read_conventions() -> dict[str, Any]:
|
||||
"""`kb/CONVENTIONS.md`'s frontmatter, or `{}` if the file is absent.
|
||||
|
||||
Permissive on purpose, like `read_page` itself: a conventions file with
|
||||
broken YAML degrades to the pre-conventions defaults rather than taking
|
||||
every command down with it. `doctor` and `docs verify` are where that
|
||||
surfaces as a finding.
|
||||
"""
|
||||
path = conventions_file()
|
||||
if not path.is_file():
|
||||
return {}
|
||||
stat = path.stat()
|
||||
key = (str(path), stat.st_mtime_ns, stat.st_size)
|
||||
if key not in _CACHE:
|
||||
frontmatter, _ = read_page(path)
|
||||
_CACHE.clear()
|
||||
_CACHE[key] = frontmatter
|
||||
return _CACHE[key]
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Drop the parsed conventions. For a caller that rewrote the file and has
|
||||
to see the new value within the same stat resolution."""
|
||||
_CACHE.clear()
|
||||
|
||||
|
||||
def _mapping(key: str) -> dict[str, Any]:
|
||||
value = read_conventions().get(key)
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def language() -> Optional[str]:
|
||||
"""The declared KB language tag (e.g. `de`), or None if undeclared.
|
||||
|
||||
Nothing in the compiler branches on it - the language is carried by the
|
||||
prose the instance writes, not by a switch. It is here because the
|
||||
conventions file is where a human and an agent look the answer up, and
|
||||
because `doctor` reports it.
|
||||
"""
|
||||
value = read_conventions().get(LANGUAGE_KEY)
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).strip() or None
|
||||
|
||||
|
||||
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 DEFAULT_HEADINGS.get(block, block.title())
|
||||
|
||||
|
||||
def declaration_issues() -> list[str]:
|
||||
"""What is wrong with this instance's conventions file, if anything.
|
||||
|
||||
Shared by `doctor` (which FAILs on it) and `docs verify` (which refuses a
|
||||
tree with it), so the two cannot disagree about what a valid declaration
|
||||
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 []
|
||||
|
||||
issues: list[str] = []
|
||||
frontmatter, _ = read_page(path)
|
||||
if not frontmatter:
|
||||
return [
|
||||
f"kb/{CONVENTIONS_FILENAME} has no readable frontmatter - it must declare "
|
||||
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(blocks.BLOCKS)} to the heading this instance renders above it"
|
||||
]
|
||||
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}.{block}` is missing or empty - "
|
||||
f"the generated `{block}` region would render under a default heading rather "
|
||||
"than this instance's own"
|
||||
)
|
||||
for block in sorted(set(declared) - set(blocks.BLOCKS)):
|
||||
issues.append(
|
||||
f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}.{block}` is not a region the tool "
|
||||
f"generates; the regions are {', '.join(blocks.BLOCKS)}"
|
||||
)
|
||||
|
||||
if config.TEMPLATE_SENTINEL in path.read_text(encoding="utf-8"):
|
||||
issues.append(
|
||||
f"kb/{CONVENTIONS_FILENAME} still carries the `{config.TEMPLATE_SENTINEL}` line - "
|
||||
"a renamed template is not a filled one"
|
||||
)
|
||||
return issues
|
||||
@@ -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)}")
|
||||
|
||||
@@ -20,11 +20,77 @@ Two corollaries are enforced rather than documented:
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from chemenu import config
|
||||
|
||||
CONTRACT_NAME = "COLLECTION.md"
|
||||
|
||||
# What a collection declares about itself, in `COLLECTION.md`'s frontmatter.
|
||||
#
|
||||
# Presence on the filesystem says a collection *exists*; it cannot say who owns
|
||||
# the rules inside it. A `COLLECTION.md` is instance-owned - the distribution
|
||||
# ships a `.template` per default collection and the instance writes the real
|
||||
# one - so the two facts the stack still needs from it have to be declared
|
||||
# rather than inferred from the directory name, which an instance is free to
|
||||
# choose.
|
||||
PROFILE_FIELD = "profile"
|
||||
REQUIRED_BY_STACK_FIELD = "required_by_stack"
|
||||
|
||||
# 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]:
|
||||
"""Return every collection directory under kb/, sorted by name.
|
||||
@@ -82,6 +148,105 @@ def stray_collection_contracts(root: Path | None = None, kb_dir: Path | None = N
|
||||
return sorted(stray)
|
||||
|
||||
|
||||
def collection_declaration(collection: Path) -> dict[str, Any]:
|
||||
"""A collection's own `COLLECTION.md` frontmatter, or `{}` if it has none.
|
||||
|
||||
Permissive like every other frontmatter read in this package: an unreadable
|
||||
declaration degrades to empty here and is reported by `docs verify`, rather
|
||||
than taking down the discovery every command starts with.
|
||||
"""
|
||||
from chemenu.frontmatter_io import read_page
|
||||
|
||||
contract = collection / CONTRACT_NAME
|
||||
if not contract.is_file():
|
||||
return {}
|
||||
frontmatter, _ = read_page(contract)
|
||||
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.
|
||||
|
||||
Two fields, for two questions the filesystem cannot answer. `profile:`
|
||||
names the entry in `instructions/kb-profiles.md` this collection adopted -
|
||||
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 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 required:
|
||||
if name not in present:
|
||||
issues.append(
|
||||
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):
|
||||
relative = f"kb/{collection.name}/{CONTRACT_NAME}"
|
||||
declared = collection_declaration(collection)
|
||||
if not declared:
|
||||
issues.append(
|
||||
f"{relative} has no frontmatter - it must declare `{PROFILE_FIELD}:` and "
|
||||
f"`{REQUIRED_BY_STACK_FIELD}:` (see instructions/kb-profiles.md)"
|
||||
)
|
||||
continue
|
||||
|
||||
profile = declared.get(PROFILE_FIELD)
|
||||
if not isinstance(profile, str) or not profile.strip():
|
||||
issues.append(
|
||||
f"{relative}: `{PROFILE_FIELD}:` is missing or empty - name the profile from "
|
||||
f"instructions/kb-profiles.md this collection adopted, or `none`"
|
||||
)
|
||||
|
||||
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_flag != expected:
|
||||
issues.append(
|
||||
f"{relative}: `{REQUIRED_BY_STACK_FIELD}: {str(required_flag).lower()}` "
|
||||
f"contradicts the stack, which "
|
||||
+ (
|
||||
"does depend on this collection by name"
|
||||
if expected
|
||||
else "depends on no collection of this name"
|
||||
)
|
||||
+ f" - it must be {str(expected).lower()}"
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def _is_vendored(path: Path, repo_root: Path) -> bool:
|
||||
try:
|
||||
relative = path.relative_to(repo_root)
|
||||
|
||||
@@ -14,9 +14,9 @@ WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)")
|
||||
|
||||
|
||||
# Root-level files under kb/ that are not pages: the generated catalog map, log
|
||||
# and provenance index, plus the contract that constrains the tree rather than
|
||||
# living in it.
|
||||
_KB_META_FILES = {"index.md", "log.md", "provenance.md", "CONTRACT.md"}
|
||||
# and provenance index, plus the two documents that constrain the tree rather
|
||||
# than living in it - the stack's contract and this instance's own conventions.
|
||||
_KB_META_FILES = {"index.md", "log.md", "provenance.md", "CONTRACT.md", "CONVENTIONS.md"}
|
||||
|
||||
# The per-collection authoring contract. Unlike the meta files above it is never
|
||||
# at the kb root - it sits one level down, in every collection - so it has to be
|
||||
|
||||
+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",
|
||||
|
||||
+108
-79
@@ -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,12 +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.
|
||||
CITE_BLOCK_HEADING = f"## {sections.FOOTNOTES}"
|
||||
# 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.
|
||||
#
|
||||
# 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 - "
|
||||
|
||||
_SOURCE_TITLE_PREFIX = "Source - "
|
||||
|
||||
def source_title_prefix() -> str:
|
||||
"""This instance's source-page title prefix, from the type-spec."""
|
||||
from chemenu.type_resolver import resolver
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -102,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)
|
||||
@@ -123,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:
|
||||
@@ -186,44 +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
|
||||
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: str = CITE_BLOCK_HEADING
|
||||
) -> 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."""
|
||||
if not definitions:
|
||||
return ""
|
||||
lines = [heading, ""]
|
||||
def render_cite_block(definitions: dict[str, tuple[str, Optional[str]]]) -> str:
|
||||
"""The citation region for `definitions`, markers included, in dict order.
|
||||
|
||||
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: str = CITE_BLOCK_HEADING,
|
||||
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,47 +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.
|
||||
|
||||
kb/CONTRACT.md's Language rule puts page prose in the KB language. That used to
|
||||
force these three to stay English, because a translated heading did not error -
|
||||
it made `xref add` append a *second* section, silently. This module removes that
|
||||
constraint by making the vocabulary explicit in one place.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
RELATIONSHIPS = "Beziehungen"
|
||||
SEE_ALSO = "Siehe auch"
|
||||
FOOTNOTES = "Fußnoten"
|
||||
|
||||
ALIASES: dict[str, tuple[str, ...]] = {
|
||||
RELATIONSHIPS: ("Relationships",),
|
||||
SEE_ALSO: ("See Also",),
|
||||
FOOTNOTES: ("Footnotes",),
|
||||
}
|
||||
|
||||
|
||||
def names(canonical: str) -> tuple[str, ...]:
|
||||
"""Every name `canonical` is recognized under, canonical first."""
|
||||
return (canonical, *ALIASES.get(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 names(canonical) for canonical in ALIASES)
|
||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chemenu import config
|
||||
from chemenu import config, conventions
|
||||
from chemenu.frontmatter_io import write_page
|
||||
from chemenu.type_resolver import resolver
|
||||
|
||||
@@ -77,9 +77,17 @@ def hermetic_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
# attribute. That binding outlives the test and hands the next one a
|
||||
# corpus directory belonging to the previous tree. Cleared on both sides,
|
||||
# so neither a leak from before nor one from this test can be inherited.
|
||||
# One layer further in again: `conventions` parses `kb/CONVENTIONS.md` once
|
||||
# and keys the result on the file's own path and stat, so a repointed
|
||||
# `KB_DIR` cannot be answered out of it. Cleared here anyway, on both sides,
|
||||
# for the same reason `config.reset()` is - a fixture that leaves state
|
||||
# behind is the hole this file exists to close, and the cost of proving it
|
||||
# cannot leak is one function call per test.
|
||||
config.reset()
|
||||
conventions.reset_cache()
|
||||
yield home
|
||||
config.reset()
|
||||
conventions.reset_cache()
|
||||
|
||||
|
||||
def use_shipped_type_specs(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -163,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):
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""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
|
||||
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
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
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"
|
||||
" links: Beziehungen\n"
|
||||
" footnotes: Fußnoten\n"
|
||||
"---\n\n# conventions\n"
|
||||
)
|
||||
|
||||
FRENCH = (
|
||||
"---\n"
|
||||
"language: fr\n"
|
||||
"profile: none\n"
|
||||
"sections:\n"
|
||||
" links: Relations\n"
|
||||
" footnotes: Notes\n"
|
||||
"---\n\n# conventions\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kb_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
kb = tmp_path / "kb"
|
||||
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()
|
||||
|
||||
|
||||
def _write(kb: Path, text: str) -> None:
|
||||
(kb / conventions.CONVENTIONS_FILENAME).write_text(text, encoding="utf-8")
|
||||
conventions.reset_cache()
|
||||
|
||||
|
||||
def _collection(kb: Path, name: str, profile: str = "none", required: bool = False) -> Path:
|
||||
directory = kb / name
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
(directory / kb_collections.CONTRACT_NAME).write_text(
|
||||
f"---\nprofile: {profile}\nrequired_by_stack: {str(required).lower()}\n---\n\n# {name}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return directory
|
||||
|
||||
|
||||
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_renders_the_headings_the_instance_declared(kb_root):
|
||||
_write(kb_root, FRENCH)
|
||||
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 conventions.heading(blocks.FOOTNOTES) == "Fußnoten"
|
||||
_write(kb_root, FRENCH)
|
||||
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 links: Beziehungen\n---\n")
|
||||
issues = conventions.declaration_issues()
|
||||
assert any("sections.footnotes" in issue for issue in issues)
|
||||
|
||||
|
||||
def test_an_unfilled_template_is_reported_like_a_missing_one(kb_root):
|
||||
_write(kb_root, GERMAN.replace("language: de", f"# {config.TEMPLATE_SENTINEL}\nlanguage: de"))
|
||||
assert any(config.TEMPLATE_SENTINEL in issue for issue in conventions.declaration_issues())
|
||||
|
||||
|
||||
def test_an_absent_file_is_not_a_declaration_issue(kb_root):
|
||||
"""`doctor` FAILs on absence; `docs verify` must not, or a fresh export
|
||||
would be unverifiable before the setup step that writes the file."""
|
||||
assert conventions.declaration_issues() == []
|
||||
|
||||
|
||||
def test_a_collection_must_declare_its_profile_and_stack_dependence(kb_root):
|
||||
_collection(kb_root, "sources", profile="sources", required=True)
|
||||
(kb_root / "notes").mkdir()
|
||||
(kb_root / "notes" / kb_collections.CONTRACT_NAME).write_text("# notes\n", encoding="utf-8")
|
||||
issues = kb_collections.declaration_issues(kb_root)
|
||||
assert any("kb/notes/COLLECTION.md has no frontmatter" in issue for issue in issues)
|
||||
|
||||
|
||||
def test_required_by_stack_is_checked_against_the_stack_not_taken_on_trust(kb_root):
|
||||
"""The one field an instance may not choose. A collection claiming the stack
|
||||
depends on it would make a rename look unsafe when it is not - and, worse,
|
||||
`sources` claiming otherwise would make one look safe when it is not."""
|
||||
_collection(kb_root, "sources", required=False)
|
||||
_collection(kb_root, "entities", required=True)
|
||||
issues = kb_collections.declaration_issues(kb_root)
|
||||
assert any("kb/sources/COLLECTION.md" in issue and "must be true" in issue for issue in issues)
|
||||
assert any("kb/entities/COLLECTION.md" in issue and "must be false" in issue for issue in issues)
|
||||
|
||||
|
||||
def test_a_missing_stack_required_collection_is_reported(kb_root):
|
||||
_collection(kb_root, "entities")
|
||||
assert any("kb/sources/ is missing" in issue for issue in kb_collections.declaration_issues(kb_root))
|
||||
|
||||
|
||||
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)
|
||||
@@ -113,6 +126,14 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
(kb / "entities" / "COLLECTION.md").write_text("# entities collection\n", encoding="utf-8")
|
||||
(kb / "entities" / "aurora.md").write_text("---\ntype: types/entity.md\n---\n", encoding="utf-8")
|
||||
(kb / "CONTRACT.md").write_text("# kb contract\n", encoding="utf-8")
|
||||
(kb / "CONVENTIONS.md").write_text(
|
||||
"---\nlanguage: de\nsections:\n relationships: Beziehungen\n"
|
||||
" see_also: Siehe auch\n footnotes: Fußnoten\n---\n\n# this instance\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(kb / "CONVENTIONS.md.template").write_text(
|
||||
f"<!-- {config.TEMPLATE_SENTINEL} -->\n# conventions template\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
for relative in ("raw/CONTRACT.md", "reports/CONTRACT.md", "work/CONTRACT.md"):
|
||||
path = root / relative
|
||||
@@ -262,6 +283,14 @@ def test_find_leaks_is_silent_on_a_clean_plan(repo):
|
||||
"instructions/dev/commonplace-kb.md",
|
||||
"kb/entities/aurora.md",
|
||||
"raw/notes/personal-note.md",
|
||||
# Both bind every page and both are the instance's to write, so the
|
||||
# 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):
|
||||
@@ -283,12 +312,48 @@ def test_export_refuses_a_plan_that_leaks(repo, tmp_path, monkeypatch):
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_plan_copies_collection_contracts_not_pages(repo):
|
||||
def test_plan_ships_collection_contracts_as_templates_not_pages(repo):
|
||||
"""The stack's own contract crosses verbatim; the instance-owned ones cross
|
||||
under the template name and are adopted by a rename. Shipping
|
||||
`kb/entities/COLLECTION.md` would hand a new instance this one's authoring
|
||||
conventions as though the stack had decided them."""
|
||||
plan = dist_cmd.build_plan()
|
||||
assert "kb/entities/COLLECTION.md" in plan
|
||||
assert "kb/entities/COLLECTION.md.template" in plan
|
||||
assert "kb/entities/COLLECTION.md" not in plan
|
||||
assert "kb/CONTRACT.md" in plan
|
||||
assert not any(relative.endswith("aurora.md") for relative in plan)
|
||||
|
||||
# The shipped template is the contract's own text - one source of truth in
|
||||
# the origin repo, renamed across the boundary. A second file kept beside
|
||||
# each contract would be a near-identical copy, maintained by hand.
|
||||
assert plan["kb/entities/COLLECTION.md.template"].content == "# entities collection\n"
|
||||
|
||||
|
||||
def test_plan_ships_the_conventions_template_and_not_the_filled_file(repo):
|
||||
plan = dist_cmd.build_plan()
|
||||
assert "kb/CONVENTIONS.md.template" in plan
|
||||
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()
|
||||
@@ -397,7 +462,7 @@ def test_export_into_a_fresh_directory_works(repo, tmp_path):
|
||||
target = tmp_path / "dist"
|
||||
dist_cmd.run_export(target, dry_run=False)
|
||||
assert (target / "AGENTS.md").is_file()
|
||||
assert (target / "kb" / "entities" / "COLLECTION.md").is_file()
|
||||
assert (target / "kb" / "entities" / "COLLECTION.md.template").is_file()
|
||||
assert (target / "raw" / "notes" / ".gitkeep").is_file()
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chemenu import config
|
||||
from chemenu import config, conventions
|
||||
from chemenu.commands import doctor, instructions_cmd
|
||||
|
||||
|
||||
@@ -25,11 +25,21 @@ 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"
|
||||
" links: Relationships\n footnotes: Footnotes\n"
|
||||
"---\n\n# conventions\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "VERSION").write_text("0.1.0\n", encoding="utf-8")
|
||||
(root / "USER.md").write_text("# USER.md - Fixture\n", encoding="utf-8")
|
||||
(root / "SOUL.md").write_text("# SOUL.md - Fixture\n", encoding="utf-8")
|
||||
@@ -194,6 +204,32 @@ def test_a_renamed_but_unfilled_template_fails(instance):
|
||||
assert "template" in next(c.detail for c in checks if c.name == "personalization")
|
||||
|
||||
|
||||
def test_conventions_are_ok_when_declared(instance):
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "conventions") == "OK"
|
||||
assert "Relationships" in next(c.detail for c in checks if c.name == "conventions")
|
||||
|
||||
|
||||
def test_missing_conventions_fail(instance):
|
||||
"""Unlike `ENVIRONMENT.md`, this one is not optional: `xref add` and
|
||||
`cite add` write headings out of it, so an instance without it is being
|
||||
answered by whatever the stack hardcoded before the file existed."""
|
||||
(config.KB_DIR / conventions.CONVENTIONS_FILENAME).unlink()
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "conventions") == "FAIL"
|
||||
assert "missing" in next(c.detail for c in checks if c.name == "conventions")
|
||||
|
||||
|
||||
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 links: Relationships\n---\n", encoding="utf-8"
|
||||
)
|
||||
conventions.reset_cache()
|
||||
assert _status(doctor.run_doctor(), "conventions") == "FAIL"
|
||||
|
||||
|
||||
def test_environment_is_ok_when_absent(instance):
|
||||
"""The file is optional, so absence is a healthy end state - a FAIL here
|
||||
would make it mandatory through the back door."""
|
||||
|
||||
@@ -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,6 +75,22 @@ def test_new_entity_creates_page_with_expected_frontmatter(monkeypatch, kb_dir):
|
||||
assert "# gateway.example.net" in body
|
||||
|
||||
|
||||
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):
|
||||
"""provenance and confidence are no longer Typer flag defaults - they
|
||||
come from the schema's own `default:`, so omitting them still yields a
|
||||
|
||||
@@ -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 sections
|
||||
from chemenu.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
@@ -44,10 +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
|
||||
# tool-owned section vocabulary rather than a literal, so that translating the spec - or
|
||||
# the section names themselves - does not turn this into a tripwire.
|
||||
assert f"## {sections.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)
|
||||
|
||||
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}
|
||||
]
|
||||
|
||||
+3
-3
@@ -36,7 +36,7 @@ page_ref_fields: [entities]
|
||||
|
||||
## Autorenanweisungen
|
||||
|
||||
- Ein Titel, der den Vergleich benennt (z. B. "Go vs Rust", "Kubernetes vs Docker Swarm"); er folgt den etablierten Namen der verglichenen Gegenstände, nicht der KB-Sprache (`kb/CONTRACT.md`, Abschnitte "Naming" und "Language")
|
||||
- Ein Titel, der den Vergleich benennt (z. B. "Go vs Rust", "Kubernetes vs Docker Swarm"); er folgt den etablierten Namen der verglichenen Gegenstände, nicht der KB-Sprache (`kb/CONTRACT.md` § "Titles are identifiers", `kb/CONVENTIONS.md` §§ "Naming" und "Language")
|
||||
- Klar darlegen, was verglichen wird und warum
|
||||
- Eine Vergleichstabelle mit den Kriterien als Zeilen verwenden
|
||||
- Eine Analyse, die die Tabelle auswertet statt sie zu wiederholen
|
||||
@@ -68,8 +68,8 @@ TODO: Falls möglich - was wann und für wen zu verwenden ist. Unter welchen Ums
|
||||
|
||||
`# Comparison:` bleibt als Präfix stehen - anders als bei `source` ist es kein `title_prefix`,
|
||||
sondern reine Template-Konvention, und der Seitentitel selbst (`Go vs Rust`) trägt es nicht.
|
||||
Fügt `wikitool xref` eine Beziehung hinzu, entsteht `## Siehe auch`; der Name steht in
|
||||
`tools/chemenu/sections.py`.
|
||||
Fügt `wikitool xref` eine Beziehung hinzu, entsteht der toolgeführte Querverweis-Abschnitt; wie
|
||||
er heißt, entscheidet die Instanz in `kb/CONVENTIONS.md` (`sections:`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+4
-3
@@ -45,7 +45,7 @@ page_ref_fields: [related, sources]
|
||||
|
||||
## Autorenanweisungen
|
||||
|
||||
- Der Titel ist der kanonische Name des Concepts und folgt der etablierten Fachbezeichnung, nicht der KB-Sprache (`kb/CONTRACT.md`, Abschnitte "Naming" und "Language")
|
||||
- Der Titel ist der kanonische Name des Concepts und folgt der etablierten Fachbezeichnung, nicht der KB-Sprache (`kb/CONTRACT.md` § "Titles are identifiers", `kb/CONVENTIONS.md` §§ "Naming" und "Language")
|
||||
- Mit einer klaren Definition beginnen: was das Concept ist
|
||||
- Beispiele geben, wo sie das Verständnis tragen
|
||||
- Auf Entities verlinken, die das Concept umsetzen oder verwenden
|
||||
@@ -90,8 +90,9 @@ TODO: Anti-Muster, Warnungen oder Situationen, in denen es fehl am Platz ist
|
||||
```
|
||||
|
||||
Der Wert hinter `**Typ:**` bleibt der englische Enum-Wert - danach filtert `search --field`.
|
||||
Fügt `wikitool xref` eine Beziehung hinzu, entstehen zusätzlich `## Beziehungen` und
|
||||
`## Siehe auch`; deren Namen stehen in `tools/chemenu/sections.py`.
|
||||
Fügt `wikitool xref` eine Beziehung hinzu, entstehen zusätzlich die beiden toolgeführten
|
||||
Abschnitte für Beziehungen und Querverweise; wie sie heißen, entscheidet die Instanz in
|
||||
`kb/CONVENTIONS.md` (`sections:`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -27,8 +27,21 @@ properties:
|
||||
related:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: object
|
||||
minProperties: 1
|
||||
maxProperties: 1
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Related concept and entity titles
|
||||
description: >-
|
||||
Declared outbound edges. Each entry is either `<label>: <page title>` - the
|
||||
label drawn from instructions/link-taxonomy.md and authorised per
|
||||
destination by the source collection's `outbound:` block - or a bare page
|
||||
title for an edge whose label has not been declared yet. The bare form is
|
||||
the pre-4.0.0 shape and is what `lint` reports until the migration reaches
|
||||
the page; it is accepted rather than rejected so that a corpus stays
|
||||
readable while it is being converted.
|
||||
sources:
|
||||
type: array
|
||||
items:
|
||||
|
||||
+5
-14
@@ -50,7 +50,7 @@ layout:
|
||||
|
||||
## Autorenanweisungen
|
||||
|
||||
- Der Titel ist der kanonische Name der Entity und folgt der etablierten Bezeichnung des Gegenstands, nicht der KB-Sprache (`kb/CONTRACT.md`, Abschnitte "Naming" und "Language")
|
||||
- Der Titel ist der kanonische Name der Entity und folgt der etablierten Bezeichnung des Gegenstands, nicht der KB-Sprache (`kb/CONTRACT.md` § "Titles are identifiers", `kb/CONVENTIONS.md` §§ "Naming" und "Language")
|
||||
- Die Hauptbeschreibung steht weit oben
|
||||
- Auf verwandte Entities und Concepts verlinken, wo Beziehungen bestehen
|
||||
- Bei `provenance: sourced` oder `mixed` harte Fakten inline mit einer `[^cite-id]`-Fußnote belegen -
|
||||
@@ -77,12 +77,6 @@ TODO: 1-2 Absätze dazu, was diese Entity ist und wozu sie dient.
|
||||
- **Verantwortlich:** TODO (falls zutreffend)
|
||||
- **Repository:** TODO (falls zutreffend)
|
||||
|
||||
## Beziehungen
|
||||
|
||||
- **Hängt ab von:** TODO
|
||||
- **Verwendet von:** TODO
|
||||
- **Verwandt mit:** TODO
|
||||
|
||||
## Details
|
||||
|
||||
TODO: Ausführliche Informationen, nach sinnvollen Abschnitten gegliedert
|
||||
@@ -90,15 +84,12 @@ TODO: Ausführliche Informationen, nach sinnvollen Abschnitten gegliedert
|
||||
## Historie
|
||||
|
||||
- [{today}] - Page created via wikitool
|
||||
|
||||
## Siehe auch
|
||||
|
||||
- TODO: Verwandte Seiten
|
||||
```
|
||||
|
||||
`## Beziehungen` und `## Siehe auch` sind toolgeführt: `wikitool xref` schreibt in genau diese
|
||||
Abschnitte, benannt in `tools/chemenu/sections.py`. Der Wert hinter `**Typ:**` bleibt der
|
||||
englische Enum-Wert - danach filtert `search --field`.
|
||||
Der Beziehungsabschnitt steht bewusst **nicht** im Template: er ist eine generierte Region, die
|
||||
`wikitool xref` beim ersten Kanteneintrag zwischen Markern anlegt und aus `related:` neu
|
||||
rendert. Ein Autor schreibt dort nie hinein. Der Wert hinter `**Typ:**` bleibt der englische
|
||||
Enum-Wert - danach filtert `search --field`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -27,8 +27,21 @@ properties:
|
||||
related:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: object
|
||||
minProperties: 1
|
||||
maxProperties: 1
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Related page titles
|
||||
description: >-
|
||||
Declared outbound edges. Each entry is either `<label>: <page title>` - the
|
||||
label drawn from instructions/link-taxonomy.md and authorised per
|
||||
destination by the source collection's `outbound:` block - or a bare page
|
||||
title for an edge whose label has not been declared yet. The bare form is
|
||||
the pre-4.0.0 shape and is what `lint` reports until the migration reaches
|
||||
the page; it is accepted rather than rejected so that a corpus stays
|
||||
readable while it is being converted.
|
||||
sources:
|
||||
type: array
|
||||
items:
|
||||
|
||||
@@ -43,6 +43,21 @@ properties:
|
||||
scriptable; `assisted` needs a judgment call per page and is therefore an
|
||||
agent procedure. Today this is a description rather than an execution
|
||||
promise - there is no `migrate run`.
|
||||
obligation:
|
||||
type: string
|
||||
enum: [required, offered]
|
||||
default: required
|
||||
description: >-
|
||||
Whether the migration must run at all - a separate axis from
|
||||
`migration_kind`, which says only how the work is done. `required`
|
||||
(the default) is the original meaning: the content must reach the new
|
||||
shape or it no longer fits the machinery, so `migrate status` counts it
|
||||
as outstanding and `migrate done` advances `kb_version` through it.
|
||||
`offered` is an upgrade the instance may decline: a file it owns still
|
||||
works as it is, and the stack is proposing a better default. An offered
|
||||
migration never blocks, never appears in the outstanding chain, and is
|
||||
not a link in the version chain - it is listed separately so an operator
|
||||
can take it when they want it.
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
|
||||
+3
-3
@@ -48,7 +48,7 @@ page_ref_fields: [entities, concepts]
|
||||
- `raw_files` listet jede Raw-Datei, die diese Quelle abdeckt (eine Source-Seite pro logischer Quelle, nicht pro Datei)
|
||||
- Bei externen Artikeln immer `source_url` auf die Ursprungs-URL setzen
|
||||
- `source_language` auf die Sprache des Rohmaterials setzen, nicht auf die der Seite
|
||||
- Die Seite wird in der KB-Sprache geschrieben, unabhängig von der Sprache der Quelle; wörtliche Passagen werden im Original zitiert (`kb/CONTRACT.md`, Abschnitt "Language")
|
||||
- Die Seite wird in der KB-Sprache geschrieben, unabhängig von der Sprache der Quelle; wörtliche Passagen werden im Original zitiert (`kb/CONVENTIONS.md` § "Language")
|
||||
- Kernaussagen im Abschnitt Summary zusammenfassen
|
||||
- Handlungsbedarf in den Abschnitt Action Items
|
||||
- Bewusst Weggelassenes in den Abschnitt Not Extracted - siehe unten
|
||||
@@ -108,8 +108,8 @@ TODO: 2-3 Absätze zu den Kernaussagen des Quellmaterials.
|
||||
|
||||
`# Source:` bleibt als Präfix stehen - es spiegelt den `title_prefix` und damit den Titel, unter
|
||||
dem die Seite verlinkt und zitiert wird. Der Wert hinter `**Typ:**` bleibt der englische
|
||||
Enum-Wert. Fügt `wikitool cite` ein Zitat hinzu, entsteht am Seitenende `## Fußnoten`; der Name
|
||||
steht in `tools/chemenu/sections.py`.
|
||||
Enum-Wert. Fügt `wikitool cite` ein Zitat hinzu, entsteht am Seitenende der toolgeführte
|
||||
Fußnoten-Block; wie er heißt, entscheidet die Instanz in `kb/CONVENTIONS.md` (`sections:`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+38
-8
@@ -40,6 +40,30 @@ under `kb/`. It is deliberately **not a collection** and carries no `COLLECTION.
|
||||
no per-collection type surface, and `tools/wikitool docs verify` fails if a contract appears
|
||||
here.
|
||||
|
||||
### Who owns a type-spec
|
||||
|
||||
`types/` holds two kinds of file, and the line between them is `root:` — already in the
|
||||
frontmatter before anyone drew it:
|
||||
|
||||
| Type-spec | Describes | Owned by | Ships as |
|
||||
|---|---|---|---|
|
||||
| `root: kb` (`entity`, `concept`, `source`, `comparison`) | A page **this instance** writes | The instance | `types/<name>.md.template` plus its `.schema.yaml.template`, adopted by a rename |
|
||||
| `root: repo` (`instruction`), no `base_dir` (`lint-report`), and `type-spec` itself | A stack artifact | The stack | Verbatim |
|
||||
|
||||
A page type-spec's prose, its `## Template` body and its language are therefore the instance's
|
||||
to rewrite — an instance writing its pages in another language simply translates the file, and
|
||||
an upgrade does not take that back. Improvements to a shipped default reach it as an *offered*
|
||||
migration ([instructions/CONTRACT.md](../instructions/CONTRACT.md#instructionsmigrations)),
|
||||
never by overwriting.
|
||||
|
||||
**What the stack still requires of the type layer is one line.** There must be a type-spec
|
||||
declaring `name: source` whose schema requires `raw_files:` — the whole `raw/` → `kb/`
|
||||
provenance path (`sources coverage`, `[^cite-id]` resolution, `kb/provenance.md`) asks
|
||||
`page.kind == "source"`, so without it nothing resolves. `docs verify` checks exactly that and
|
||||
nothing beyond it: not the directory, not the title prefix, not a word of the prose. Which
|
||||
collection is stack-required is *derived* from where that type writes rather than listed
|
||||
separately, so renaming it stays consistent instead of tripping a hardcoded name.
|
||||
|
||||
**Quality goal:** a type-spec is the single source of truth for its type. No structural fact
|
||||
about a page type may be restated anywhere else — not in `AGENTS.md`, not in a skill, not in
|
||||
Python. Adding a type must require no code change.
|
||||
@@ -98,17 +122,23 @@ The `## Template` block is filled from the page's own frontmatter, plus `{name}`
|
||||
`{entities|table_cells}`. `{field|literal text}` falls back to the literal when the field is
|
||||
absent.
|
||||
|
||||
**A template never contains a tool-owned region.** The links and footnotes regions are generated
|
||||
between markers by `xref` and `cite`, rendered from frontmatter, and re-rendered on every write -
|
||||
so scaffolding them would create a section an author is forbidden to edit and the tool would
|
||||
replace anyway. See `tools/chemenu/blocks.py`.
|
||||
|
||||
### Ownership boundary
|
||||
|
||||
| Owned here | Owned by `kb/CONTRACT.md` and the collection contracts |
|
||||
|------------|-----------------------------------------------------------|
|
||||
| Frontmatter fields, enums, defaults, required-ness | Quality goal and tone |
|
||||
| Directory placement and title prefix | Naming conventions |
|
||||
| Body skeleton (template) | Linking policy and relationship vocabulary |
|
||||
| When to use / not use this type | Provenance and confidence practice |
|
||||
| Owned here | Owned by `kb/CONTRACT.md` | Owned by `kb/CONVENTIONS.md` and the collection contracts |
|
||||
|------------|--------------------------|-----------------------------------------------------------|
|
||||
| Frontmatter fields, enums, defaults, required-ness | Provenance and citation mechanics | Quality goal and tone |
|
||||
| Directory placement and title prefix | The confidence machinery | Naming conventions and the confidence rubric |
|
||||
| Body skeleton (template) | Linking mechanics and the orphan check | Relationship vocabulary |
|
||||
| When to use / not use this type | The prose/identifier rule | The KB language and its section-heading names |
|
||||
|
||||
If a rule would be identical for every type, it belongs in `kb/CONTRACT.md`, not in a
|
||||
type-spec. If it is identical for every page in one collection, it belongs in that
|
||||
If a rule would be identical for every type *and* every instance, it belongs in
|
||||
`kb/CONTRACT.md`. If every instance would answer it differently, it belongs in
|
||||
`kb/CONVENTIONS.md`. If it is identical for every page in one collection, it belongs in that
|
||||
collection's `COLLECTION.md`.
|
||||
|
||||
### What does not belong here
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Workshop: link-taxonomy-migration
|
||||
|
||||
- **Run key:** `link-taxonomy-migration` (this directory's name - there is no other identifier)
|
||||
- **Input:** none - this run is not an ingest
|
||||
- **Started:** 2026-09-02
|
||||
- **Session id form:** `WIKITOOL_SESSION_ID="link-taxonomy-migration/u<N>"`, one per unit
|
||||
- **Issue:** Gitea #40, sections *Label werden Enum* and *Toolgeführte Blöcke*
|
||||
|
||||
## Goal
|
||||
|
||||
Move every relationship in `kb/` from free-text German prose in a body bullet to a machine
|
||||
value in `related:`, and every tool-owned body region from heading-matching to a marker pair.
|
||||
Afterwards the compiler contains no heading text and no relationship label, and `lint` can
|
||||
enforce the vocabulary because there is one.
|
||||
|
||||
## Why this is `assisted` and not `mechanical`
|
||||
|
||||
Measured on 2026-09-02, against the corpus rather than against the documentation:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Pages | 180 |
|
||||
| Distinct relationship labels in body bullets | **152** |
|
||||
| Labelled bullets | 337 |
|
||||
| Labels occurring exactly once | 102 |
|
||||
| Top 20 labels cover | 167 of 337 |
|
||||
| Bare `- [[X]]` bullets under `## Siehe auch` | 555 |
|
||||
| ...of those, provably redundant (a labelled edge already exists) | 353 |
|
||||
| ...of those, the only connection between the two pages | **202**, across 63 target pages |
|
||||
|
||||
`kb/CONVENTIONS.md` documents thirteen labels. Nothing ever checked that, and the corpus does
|
||||
not follow it - so there is no mapping table to apply, and roughly 539 edges need a judgment
|
||||
call each. A large minority are reverse directions (`Verwendet von` 20x, `implementiert durch`,
|
||||
`Ersetzt durch`), which under authored directional edges are exactly the edges that stop being
|
||||
stored and start being rendered.
|
||||
|
||||
Rejected alternative, recorded so it is not re-proposed: map the top 20 mechanically and set
|
||||
everything else to `see-also`. That would start the new taxonomy with ~370 of ~539 edges on its
|
||||
weakest label - the `verwandt mit` sediment this whole change exists to end, re-created as the
|
||||
documented initial state.
|
||||
|
||||
## Closes when
|
||||
|
||||
Every unit in `plan.md` is published, and:
|
||||
|
||||
- `wikitool lint` reports zero unlabelled edges and zero labels outside the authorising
|
||||
collection's `outbound:` block
|
||||
- `wikitool migrate verify --from <pre-migration rev>` reports no wikilink or citation count
|
||||
change, and no unbalanced marker
|
||||
- `wikitool migrate done 4.0.0 --pages <N>` has run
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] u0 mechanism - taxonomy, `links.py`, `blocks.py`, `xref` rewrite, lint checks,
|
||||
`links show` for the inbound view, deletion of the matching layer. No page touched.
|
||||
- [ ] u1 `kb/entities/` (systems, tools, technologies)
|
||||
- [ ] u2 `kb/entities/` (projects, people) + `kb/comparisons/`
|
||||
- [ ] u3 `kb/concepts/`
|
||||
- [ ] u4 `kb/sources/`
|
||||
- [ ] u5 close-out - `migrate done`, version bump, `CHANGES.md`, workshop close
|
||||
|
||||
## Measured after u0
|
||||
|
||||
`lint` against the corpus, with the machinery in place and no page touched:
|
||||
|
||||
| Finding | Count |
|
||||
|---|---|
|
||||
| `unlabelled_edges` | **480** - every `related:` entry, since none carries a label yet |
|
||||
| `unauthorised_labels` | 0 - nothing declares a label at all, so nothing can be unauthorised |
|
||||
| `malformed_edges` | 0 |
|
||||
| `unbalanced_markers` | 0 |
|
||||
| `orphan_pages` | 1 (`GRUB`, pre-existing) |
|
||||
| `broken_links`, `dangling_frontmatter_refs`, `schema_validation_errors` | 0 |
|
||||
|
||||
480 is the number u1-u4 have to bring to zero. It is larger than the 337 labelled body bullets
|
||||
because `related:` also holds entries whose bullet was lost or never written - which is itself a
|
||||
finding: the frontmatter and the body had already drifted apart under the old model, and nothing
|
||||
could see it while the label lived only in the prose.
|
||||
|
||||
## Open decisions
|
||||
|
||||
- **Settled 2026-09-02:** edges are directional; the reverse edge is authored only when it is a
|
||||
primary statement on its own page. The inbound view is rendered, not stored.
|
||||
- **Settled 2026-09-02:** the 353 provably-redundant `## Siehe auch` edges are dropped
|
||||
mechanically. The 202 that are the only connection get a real label each, or are dropped with
|
||||
a reason - never converted to `see-also` in bulk.
|
||||
- **Settled 2026-09-02:** labels are not localized. `- **depends-on:** [[Hermes]]` is what a
|
||||
German page carries.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Plan: link-taxonomy-migration
|
||||
|
||||
One unit is one session id and one `publish`, sized against the 60-call iteration budget.
|
||||
Per-page cost here is roughly `1 touch` + the edges on it; the corpus-wide commands
|
||||
(`index rebuild`, `sources rebuild-index`, `log append`, `publish` twice for the gate) are
|
||||
per unit, not per page. That puts the ceiling near 45 pages and the target at 40.
|
||||
|
||||
| # | Unit | Pages | Job | Done when |
|
||||
|---|------|------:|-----|-----------|
|
||||
| u0 | mechanism | 0 | Taxonomy catalogue, `links.py`, `blocks.py`, `outbound:` in every `COLLECTION.md`, `xref` rewritten to one directional edge, lint checks, `links show` for the inbound view, `migrate verify` marker invariant, deletion of `sections.py` and the matching layer | Suite green; `lint` reports the corpus's unlabelled edges as findings rather than crashing |
|
||||
| u1 | `kb/entities/systems`, `tools`, `technologies` | ~45 | Label every edge, drop redundant see-also, wrap markers | `migrate verify --path kb/entities --fail-on-error` clean |
|
||||
| u2 | `kb/entities/projects`, `people`, `kb/comparisons` | ~40 | as u1 | as u1 |
|
||||
| u3 | `kb/concepts` | ~45 | as u1 | `migrate verify --path kb/concepts --fail-on-error` clean |
|
||||
| u4 | `kb/sources` | ~50 | as u1, plus `entities:`/`concepts:` on source pages | `migrate verify --path kb/sources --fail-on-error` clean |
|
||||
| u5 | close-out | 0 | `migrate done 4.0.0`, `version bump --major`, `CHANGES.md` body, promote nothing, close workshop | `docs verify` + `lint --fail-on-error` green, workshop deleted |
|
||||
|
||||
Unit boundaries are written down here *before* the run so that publishing several units
|
||||
together stays a planned batch rather than a way around a Mass-Update Gate refusal - see
|
||||
`instructions/gates.md`.
|
||||
|
||||
## Per-page procedure
|
||||
|
||||
1. Read the page's `## Beziehungen` and `## Siehe auch` blocks.
|
||||
2. For each labelled bullet: say the sentence `[this page] <label> [target]`. Pick the catalogue
|
||||
label that makes it true. If it only reads true backwards, the edge belongs on the other
|
||||
page - move it, do not invert the label into something the catalogue does not have.
|
||||
3. For each bare `## Siehe auch` bullet: drop it if a labelled edge already connects the pair
|
||||
(the tooling lists these). Otherwise decide - a real label, or dropped with the reason
|
||||
recorded in the unit's notes.
|
||||
4. Write the edges with `xref add --rel`, never by hand.
|
||||
5. The body blocks are then *generated*: no hand-editing inside a marker pair.
|
||||
|
||||
## Vocabulary carried between units
|
||||
|
||||
`glossary.md` in this directory. A mapping decided in u1 and re-decided in u3 is the failure the
|
||||
file exists to prevent - add to it **before** dispatching the next unit.
|
||||
|
||||
## Deliberately excluded from this run
|
||||
|
||||
- **Commonplace's articulation test and the `connect` report workflow.** They change how ingest
|
||||
proposes links, not how links are stored. Separate question, separate issue.
|
||||
- **Promoting the lint checks to hard errors.** During this run an unlabelled edge is a finding,
|
||||
because that is precisely the migration window `.wikitool-kb.json` exists to represent. The
|
||||
promotion is a later version's change, once the corpus can pass it.
|
||||
- **`sources:` and `[^cite-id]`.** The provenance path is unlabelled by construction and is not
|
||||
part of the link taxonomy.
|
||||
- **Any change to page prose.** This run restates relationships in a new form; it learns
|
||||
nothing new, and a body edit outside a marker pair is out of scope.
|
||||
Reference in New Issue
Block a user