Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87a47cc237 | |||
| d4cbeca5a8 | |||
| 0ba94c63d1 | |||
| 368438e48c | |||
| cd81ba3d4f | |||
| 1b5ffea854 | |||
| d2b1719a4b | |||
| 686c08bb14 | |||
| abe5497cda | |||
| d29d400dd3 | |||
| b1883befc7 | |||
| 56ecfc7fee | |||
| 4e80a07ac7 | |||
| 0b8ca746fa | |||
| 9b461421e8 | |||
| 41f5dfe1cd | |||
| 23307c3c5f |
@@ -66,6 +66,26 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
version="$(cat VERSION | tr -d '[:space:]')"
|
||||
|
||||
# A running candidate (`X.Y.Z-beta.N`) is never released - betas are
|
||||
# a dev-checkout state, not a distributed one (see
|
||||
# instructions/dev/version-parts.md). This guard sits *before* the
|
||||
# API query below: without it, every `version bump` on a candidate
|
||||
# would push VERSION and trigger a wasted round-trip against the
|
||||
# releases API for a tag that was never going to be created. Ending
|
||||
# the job cleanly here (not `exit 1`) is what keeps a beta bump a
|
||||
# normal, unremarkable push rather than a failing CI run - skipping
|
||||
# every later step is what "cleanly" means in Actions: mark this one
|
||||
# skip and gate the rest on it.
|
||||
case "$version" in
|
||||
*-beta.*)
|
||||
echo "VERSION is a running candidate (${version}) - nothing to release. Skipping."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
tag="v${version}"
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||
@@ -82,6 +102,7 @@ jobs:
|
||||
- name: Release notes from CHANGES.md
|
||||
# `version notes` fails when the changelog has no entry for this
|
||||
# version, which is the last place that mistake can still be caught.
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
set -eu
|
||||
tools/wikitool docs verify
|
||||
@@ -90,6 +111,7 @@ jobs:
|
||||
|
||||
- name: Build the distribution tarball
|
||||
id: build
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
@@ -109,6 +131,7 @@ jobs:
|
||||
echo "name=${name}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish the release
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
env:
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
TOKEN: ${{ gitea.token }}
|
||||
|
||||
@@ -71,6 +71,7 @@ What a file is called says who it is for and how it is loaded. This is a rule, n
|
||||
|------|-----|--------|
|
||||
| `README.md` | Humans - technical documentation and how to develop the thing in that directory | Never by an agent as instruction |
|
||||
| `EVALS.md` | Humans - how telemetry and evaluation work; routes to the contracts that bind | Never by an agent as instruction |
|
||||
| `DEVELOPMENT.md` | Humans - the release workflow (`version bump`/`version release`/`publish`/CI), for whoever develops this stack rather than an instance built on it | Never by an agent as instruction. Not shipped: `dist_cmd.ROOT_FILES` excludes it deliberately, the same way `instructions/dev/` (which it may link to, unlike the documents `instructions verify` holds to that rule) is excluded - a distributed instance has no release workflow to document |
|
||||
| `AGENTS.md` | Agents | Always, every session |
|
||||
| `CLAUDE.md` | Agents on Claude Code | Automatically by that harness, which does not load `AGENTS.md` - so it imports this file and the two below, and carries no rules itself. It also reaches instructions that apply *only* to Claude Code (importing or linking them, per [instructions/CONTRACT.md](instructions/CONTRACT.md)), which is the one thing this file cannot do for them: from here they would load into every other harness too |
|
||||
| `USER.md` | Agents | Always, every session |
|
||||
@@ -82,6 +83,7 @@ What a file is called says who it is for and how it is loaded. This is a rule, n
|
||||
| `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`. Split by `root:`: a page type-spec (`root: kb`) belongs to the instance and ships as `.template`; one describing a stack artifact ships verbatim |
|
||||
| `docs/<name>.md` | Agents and humans | By link, or on explicit request - never automatically, and never as instruction |
|
||||
| `INDEX.md` | Both | Generated - never hand-edited |
|
||||
|
||||
A stage may carry both a `README.md` and a `CONTRACT.md`: different readers, different
|
||||
@@ -89,6 +91,16 @@ documents. What it may not carry is the same content twice - a README that resta
|
||||
contract is a second copy that drifts. `docs verify` enforces the specific case that already
|
||||
happened once: no README may hold a copy of the `wikitool` command table.
|
||||
|
||||
**`docs/` carries no normative sentence.** It holds why the stack is built the way it is -
|
||||
background a session consults in passing, not a rule it must follow. Anything that would bind
|
||||
belongs in a `CONTRACT.md` instead, which is what keeps invariant 8 intact here: `docs/` is
|
||||
never a second place a rule could live, only prose about rules that live elsewhere. That is also
|
||||
why nothing verifies its content - there is no rule in it to check. It has no frontmatter, no type, no index, no lint, no decay, no provenance, and no
|
||||
`COLLECTION.md` - which [kb/CONTRACT.md § Collections](kb/CONTRACT.md#collections) forbids
|
||||
outside `kb/` anyway, but the point holds independently: `docs/` stays a plain directory of
|
||||
prose, invisible to everything `tools/wikitool` does except `dist export`, which copies it
|
||||
verbatim. A fresh instance needs the reasoning as much as this one does.
|
||||
|
||||
## Personalization
|
||||
|
||||
`USER.md` and `SOUL.md` are read at session start, if the runtime has not already injected
|
||||
@@ -145,7 +157,8 @@ input schema + compiler output derived (gitignored)
|
||||
work/ tracked scratch, deleted when the run closes
|
||||
```
|
||||
|
||||
Alongside it, not part of it: `instructions/` (what agents are told to do) and this file.
|
||||
Alongside it, not part of it: `instructions/` (what agents are told to do), `docs/` (why the
|
||||
stack is built the way it is - see [File naming](#file-naming)), and this file.
|
||||
|
||||
**By stage** - read the contract for the stage you are writing in:
|
||||
|
||||
@@ -225,7 +238,8 @@ Every `tools/wikitool` call has exactly four outcomes:
|
||||
produced.
|
||||
|
||||
After the single allowed retry - or immediately, for the non-idempotent commands `new`,
|
||||
`log append`, and `publish` - stop and report the exact command and error text to the user.
|
||||
`log append`, `publish`, and `upstream merge` - stop and report the exact command and error
|
||||
text to the user.
|
||||
|
||||
Per-command detail (what exit 1 means, whether the command is atomic, whether a retry is
|
||||
safe) is in [tools/CONTRACT.md](tools/CONTRACT.md). A gate refusal is not a validation error -
|
||||
@@ -263,3 +277,10 @@ and `tools/README.md` are part of the change that introduced a stage, a command
|
||||
not follow-up work: nobody comes back for them, and a document that describes a repo which no
|
||||
longer exists is worse than none. The mechanical half - command tables, contracts, ignore
|
||||
canaries - is checked by `tools/wikitool docs verify`; the prose half is yours.
|
||||
|
||||
`docs/` pages are held to a different clock than those three. A README goes stale on every new
|
||||
flag; a `docs/` page goes stale only when the reasoning it wrote down stops holding - a gate
|
||||
that stops living in code, an ownership line that moves, a boundary redrawn - which is rarer
|
||||
and not tied to any one commit. Nothing checks this by construction: a page there carries no
|
||||
normative sentence (see [File naming](#file-naming)), so there is no rule for `docs verify` to
|
||||
check, only a rationale for a session to notice has gone stale and to update or retire.
|
||||
|
||||
+619
@@ -18,6 +18,625 @@ heading, and `wikitool docs verify` refuses a tree whose `VERSION` and newest
|
||||
versioned entry disagree. Entries below `0.1.0` predate versioning and keep
|
||||
their date-only headings.
|
||||
|
||||
Since `4.4.0` the stack carries **one running candidate** between two
|
||||
releases rather than a fresh version per bump - see
|
||||
`instructions/dev/version-parts.md`. While a candidate is open its heading
|
||||
names it with a `-beta.N` suffix (`## 4.4.0-beta.2 - <date> - <title>`), and
|
||||
every bump of that same candidate updates this one entry in place rather than
|
||||
opening another: the heading's version/date/title move, and the bump's
|
||||
`--title` joins a machine-managed `<!-- wikitool:bumps -->` list right under
|
||||
the entry's `**Author:**` line - written and read by `wikitool version bump`,
|
||||
never by hand. `wikitool version release` is what closes a candidate: it
|
||||
strips the suffix and turns the entry into an ordinary, suffix-free one,
|
||||
leaving the bump-title list as the record of what happened. A distributed
|
||||
instance never sees a `-beta.` version at all (`release.yml` only ever
|
||||
releases a fixed one), so this suffix and the list beneath it are a
|
||||
dev-checkout concern - readable here, never shipped as something to parse.
|
||||
|
||||
---
|
||||
|
||||
## 4.5.1 - 2026-09-04 - issue-tracking - destructive-step invariants, comment-vs-body authority, rename sweep
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
<!-- wikitool:bumps -->
|
||||
- issue-tracking - destructive-step invariants, comment-vs-body authority, rename sweep
|
||||
<!-- /wikitool:bumps -->
|
||||
|
||||
Block 1 aus #47 (gemeinsam mit #29): drei Ergänzungen an
|
||||
`instructions/dev/issue-tracking.md`, ausgelöst durch zwei Fehlerklassen, die
|
||||
in derselben Sitzung am Stack aufgetreten waren.
|
||||
|
||||
- **Schritt 1** trägt jetzt, dass destruktive Schritte im Body die Invariante
|
||||
nennen müssen, die sie nicht verletzen dürfen, und dass ein
|
||||
Akzeptanzkriterium eine prüfbare Eigenschaft ist, keine Tätigkeit. Auslöser
|
||||
war #30: der Body schrieb wörtlich "Arbeitsverzeichnis entfernen" für den
|
||||
`upstream merge`-Ablauf, und genau das wurde zum datenvernichtenden Bug -
|
||||
ein `shutil.rmtree` auf eine Stage mit gitignorierten, nicht
|
||||
rekonstruierbaren Daten.
|
||||
- **Schritt 2** trägt jetzt die Lesesicht auf Body und Kommentare, die es
|
||||
bisher nur aus Autorensicht gab: der Body ist der Stand, Kommentare sind
|
||||
Historie; ein erkennbar veralteter Body wird richtiggestellt statt
|
||||
umgangen; widersprüchliche Kommentare werden nach Beleg aufgelöst, nicht
|
||||
nach Datum. Auslöser war ebenfalls #30 (ein Kommentar empfahl das Gegenteil
|
||||
dessen, was der Body später festlegte) und #10 (ein seit Tagen veralteter
|
||||
Body gegen drei widersprechende Kommentare, zwei davon sich selbst
|
||||
widersprechend).
|
||||
- Neuer Abschnitt **"Renames and other decay in the tracker"** nach Schritt 7:
|
||||
ein Rename ist erst fertig, wenn auch die offenen Issues nachgezogen sind,
|
||||
weil `wikitool` diesen Tracker nicht kennt und nicht kennenlernen soll. Mit
|
||||
der Wegweiser-vs-Beleg-Unterscheidung aus #29 und dem Hinweis, dass auch
|
||||
verschwundene `kb/`-Seiten und private Infrastrukturangaben Issue-Texte
|
||||
altern lassen. Ein neuer Trigger in "When to run" verweist darauf.
|
||||
|
||||
Keine der drei Ergänzungen verschiebt die bestehende Nummerierung der
|
||||
Schritte 1-7 - die Querverweise darauf (u. a. aus
|
||||
`instructions/dev/stack-dev/SKILL.md` auf Schritt 7, aus
|
||||
`kb/concepts/Issue Label Scheme.md` auf Schritt 2) bleiben also gültig, ohne
|
||||
angefasst zu werden.
|
||||
|
||||
Verifiziert: `tools/wikitool instructions verify`, `tools/wikitool docs
|
||||
verify`, beide grün (Prosa-only, kein Interface geändert, PATCH).
|
||||
|
||||
#47 bleibt offen (Block 2: der Skill-Schnitt aus Vorschlag E; Block 3: die
|
||||
beiden Nebenbefunde). #29 bleibt ebenfalls offen: dieser Block deckte nur den
|
||||
Regelabsatz, nicht den noch ausstehenden Pfad-Durchgang durch #4, #5, #15,
|
||||
#21, #16, #26 - der war nicht Teil des Auftrags für diesen Block.
|
||||
|
||||
---
|
||||
|
||||
## 4.5.0 - 2026-09-04 - Beide Update-Wege in Code: upstream merge fuer Clones, dist upgrade fuer Tarball-Instanzen
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
<!-- wikitool:bumps -->
|
||||
- Faktenkorrekturen in der 4.4.0-Prosa; DEVELOPMENT.md ohne zweite Kommandobeschreibung
|
||||
- wikitool upstream merge/verify: code procedure for taking a stack update, ownership.py as the shared stack/instance boundary
|
||||
- upstream merge: combined-commit regression test (edit+add+delete+contract+template+contract-delete in one commit)
|
||||
- upstream merge: keep gitignored local data under a content stage, refuse a merge git never opened, report what actually changed
|
||||
- dist upgrade: apply a stack update, not just detect one (#7)
|
||||
- dist upgrade: Stamp-Semantik nach --keep-local benannt, docs/ownership-and-templates.md auf drei Eigentumsklassen nachgezogen
|
||||
<!-- /wikitool:bumps -->
|
||||
|
||||
Die Prosa zu 4.4.0 - Changelog-Eintrag, `docs/version-model.md`, `instructions/dev/version-parts.md`,
|
||||
`DEVELOPMENT.md` - entstand ungeprüft: kein maschineller Check liest sie, und die Sitzung, die sie
|
||||
schrieb, hat den dafür vorgesehenen Modellwechsel aus `stack-dev` Schritt 6 übersprungen. Ein
|
||||
nachgeholter Durchgang hat drei Fehler gefunden, von denen der erste der teuerste war.
|
||||
|
||||
**Der Befund war an einer Stelle schlicht falsch.** Changelog und `docs/version-model.md`
|
||||
behaupteten, unter dem alten Modell seien Nummern vergeben worden, die „nie ausgeliefert" wurden -
|
||||
im Changelog sogar mit einer erfundenen Zahl („fünf Minor-Bumps ... vier nie ausgeliefert"). Das
|
||||
Gegenteil stimmt: weil `release.yml` auf jede `VERSION`-Bewegung feuerte, wurde **jeder** Bump zu
|
||||
einem echten, getaggten Release. Der 2026-09-03 hat vier davon in sechs Stunden erzeugt (`4.3.0`
|
||||
bis `4.3.3`), zwei für reine Prosa-Änderungen. Der Schaden war nie ein Phantom-Release, sondern
|
||||
dass „Release" aufhörte, etwas zu bedeuten - vier Upgrades an einem Nachmittag sind für einen
|
||||
Konsumenten kein kleineres Versprechen, sondern ein unleserliches. Beide Stellen tragen jetzt den
|
||||
tatsächlichen Vorgang samt Beleg.
|
||||
|
||||
Dazu zwei kleinere Korrekturen: `version-parts.md` nannte den Stack weiterhin `2.x` (er steht bei
|
||||
`4.x`), und `DEVELOPMENT.md` trug eine Tabelle, die für jeden Verify-Befehl ein zweites Mal
|
||||
beschrieb, was er prüft - eine Kopie dessen, was `tools/CONTRACT.md` hält und `docs verify` dort
|
||||
gegen die CLI prüft. Die Tabelle ist raus; dass die Datei selbst außerhalb der von diesem Check
|
||||
abgedeckten Dokumente liegt, steht jetzt an ihrer Stelle. Der Code-seitige Teil davon - ob
|
||||
`DEVELOPMENT.md` in `docs_verify.STAGE_READMES` gehört - hängt an #47, zusammen mit der Lücke im
|
||||
Skill, die den übersprungenen Modellwechsel überhaupt erst unauffällig gemacht hat.
|
||||
|
||||
Kein Verhalten geändert, nur Prosa - und bewusst als laufender Kandidat gelassen statt fixiert:
|
||||
CIs Version-Gate verlangt die `VERSION`-Bewegung, ein Release verlangt sie nicht. Genau dafür gibt
|
||||
es das Modell aus 4.4.0.
|
||||
|
||||
**Zweiter Bump auf demselben Kandidaten (#30):** `git merge upstream/main` behandelt einen
|
||||
bewegten Korpus asymmetrisch - eine gelöschte, upstream-geänderte Seite meldet sich als Konflikt,
|
||||
eine neu angelegte Seite wird still gestaged, nur eine beidseitig gelöschte Seite ist harmlos. Die
|
||||
Prosa-Prozedur in `private-instance.md` § "Taking a stack update" hat das geschlossen, aber mit
|
||||
vier eigenen Fehlern: der Pfadsatz stand dreifach (im Skript, im Kontroll-Grep, implizit in
|
||||
`dist_cmd.py`); eine vom Upstream **gelöschte** Maschinerie-Datei wurde von `git checkout
|
||||
MERGE_HEAD -- <pfad>` still ignoriert, weil das Skript kein `set -e` hatte; ein echter Konflikt in
|
||||
`tools/`/`types/`/`instructions/` endete in einem von der Prosa nirgends erwähnten offenen Merge;
|
||||
und ein *neuer* Maschinerie-Pfad unter einer Content-Stage hätte die Literal-Liste nie erreicht.
|
||||
|
||||
Die Eigentumsgrenze ist jetzt ein Prädikat statt einer Liste: `chemenu/ownership.py`,
|
||||
`is_stack_owned(relative)`, wahr für `<stage>/CONTRACT.md` direkt unter einer Content-Stage
|
||||
(`kb`, `raw`, `work`, `reports`) und für jeden Pfad, der dort auf `.template` endet - nicht
|
||||
rekursiv, `kb/<collection>/COLLECTION.md` bleibt seit #39 instanzeigen. `dist_cmd.py` liest das
|
||||
Modul jetzt statt einer eigenen `_CONTENT_ALLOWED_NAMES`-Liste zu pflegen, und
|
||||
`CONTRACT_ONLY_STAGES` leitet sich aus `ownership.CONTENT_STAGES` ab statt die drei Stage-Pfade
|
||||
ein zweites Mal aufzuschreiben - ein Test hält fest, dass beide Sichten nicht auseinanderlaufen
|
||||
können.
|
||||
|
||||
Neu: `wikitool upstream merge [--remote upstream] [--branch main] [--no-fetch]` und `wikitool
|
||||
upstream verify --since <rev> [--until HEAD]`. `merge` prüft Vorbedingungen (sauberer Baum, kein
|
||||
laufendes Merge, Remote löst auf), warnt statt zu blockieren, wenn `.wikitool-remotes.json` fehlt,
|
||||
hält den Merge offen (`--no-commit --no-ff`), zwingt jede Content-Stage auf die lokale Seite
|
||||
zurück, holt dann über die Vereinigungsmenge der Bäume von `MERGE_HEAD` und `HEAD` genau die
|
||||
stack-eigenen Pfade zurück - inklusive einer Löschung, falls der Upstream einen Maschinerie-Pfad
|
||||
entfernt hat -, verweigert bei verbliebenen unaufgelösten Pfaden ohne zu committen, committet
|
||||
sonst und verifiziert den entstandenen Commit mit derselben Logik wie `verify` - ein Fund dort
|
||||
wird laut gemeldet und **nicht** automatisch zurückgerollt. Nicht idempotent (AGENTS.md § Tool
|
||||
error contract), nicht budget-exempt; `verify` liest nur und ist wie `migrate verify` von der
|
||||
Budget-Gate ausgenommen. Die Mass-Update-Gate greift bei einem Merge-Commit strukturell nicht -
|
||||
das steht jetzt als eigener Absatz in `instructions/gates.md`, mit `upstream merge`s eigener
|
||||
Nachkontrolle als der Sicherung, die hier tatsächlich trägt.
|
||||
|
||||
`private-instance.md` § "Taking a stack update" verweist jetzt auf den Befehl statt das Skript
|
||||
auszuschreiben; die Pfadtabelle bleibt als Erklärung stehen, ist aber nicht mehr die operative
|
||||
Liste. Vorschlag B (eigenes Demo-Repo) bleibt zurückgestellt, siehe #30.
|
||||
|
||||
23 neue Tests unter `test_upstream_cmd.py` (35 Fälle mit der parametrisierten
|
||||
`is_stack_owned`-Tabelle) decken die Fälle aus der Spezifikation ab:
|
||||
gelöschte vs. geänderte vs. neu angelegte Seiten, `kb/CONTRACT.md`- und Template-Änderungen,
|
||||
`kb/entities/COLLECTION.md` bleibt lokal, eine gelöschte `raw/CONTRACT.md` landet, ein neuer
|
||||
Template-Pfad landet, ein offener `work/`-Lauf landet nicht, ein echter `tools/`-Konflikt lässt
|
||||
den Merge offen, ein schmutziger Baum wird unberührt abgewiesen, "bereits aktuell" ist ein No-op,
|
||||
die Publish-Remote-Gate-Warnung, `verify` gegen einen von Hand verpfuschten Merge, und die
|
||||
`dist_cmd`/`ownership`-Konsistenz.
|
||||
|
||||
**Dritter Bump auf demselben Kandidaten:** die im Akzeptanzkriterium geforderte Kombinationsprobe
|
||||
fehlte noch - ein einzelner Upstream-Commit, der Editieren, Anlegen, Löschen einer Seite, eine
|
||||
Contract-Änderung, eine Template-Änderung und eine Contract-Löschung gleichzeitig bewegt. Jetzt
|
||||
als `test_one_upstream_commit_mixing_every_case_at_once` nachgetragen; alle sechs Erwartungen in
|
||||
einem `upstream merge`-Aufruf verifiziert.
|
||||
|
||||
**Vierter Bump: zwei Fehler, die ein Review-Durchgang nach dem Publish gefunden hat.** Beide
|
||||
waren in der ersten Fassung enthalten, beide hätten Daten vernichtet, und keiner der bestehenden
|
||||
Tests hat sie berührt.
|
||||
|
||||
*Erstens: die Content-Stage wurde als Ganzes gelöscht.* `_restore_stage_to_local` hieß in der
|
||||
ersten Fassung `shutil.rmtree(stage_dir)` — die wörtliche Übersetzung des `rm -rf kb raw` aus der
|
||||
Prosa-Prozedur. Für `kb/` und `raw/` ist das harmlos, weil dort nichts Ignoriertes liegt. Für die
|
||||
beiden Stages, die dieses Issue *neu* in den Satz aufgenommen hat, ist es das nicht: `reports/`
|
||||
ist bis auf seinen Contract komplett gitignored und trägt genau die Daten, die nirgends sonst
|
||||
existieren — die Telemetrie-Traces, aus denen `eval score` liest, gespeicherte Eval-Berichte,
|
||||
alte Lint-Reports. In dieser Instanz standen zum Zeitpunkt des Fundes 497 Trace-Verzeichnisse
|
||||
unter `reports/telemetry/`; ein einziger `upstream merge` hätte sie alle gelöscht, und zwar
|
||||
stillschweigend, weil git von ignorierten Dateien nichts meldet. Die Stage wird jetzt über die
|
||||
**getrackten** Pfade beider Bäume zurückgesetzt statt über das Verzeichnis; ignorierte lokale
|
||||
Daten bleiben unberührt. Leergewordene Verzeichnisse werden aufgeräumt, aber nur wirklich leere.
|
||||
|
||||
*Zweitens: ein Merge, den git nie eröffnet hat, hätte die Maschinerie gelöscht.* Der Exit-Code
|
||||
von `git merge --no-commit --no-ff` wird bewusst ignoriert (Konflikte unter den Content-Stages
|
||||
sind erwartet). Nur: wenn git das Merge gar nicht erst eröffnet — unverwandte Historien, eine
|
||||
ignorierte Datei im Weg —, gibt es kein `MERGE_HEAD`, `_tree_paths("MERGE_HEAD")` liefert die
|
||||
leere Menge, und **jeder** stack-eigene Pfad in `HEAD` fällt damit in den Zweig „der Upstream hat
|
||||
ihn gelöscht": `kb/CONTRACT.md`, `raw/CONTRACT.md` und sämtliche Templates werden entfernt. Der
|
||||
Kommando-Ablauf prüft jetzt nach dem Merge-Aufruf, dass tatsächlich ein Merge offen ist, und
|
||||
bricht sonst ab, ohne den Baum angefasst zu haben. Beide Fehler haben je einen Regressionstest,
|
||||
und beide Tests wurden gegen die alte Fassung laufen gelassen, um zu zeigen, dass sie sie
|
||||
tatsächlich fangen.
|
||||
|
||||
Dazu eine Ehrlichkeitskorrektur an der Erfolgsmeldung: sie zählte die *wiederhergestellten*
|
||||
Pfade, nicht die geänderten — ein Merge, der eine Datei bewegt, meldete vier oder fünf. Sie fragt
|
||||
jetzt `git diff` zwischen Vor- und Nach-Commit, kennzeichnet Löschungen, und stimmt damit mit dem
|
||||
überein, was ein Leser nachprüfen würde. `docs/ownership-and-templates.md` hat einen Abschnitt
|
||||
bekommen, warum die Grenze ein Prädikat und keine Liste ist — die Begründung, die dieses Issue
|
||||
erarbeitet hat, gehörte in die Hintergrunddoku und nicht nur in einen Changelog-Eintrag.
|
||||
|
||||
**Fünfter Bump: `wikitool dist upgrade` (#7), der zweite der beiden Update-Wege.** `upstream
|
||||
merge` oben bedient eine Instanz mit gemeinsamer Git-History; `dist upgrade` bedient eine
|
||||
Instanz aus einem Tarball, ohne History, die bislang eine rein manuelle Prozedur in
|
||||
`INSTALL.md` durchlaufen musste - Schritt 4 verlangte einen sha256-Vergleich von Hand gegen den
|
||||
`files`-Block der alten `.wikitool-release.json`.
|
||||
|
||||
Die tragende Regel: die Schreibmenge ist genau der `files`-Block der *neuen*
|
||||
`.wikitool-release.json`, minus was ein Export aus einer leeren Vorlage neu sät
|
||||
(`chemenu.ownership.is_export_stub`, wie bisher schon für `kb/log.md`/`.gitkeep`) oder einmalig
|
||||
sät und danach der Instanz gehört (`chemenu.ownership.is_upgrade_preserved`, neu für
|
||||
`.wikitool-kb.json` und `CHANGES.md`), plus der Stamp selbst. Jeder Kandidatpfad wird gegen die
|
||||
*alte* Instanz-Summe klassifiziert: unverändert wird geräuschlos überschrieben, neu im Release
|
||||
wird angelegt, lokal verändert oder gelöscht wird **nie** still überschrieben - der Lauf bricht
|
||||
mit der vollständigen Liste ab, außer `--keep-local` sagt ausdrücklich, dass die Dateien liegen
|
||||
bleiben sollen. `--prune` entfernt zusätzlich aus dem Release entfallene Dateien, aber nur
|
||||
solche, die seit der Installation unverändert sind.
|
||||
|
||||
Die Migrationskette nach dem Tausch wird aus den `instructions/migrations/` des *neuen* Baums
|
||||
ermittelt (`kb_state.load_migrations` bekam dafür einen `directory`-Parameter) und nur
|
||||
gemeldet, nie ausgeführt - es gibt bewusst kein `migrate run`. Eine bereits gegen die
|
||||
*installierte* Maschinerie offene Kette lässt den Befehl abbrechen, bevor er die Quelle
|
||||
überhaupt öffnet. `kb_state.divergent_files()` (bisher nur von `migrate status` gelesen) ist
|
||||
jetzt eine dünne Hülle um das neue, zwei-Baum-fähige `compare_against_stamp()` - gleiches
|
||||
Verhalten für den bestehenden Aufrufer, wiederverwendbar für `dist upgrade`s eigenen Vergleich.
|
||||
|
||||
Quelle ist immer ein bereits vorhandenes Verzeichnis oder `.tar.gz` - kein Download, das bleibt
|
||||
allein `version check`s Sache. Ein Tarball muss genau ein Top-Level-Verzeichnis enthalten (die
|
||||
Form, in der `release.yml` es baut) und wird gegen eine `.sha256`-Beidatei geprüft, falls eine
|
||||
danebenliegt (fehlt sie: WARN, kein Abbruch). Weitere Abbruchgründe vor jedem Schreiben: fehlende
|
||||
lokale `VERSION`/`.wikitool-kb.json`/Stamp mit `files`-Block, ein schmutziger Arbeitsbaum (kein
|
||||
Git-Repo ist ein WARN, keine Sperre), eine Vorab-Version (`-beta.N`) ohne `--pre`, sowie ein
|
||||
Downgrade; Gleichstand ist ein No-op. Ein Grenzübertritt der Kompatibilität wird laut gemeldet,
|
||||
blockiert aber nicht. Committet und pusht nichts (Invariante 5).
|
||||
|
||||
Gegenüber dem ersten Entwurf des Issues zwei Korrekturen, die dort auch nachgetragen sind: der
|
||||
`files`-Block wurde entgegen der ursprünglichen Annahme bereits vor diesem Bump gelesen
|
||||
(`divergent_files`/`migrate status`), und die Migrationskette war ursprünglich falsch begründet
|
||||
- sie kann nur aus dem *neuen* Baum kommen, nicht durch eine andere Abfragereihenfolge aus der
|
||||
alten Instanz. 24 neue Tests unter `test_dist_upgrade.py` decken die Klassifikation, alle
|
||||
Abbruchgründe, `--keep-local`, `--prune` und beide Quellformen (Verzeichnis und Tarball,
|
||||
inklusive der sha256- und Top-Level-Prüfung) ab.
|
||||
|
||||
Bewusst nicht angetastet: `instructions/private-instance.md` (der Clone-Weg ändert sich nicht,
|
||||
`INSTALL.md` benennt jetzt beide Wege nebeneinander) und die Frage, wie `dist upgrade` mit
|
||||
Collection-Templates umgeht, deren Namen eine fremde Instanz gar nicht hat - es verhält sich wie
|
||||
`upstream merge` und schreibt sie, was ein eigenes Issue gegen den Export wäre, keins gegen das
|
||||
Upgrade.
|
||||
|
||||
**Sechster Bump: die ungeprüfte Phase nachgeholt.** Der Abschluss des vorigen Bumps lief auf
|
||||
Sonnet, und `stack-dev` Schritt 6 verlangt dort genau zwei Dinge, die kein Check erzwingt:
|
||||
Issue-Body und `docs/`-Veralterung. Der Body war gemacht, die `docs/`-Prüfung nicht - sie wurde
|
||||
benannt statt durchgeführt. Nachgeholt auf Opus, mit einem Fund.
|
||||
|
||||
`docs/ownership-and-templates.md` § „The consequence in practice" beschrieb ein Upgrade als
|
||||
Zweiteilung: verbatim überschreiben, `.template`-gestützte Dateien liegen lassen - und begründete
|
||||
den ersten Teil damit, dass verbatim ausgelieferte Dateien „safe to replace wholesale" seien,
|
||||
weil sie „never instance-specific to begin with" waren. Genau diese Annahme trifft `dist upgrade`
|
||||
nicht: eine Instanz *kann* eine verbatim ausgelieferte Datei angefasst haben, und die sha256 je
|
||||
Datei existiert, um das zu erkennen, statt es vorauszusetzen. Dazu fehlte die dritte Klasse ganz -
|
||||
die einmalig gesäten, danach instanzeigenen Pfade (`.wikitool-kb.json`, `CHANGES.md`,
|
||||
`kb/log.md`, `raw/*/.gitkeep`), die im Stamp stehen wie jede andere Datei und deshalb aktiv
|
||||
ausgeschlossen werden müssen. Die Seite nennt jetzt drei Klassen und die engere praktische Regel:
|
||||
überschreibe die verbatim ausgelieferten Dateien, *die diese Instanz nicht angefasst hat*.
|
||||
|
||||
Dazu eine Präzisierung in `tools/CONTRACT.md`, die vorher nirgends stand: nach `--keep-local`
|
||||
wird der neue Stamp trotzdem vollständig geschrieben, trägt also die Release-Summe auch für
|
||||
Dateien, die bewusst *nicht* geschrieben wurden. Der Stamp ist die Vergleichsbasis für den
|
||||
nächsten Lauf, kein wörtliches Inventar der Platte - und genau das hält eine übersprungene Datei
|
||||
bei jedem weiteren Lauf als abweichend gemeldet, statt sie nach einmaligem Überspringen still als
|
||||
aktuell zu führen.
|
||||
|
||||
---
|
||||
|
||||
## 4.4.0 - 2026-09-03 - Versionskandidat statt Bump-pro-Release: VERSION traegt -beta.N, version release fixiert
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
<!-- wikitool:bumps -->
|
||||
- Versionskandidat statt Bump-pro-Release: VERSION traegt -beta.N, version release fixiert
|
||||
<!-- /wikitool:bumps -->
|
||||
|
||||
Bisher bekam jeder `version bump` sofort eine fixierte Nummer, und weil `release.yml` auf jede
|
||||
`VERSION`-Bewegung feuert, wurde daraus sofort ein Release: Nummern entstanden in
|
||||
Commit-Granularität statt in Release-Granularität. Der 2026-09-03 hat so vier Releases in sechs
|
||||
Stunden erzeugt (`4.3.0` bis `4.3.3`), zwei davon für reine Prosa-Änderungen - alle vier echt,
|
||||
keines davon eine Einheit, an der ein Konsument sich hätte orientieren können. `VERSION` trägt
|
||||
jetzt zwischen zwei Releases **einen** laufenden Kandidaten (`X.Y.Z-beta.N`):
|
||||
`--major/--minor/--patch` eskaliert diesen Kandidaten max-wins gegen den letzten Release, statt
|
||||
eine neue Nummer danebenzustellen, und geht dabei nie zurück.
|
||||
|
||||
`Version` versteht den Suffix, mit einer expliziten Ordnung
|
||||
(`4.4.0-beta.1 < 4.4.0-beta.2 < 4.4.0`, numerisch nach `N`, nicht lexikografisch). `CHANGES.md`
|
||||
trägt genau einen offenen Eintrag pro Kandidat: der erste Bump eröffnet ihn, jeder weitere
|
||||
aktualisiert Heading und die maschinenverwaltete Bump-Titel-Liste in
|
||||
`<!-- wikitool:bumps -->` (Marker-Konvention aus `blocks.py`, aber bewusst nicht in
|
||||
`blocks.BLOCKS` - diese Region gehört zu `CHANGES.md`, nicht zu einer Seite). `version release`
|
||||
ist neu und fixiert einen Kandidaten: Suffix weg, Eintrag geschlossen, committet und pusht nichts.
|
||||
|
||||
Vier Stellen am Bestand angepasst, die das Kandidatenmodell sonst still beschädigt hätten:
|
||||
`release.yml` überspringt einen suffixbehafteten `VERSION`-Push sauber, bevor die Releases-API
|
||||
gefragt wird, statt jeden Beta-Bump zu veröffentlichen; die Grenzübertritts-Checks in
|
||||
`docs verify` (`check_migration_for_boundary`, `check_breaking_change_for_boundary`) messen jetzt
|
||||
gegen den **letzten Release** (`version_mod.last_release`) statt gegen den zweitobersten Eintrag,
|
||||
der zwischen zwei Betas keine Grenze mehr hergibt; `kb_state.chain()`/`next_link()` vergleichen
|
||||
gegen die **Kandidatenbasis**, weil eine Migration mit Ziel `4.4.0` sonst bei installiertem
|
||||
`4.4.0-beta.1` aus dem Intervall fällt (`4.4.0-beta.1 < 4.4.0`); `read_kb_version()` verweigert
|
||||
einen Prerelease, weil eine Inhaltsform kein Beta kennt. `dist export` schreibt `VERSION` und den
|
||||
Stamp weiterhin ehrlich mit Suffix, aber `.wikitool-kb.json` bekommt die Basis.
|
||||
|
||||
Menschendoku für die Erzeuger-Seite: `DEVELOPMENT.md` im Repo-Root, bewusst nicht in
|
||||
`dist_cmd.ROOT_FILES` (Begründung als Kommentar dort), mit Zeile in `AGENTS.md` § File naming und
|
||||
Zeiger aus `README.md`. `docs/version-model.md` hat einen neuen Abschnitt, warum eine Nummer erst
|
||||
durch ein Release verbraucht wird.
|
||||
|
||||
---
|
||||
|
||||
## 4.3.3 - 2026-09-03 - Modellwahl nach Pruefbarkeit statt nach Aufgabenname; stack-dev bricht an den Phasenwechseln fuer den Model-Switch
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
`instructions/claude-code-model-selection.md` routete bisher nach Skill: eine Zeile "Stack
|
||||
development -> Opus/high" fuer alles, was `tools/`, `types/` oder `instructions/` anfasst. Das ist
|
||||
zu grob in beide Richtungen - es verteuert die lange, mechanische Mitte einer Stack-Sitzung, und es
|
||||
sagt nichts darueber, dass Anfang und Ende derselben Sitzung anders zu behandeln sind.
|
||||
|
||||
**Die neue Achse ist "was faengt hier einen Fehler ab".** Wo ein Check in Code steht - `pytest`,
|
||||
`docs verify`, `instructions verify`, CI, die Gates - kostet der Fehler eines schwaecheren Modells
|
||||
eine Runde und faellt auf. Wo die einzige Durchsetzung eine Sitzung ist, die Prosa liest, faellt
|
||||
derselbe Fehler gar nicht auf: er wird ausgeliefert und bleibt stehen. Das ist dasselbe Argument,
|
||||
das `docs/why-gates-are-code.md` fuer Gates fuehrt, angewandt auf die Modellwahl.
|
||||
|
||||
Stack-Entwicklung ist damit **nicht mehr eine Zeile, sondern drei**:
|
||||
|
||||
| Phase | Was einen Fehler faengt | Modell |
|
||||
|---|---|---|
|
||||
| Design, Versionsstelle, Grenzuebertritts-Urteil | nichts | Opus/high |
|
||||
| Code, Tests, mechanische Doku-Synchronisation | pytest, CI, `docs verify` | Sonnet/high |
|
||||
| Issue-Abschluss, `docs/`-Veralterung, Changelog-Prosa | nichts, per Konstruktion | Opus/high |
|
||||
|
||||
Die Mitte ist die lange Phase und die mit den Checks - dort liegt die Ersparnis. Die beiden
|
||||
Raender sind kurz (Minuten, nicht Stunden), haben aber keinen maschinellen Waechter: `wikitool`
|
||||
kennt den Issue-Tracker bewusst nicht, und eine `docs/`-Seite traegt keinen normativen Satz, also
|
||||
gibt es dort nichts zu verifizieren. Sie oben zu lassen ist billig und schuetzt genau die Arbeit,
|
||||
die still scheitert.
|
||||
|
||||
Zwei Praezisierungen dazu: **Effort ist der billigere Hebel als das Modell** - `medium` steht fuer
|
||||
Stack-Arbeit bewusst in keiner Zeile, weil Mehrdatei-Konsistenz das ist, was ein reduzierter
|
||||
Effort zuerst aufgibt; `high` ist die Untergrenze, sobald mehr als eine Datei oder ein Contract
|
||||
betroffen ist. Und die Asymmetrie ist benannt: eine unnoetige Opus-Phase kostet einmal Geld, eine
|
||||
ungepruefte Sonnet-Phase kann etwas ausliefern, das nie wieder jemand ansieht.
|
||||
|
||||
**Damit die Tabelle ueberhaupt wirksam wird, braucht sie Haltepunkte.** Eine Sitzung kann ihr
|
||||
eigenes Modell nicht wechseln - das ist `/model` und gehoert dem Nutzer. Eine Empfehlung, die
|
||||
niemand zum richtigen Zeitpunkt ausspricht, aendert nichts. `instructions/dev/stack-dev/SKILL.md`
|
||||
bekommt deshalb zwei ausdrueckliche Breaks:
|
||||
|
||||
- **Neuer Schritt 3** - "Settle the design before building", mit dem Angebot zum Wechsel nach
|
||||
unten, sobald der Plan steht und die Arbeit mechanisch wird. Einmal aussprechen, dann so oder
|
||||
so weiterarbeiten.
|
||||
- **Schritt 6 (Abschluss) bricht in die Gegenrichtung** - ab dort greift wieder kein Check. Mit
|
||||
der ausdruecklichen Auflage, die Arbeit **unabhaengig von der Antwort** zu tun: nach dem Publish
|
||||
auf einen Modellwechsel zu blockieren wuerde genau den Zustand hinterlassen, den Schritt 6
|
||||
verhindern soll. Lief die Phase auf dem billigeren Modell, gehoert das in die Uebergabe statt
|
||||
ins Schweigen.
|
||||
|
||||
Ein auftauchender Grenzuebertritt ist unter den Decision points ebenfalls als Anlass zum Wechsel
|
||||
nach oben benannt: `docs verify` prueft, dass ein Uebertritt sich dokumentiert, nie dass die
|
||||
Stelle richtig gewaehlt war.
|
||||
|
||||
Die uebrigen Schritte sind unveraendert und nur umnummeriert (alt 3-5 -> neu 4-6).
|
||||
|
||||
---
|
||||
|
||||
## 4.3.2 - 2026-09-03 - stack-dev: Issue-Abschluss ist ein nummerierter Schritt, kein Zeiger in einer Routing-Liste
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
Nachfassen zu 4.1.2 (#44), das die Regel geschaerft, aber den Weg zu ihr nicht geaendert hat.
|
||||
`instructions/dev/issue-tracking.md` bekam damals Schritt 7 ("Closing is the last body update,
|
||||
not a comment"); `instructions/dev/stack-dev/SKILL.md` bekam nur eine umformulierte Zeile in
|
||||
seiner Routing-Liste. Eine Stunde spaeter schloss #45 auf exakt dieselbe Weise: gruendlicher
|
||||
Abschlusskommentar ueber einem Body mit unangehakten Kriterien.
|
||||
|
||||
**Die Ursache lag nicht am Text der Regel, sondern an ihrer Erreichbarkeit.** Die nummerierten
|
||||
Schritte des Skills endeten bei "Verify before publishing". Ein Issue zu schliessen war ueberhaupt
|
||||
kein Schritt - es hing an einem Zeiger *innerhalb* von Schritt 2, und Schritt 2 ist eine
|
||||
Routing-Tabelle aus fuenf "read X before Y"-Eintraegen, keine Checkliste. Eine Sitzung folgt dem
|
||||
Spine, den sie im Kontext hat; was nur hinter einem Link steht, wird genau in dem Moment nicht
|
||||
aufgeschlagen, in dem es greift - am Ende einer langen Sitzung, wenn der Kontext am vollsten und
|
||||
die verbleibende Instruktionsflaeche am duennsten ist.
|
||||
|
||||
Verschaerfend arbeitete der Blurb gegen seine eigene Regel: fett gesetzt war "keep it current as
|
||||
the state moves, **not at the end**". Wer den Body unterwegs ungefaehr gepflegt hatte, las daraus
|
||||
Konformitaet - der eigentliche Abschlusstest stand nur in der verlinkten Datei.
|
||||
|
||||
Geaendert:
|
||||
|
||||
- **Neuer Schritt 5 in `stack-dev/SKILL.md`** - "Close the issue with a body rewrite, not a
|
||||
comment", mit dem Test inline (Kriterien abgehakt oder mit Begruendung gestrichen,
|
||||
Entscheidungen als entschieden formuliert, kein Praesens ueber einen behobenen Defekt,
|
||||
Verifikation benannt) und dem Verweis auf Schritt 7 fuer die volle Form. Damit steht der
|
||||
Abschluss auf dem Spine.
|
||||
- **Schritt-2-Blurb rebalanciert** - beide Haelften binden jetzt sichtbar: fortlaufende Pflege
|
||||
*und* der Rewrite vor dem Schliessen, mit Verweis auf Schritt 5.
|
||||
|
||||
Nichts davon ist maschinell pruefbar, und das bleibt richtig so: `wikitool` kennt den Tracker
|
||||
nicht und darf ihn nicht lernen, weil es an Instanzen ausliefert, die kein Board haben
|
||||
(`issue-tracking.md` § "What no tool checks"). Der Skill-Spine ist die einzige Durchsetzung, die
|
||||
es geben kann - was der Grund ist, den Schritt zu nummerieren statt ihn zu verlinken.
|
||||
|
||||
Verallgemeinerbar: eine Regel, die in eine verlinkte Instruction geschrieben wird, erreicht
|
||||
Sitzungen nur, wenn die nummerierten Schritte des zustaendigen Skills sie in dem Moment
|
||||
ansteuern, in dem sie greift.
|
||||
|
||||
---
|
||||
|
||||
## 4.3.1 - 2026-09-03 - docs/ befuellt - Stack-Hintergrund fuer vier Themen, Pflegeklausel in AGENTS.md ergaenzt
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
Gitea #45: die von #38 angelegte, bis dahin leere `docs/` bekommt ihre ersten vier Seiten - frisch
|
||||
geschrieben, nicht durch Umzug aus `kb/` befuellt, jede ohne normativen Satz und mit Verweis auf
|
||||
das bindende Dokument statt einer Wiederholung seiner Regeln:
|
||||
|
||||
- `docs/pipeline-rationale.md` - warum `raw -> types/tools -> kb -> reports` vier getrennte Stufen
|
||||
sind und was "never re-derive, always compile" praktisch bedeutet
|
||||
- `docs/why-gates-are-code.md` - warum Mass-Update-, Publish-Remote- und Iteration-Budget-Gate in
|
||||
`tools/wikitool` statt in einer Instruktion stehen
|
||||
- `docs/ownership-and-templates.md` - der Unterschied zwischen stack-eigenen, verbatim
|
||||
ausgelieferten Dateien und instanz-eigenen `.template`-Dateien
|
||||
- `docs/version-model.md` - warum Drop-in-Kompatibilitaet und Migrationsbedarf zwei unabhaengige
|
||||
Fragen sind, illustriert an der 2.0.0-Fallstudie
|
||||
|
||||
**AGENTS.md § Changelog:** neue Klausel zur Pflege von `docs/`, ergaenzt neben der bestehenden
|
||||
Regel zu `README.md`/`EVALS.md`/`tools/README.md`. Eine `docs/`-Seite veraltet nicht wie ein
|
||||
README bei jedem neuen Flag, sondern nur, wenn die aufgeschriebene Begruendung selbst nicht mehr
|
||||
traegt - per Konstruktion ungeprueft, da die Seite keinen normativen Satz enthaelt, den
|
||||
`docs verify` pruefen koennte.
|
||||
|
||||
---
|
||||
|
||||
## 4.3.0 - 2026-09-03 - docs/ als ausgelieferter Hintergrund-Ort; Decision-Seiten bleiben in kb/, Decay-Skip fuer concept_type: decision
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
Gitea #38: `dist export` lieferte bislang keine einzige `kb/`-Seite aus - eine frische Instanz
|
||||
bekam den Stack, aber keinen Grund für seine Form. Die dokumentierte `adr-NNN-`-Konvention in
|
||||
`kb/concepts/COLLECTION.md` existierte zudem nur auf Papier: keine der sieben
|
||||
`concept_type: decision`-Seiten folgte ihr, und `confidence_decay()` lief bedingungslos über sie
|
||||
- ein Kategorienfehler, weil Zeitablauf eine Entscheidung nicht falscher macht, nur Supersession
|
||||
tut das.
|
||||
|
||||
**Neu:** `docs/` - ein inertes Verzeichnis für Stack-Hintergrund (warum der Stack so gebaut ist,
|
||||
nicht was diese Instanz entschieden hat). Keine Frontmatter, kein Typ, kein Index, kein Lint,
|
||||
keine Decay, keine Provenance, keine `COLLECTION.md`. `dist export` liefert es verbatim aus, wie
|
||||
`instructions/` und `types/`. Befüllung folgt in Gitea #45.
|
||||
|
||||
**Verworfen, nach Prüfung:** ein Umzug der sieben Decision-Seiten nach `decisions/`. Der
|
||||
Subtyp-Floor aus #28 verlangt mindestens eine Seite je deklariertem `concept_type`, und ein
|
||||
Umzug hätte `decision` auf null gebracht; dazu zeigen 89 Wikilinks aus `kb/` sowie
|
||||
tool-eigene Frontmatter-Arrays auf die sieben, und `links.py`/`xref add` kennen kein Ziel
|
||||
außerhalb `kb/`. Die sieben bleiben in `kb/concepts/`, ebenso ein zweiter, separat erwogener
|
||||
Rename (`docs verify` → `parity verify`) - der wäre nur nötig gewesen, wenn ein Befehl auf das
|
||||
Verzeichnis `docs/` wirkt, und keiner tut das.
|
||||
|
||||
**Geändert:**
|
||||
- `confidence_decay()` überspringt `concept_type: decision` strukturell (kategorische Ausnahme,
|
||||
nicht als Brücke gebaut - Begründung im Docstring).
|
||||
- `kb/concepts/COLLECTION.md` § Decisions ersetzt die tote ADR-Vorlage durch die real gelebte
|
||||
Form: eine Entscheidung ist eine gewöhnliche Concept-Seite, organische Prosa, kein
|
||||
`adr-NNN-`-Präfix, `**Status:**` optional, Supersession per `supersedes`-Link.
|
||||
- `kb/CONVENTIONS.md` § Naming und `instructions/kb-profiles.md` (Profil `german`) korrigiert -
|
||||
beide dokumentierten noch die verworfene `adr-NNN-`-Namensregel.
|
||||
- `AGENTS.md` § File naming und § Routing: `docs/`-Zeile, plus die Regel, dass `docs/` keinen
|
||||
normativen Satz trägt (das hält Invariante 8 heil - was binden würde, gehört in einen
|
||||
Contract).
|
||||
- `tools/CONTRACT.md`: Klarstellung, dass `docs verify` Dokumentations-Parität prüft, nicht das
|
||||
`docs/`-Verzeichnis, sowie `docs/` in der `dist export`-Zeile ergänzt.
|
||||
|
||||
Additiv und in beide Richtungen drop-in: eine bestehende Instanz ohne `docs/` exportiert
|
||||
weiterhin identisch (leerer `_copy_tree`-Treffer), eine Instanz mit `docs/` bekommt es ab jetzt
|
||||
mitgeliefert. Kein Feld, kein Kommando ändert sein Verhalten für bestehenden Inhalt.
|
||||
|
||||
**Migration:** none required.
|
||||
|
||||
Berührt: `tools/chemenu/commands/confidence_decay.py`, `tools/chemenu/commands/dist_cmd.py`,
|
||||
`tools/chemenu/tests/test_confidence_decay.py`, `tools/chemenu/tests/test_dist_cmd.py`,
|
||||
`kb/concepts/COLLECTION.md`, `kb/CONVENTIONS.md`, `instructions/kb-profiles.md`, `AGENTS.md`,
|
||||
`tools/CONTRACT.md`.
|
||||
|
||||
---
|
||||
|
||||
## 4.2.0 - 2026-09-03 - Korpus-Kuratierungsrichtlinie: Untergrenzen und Leitplanke für reaktive Fixes
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
Ein Demo-Korpus will klein und stabil sein, ein Testbett groß, unordentlich und in Bewegung -
|
||||
dieses Repo verlangt seit der Veröffentlichung beides vom selben `kb/` (Gitea #28). Die Sitzung
|
||||
vom 2026-09-02 hatte Fixture, `--with-demo` und ein zweites Repo bereits verworfen; offen blieb
|
||||
nur, wie kuratiert "kuratiert genug" heißt und welche Leitplanke reaktive Fixes bekommen.
|
||||
|
||||
**Neu:** `instructions/dev/corpus-policy.md`. Fünf Untergrenzen, jede mit einer bestehenden
|
||||
`wikitool`-Prüfung messbar, keine davon durch neuen Tool-Code: jeder Seitentyp und jeder
|
||||
deklarierte Subtyp mit mindestens einer Seite, mindestens fünf Seiten mit mindestens drei
|
||||
Quellen, ein bis zehn Orphan-Seiten, im Schnitt mindestens vier ausgehende Wikilinks pro Seite.
|
||||
Gemessen am 2026-09-03: 181 Seiten, alle Typ-/Subtyp-Floors erfüllt, 12 Seiten mit ≥3 Quellen, 3
|
||||
Orphans, Ø 6,2 ausgehende Links - der Korpus war bereits groß genug, ohne dass eine einzige
|
||||
Seite eigens dafür angelegt werden musste. Eine Untergrenze wird nie durch eine erfundene Seite
|
||||
gefüllt, sondern durch eine echte Quelle beim nächsten passenden Ingest - Invariante 3 gilt
|
||||
unverändert.
|
||||
|
||||
**Die Leitplanke für reaktive Fixes** unterscheidet drei Stufen: punktuelle Änderungen (immer
|
||||
erlaubt, gewöhnliche Arbeit), korpusweite Änderungen (nur geplant, mit eigenem Issue und
|
||||
`work/`-Run - trifft eine Session das Mass-Update-Gate während sie etwas anderes tat, holt sie
|
||||
sich nicht den `--confirm`-Token, sondern stoppt und legt ein Issue an) und reaktive Eingriffe
|
||||
in Korpusinhalt, um einen Test grün zu machen oder einen Tool-Bug zu umgehen (nie erlaubt,
|
||||
Invariante 7). Das Verhältnis zu `kb_dir`/`raw_dir` und `test_pipeline_l0.py` bleibt wie im
|
||||
ursprünglichen Befund: kleiner, isolierter Fall in der Fixture, großer, vernetzter Fall in
|
||||
`kb/` - keine Fixture-Extraktion aus dem Korpus.
|
||||
|
||||
Dev-only und rein additiv - kein Feld, kein Kommando, keine Datei außerhalb von
|
||||
`instructions/dev/` ändert sich, daher `--minor` ohne `--breaking`.
|
||||
|
||||
**Migration:** none required.
|
||||
|
||||
Berührt: `instructions/dev/corpus-policy.md` (neu),
|
||||
`instructions/dev/stack-dev/SKILL.md` (Schritt 2, Routing-Zeile).
|
||||
|
||||
---
|
||||
|
||||
## 4.1.2 - 2026-09-03 - Issue-Abschluss ist ein Body-Rewrite, nicht nur ein Kommentar
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
Aufgefallen beim Schließen von #44: der Abschlussbericht stand als Kommentar da, der Body
|
||||
darunter weiterhin als offene Arbeit — Abschnitt „Zu entscheiden" über eine längst getroffene
|
||||
Entscheidung, ungehakte Checkliste, Präsens über einen Defekt, den es nicht mehr gab.
|
||||
|
||||
Die Regel gab es dafür schon: Schritt 2 von `instructions/dev/issue-tracking.md` sagt, der Body
|
||||
ist die aktuelle Wahrheit und wird umgeschrieben, wenn sich der Stand ändert. Nur ließ die
|
||||
Formulierung offen, *wann* — und Schritt 7 („Close with what actually happened") war vollständig
|
||||
erfüllbar, ohne den Body anzufassen. Ein Abschlussbericht im Kommentar fühlt sich beim Schreiben
|
||||
vollständig an; dass der Body dabei zurückbleibt, merkt erst der nächste Leser.
|
||||
|
||||
**Schritt 2 ist deshalb schärfer geworden: der Body ist das Plan-File dieses Stacks.** Dasselbe,
|
||||
was das Plan-Dokument eines Harness ist, und genauso gepflegt — fortlaufend, sobald etwas darin
|
||||
nicht mehr stimmt, nicht am Ende. Der Maßstab ist der Abbruch, nicht der Meilenstein: eine
|
||||
Session kann jederzeit enden, und was der Body in diesem Moment sagt, ist die vollständige
|
||||
Übergabe. Eine frische Session muss zu **jedem** Zeitpunkt allein aus dem Body weiterarbeiten
|
||||
können, ohne Kommentare rückwärts zu lesen und ohne einen Menschen, der es neu erklärt. Entschieden
|
||||
ersetzt die Frage, erledigt hakt das Kriterium ab, verworfen steht mit Begründung dort, wo das
|
||||
Kriterium stand.
|
||||
|
||||
Schritt 7 ist damit kein Sonderakt mehr, sondern die letzte dieser Aktualisierungen: erst Body
|
||||
auf den Endstand, dann schließen, dann die Changelog-Zeile aus Schritt 3. Wer Schritt 2 befolgt
|
||||
hat, ist fast fertig; wer nicht, zahlt die ganze Schuld im schlechtesten Moment — der
|
||||
geschlossene Body ist die Fassung, die danach alle lesen und niemand mehr aufsucht. #44 steht
|
||||
als Beispiel drin.
|
||||
|
||||
Schritt 3 zieht die Konsequenz: **ein Kommentar pro Session-Umfang, nicht pro Edit.** Ein
|
||||
fortlaufend gepflegter Body mit einem Changelog-Kommentar je Änderung wäre Lärm; triviale Pflege
|
||||
braucht gar keinen. Der `stack-dev`-Skill sagt es beim Aufgreifen mit, weil dort die Entscheidung
|
||||
fällt, ob eine Session den Body überhaupt anfasst.
|
||||
|
||||
**Und die ehrliche Antwort auf die Frage nach dem Tooling: es gibt keins, und es soll keins
|
||||
geben.** `wikitool` kennt diesen Tracker nicht. Es wird an Instanzen ausgeliefert, die unter
|
||||
dieser URL keine Issues haben, während `instructions/dev/` von `dist export` gepruned wird —
|
||||
ein Gitea-Client im ausgelieferten Tool wäre eine Dev-Abhängigkeit, die jede Instanz mitträgt,
|
||||
um ein Board zu prüfen, das keine von ihnen hat. Der Tracker ist ausschließlich über
|
||||
`gitea-mcp` erreichbar, also in einer Session, durch einen Agenten.
|
||||
|
||||
Kein `docs verify` fängt also einen geschlossenen Issue, dessen Body offen klingt, einen Body,
|
||||
der seinen eigenen Kommentaren widerspricht, oder ein fehlendes Pflichtlabel. Das steht jetzt
|
||||
als eigener Abschnitt „What no tool checks" in der Instruktion — nicht als Bedauern, sondern als
|
||||
Begründung dafür, warum die Reihenfolge in Schritt 7 ausgeschrieben ist statt aus Schritt 2
|
||||
erschlossen zu werden.
|
||||
|
||||
---
|
||||
|
||||
## 4.1.1 - 2026-09-03 - Testisolation: kb_dir repointet config.ROOT, lint löst Kollektionen gegen den übergebenen Baum auf
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
Issue #44, gefunden beim Bau der Migrations-Gate-Tests für 4.1.0: die `kb_dir`-Fixture baute
|
||||
ihren Baum unter `tmp_path`, ließ `config.ROOT` aber auf dem echten Checkout stehen. Jeder
|
||||
Codepfad, der eine Datei über `config.ROOT`/`config.KB_DIR` auflöst statt über das übergebene
|
||||
Verzeichnis, traf damit das echte Repository.
|
||||
|
||||
**Der laute Fall** war ein Test, der `kb_state.write_kb_state()` rief und dabei das
|
||||
`.wikitool-kb.json` des Repos überschrieb — Applied-Ledger leer statt zwei Einträgen. In
|
||||
`git status` sofort sichtbar und reversibel; bei einer gitignorierten Datei wäre es das nicht
|
||||
gewesen.
|
||||
|
||||
**Der stillere Fall** ist der teurere. `lint`s Kollektions-Lookup löste eine Seite gegen
|
||||
`config.KB_DIR` auf. Für eine Seite unter `tmp_path/kb/` warf das `ValueError`, die Funktion
|
||||
antwortete „keine Kollektion", und die Label-Autorisierung übersprang die Kante wortlos.
|
||||
`unauthorised_labels` war damit faktisch ungetestet — jeder Test, der das Finding hätte
|
||||
auslösen können, bekam eine leere Liste und behauptete nichts. Ein grüner Lauf, der wie eine
|
||||
Zusicherung aussah.
|
||||
|
||||
**Der Fix ist der Codepfad, nicht die Fixture.** `run_lint()` bekommt ein Verzeichnis
|
||||
übergeben und löst jetzt auch intern dagegen auf; `authorised_labels()` bekommt denselben Baum
|
||||
gereicht, statt auf `config.KB_DIR` zurückzufallen. Der Regressionstest lintet einen Baum, von
|
||||
dem `ROOT` bewusst wegzeigt — genau der Fall, den die alte Auflösung verschluckte. Eine Funktion,
|
||||
die ein Verzeichnis entgegennimmt, löst dagegen auf: keine Fixture kann diese Form von außen
|
||||
reparieren.
|
||||
|
||||
**Beide Korpus-Fixturen repointen jetzt.** `kb_dir` tut, was `raw_dir` längst tat — `ROOT` auf
|
||||
das eigene `tmp_path`, plus `use_shipped_type_specs()`. Der Suite-Lauf kippte dadurch keinen
|
||||
einzigen Test. Die lokale `rooted_kb`-Umgehung aus 4.1.0 entfällt damit; die Auswahl zwischen
|
||||
zwei fast gleichen Fixturen war Wissen, das nirgends stand.
|
||||
|
||||
**Und ein Wächter für die ganze Klasse.** `repository_tree_guard` (session-scoped, autouse)
|
||||
vergleicht `git status --porcelain` vor und nach dem Lauf und lässt die Suite scheitern, wenn
|
||||
sich im Checkout etwas bewegt hat — zwei `git status`-Aufrufe pro Lauf, deshalb per Default an.
|
||||
Er vergleicht vorher gegen nachher statt einen sauberen Baum zu verlangen, sagt also nichts über
|
||||
die unveröffentlichte Arbeit des Entwicklers. Den Verursacher benennt er nicht;
|
||||
`CHEMENU_TREE_GUARD=each` prüft nach jedem Test und tut es. Ohne git oder außerhalb eines
|
||||
Repositorys sind beide still.
|
||||
|
||||
Was der Wächter nicht sieht: eine Prüfung, die unter Test nichts tut, schreibt keine Datei.
|
||||
Dagegen hilft nur ein Test, der das Finding tatsächlich auslöst — der neue tut das.
|
||||
|
||||
`instructions/dev/testing-conventions.md` hat dafür einen eigenen Abschnitt („Which tree a test
|
||||
writes into"), einen Schritt in der Checkliste und die Regel für neue Fixturen.
|
||||
|
||||
---
|
||||
|
||||
## 4.1.0 - 2026-09-03 - Link-Taxonomie: Lint-Findings hart ab kb_version 4.0.0, outbound: an das Type-Spec gebunden, part-of/composition als Inversenpaar
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Entwicklung dieses Stacks
|
||||
|
||||
Dieses Dokument richtet sich an Menschen, die an `tools/wikitool`, dem Type-Schema oder der
|
||||
Instruction-/Skill-Schicht selbst arbeiten - nicht an den Konsumenten einer Instanz. Für die
|
||||
Gegenseite (eine Instanz installieren, aktualisieren, betreiben) siehe [INSTALL.md](INSTALL.md).
|
||||
|
||||
**Diese Datei wird nicht ausgeliefert.** Sie ist das menschliche Gegenstück zu
|
||||
`instructions/dev/`, das `tools/wikitool dist export` vollständig ausschließt: eine
|
||||
ausgelieferte Instanz hat keinen Release-Workflow, keine CI und kein Issue-Board, also braucht
|
||||
sie auch keine Anleitung dafür. `dist_cmd.ROOT_FILES` listet sie deshalb bewusst nicht - der
|
||||
Grund steht dort als Kommentar, damit eine spätere Sitzung die vermeintliche Lücke nicht
|
||||
"repariert". Und weil sie nicht ausgeliefert wird, darf sie - anders als `README.md`,
|
||||
`INSTALL.md` oder `EVALS.md`, die `instructions verify` auf genau diesen Punkt prüft - nach
|
||||
`instructions/dev/` verlinken.
|
||||
|
||||
## Der Release-Ablauf
|
||||
|
||||
Zwischen zwei Releases führt der Stack **einen** laufenden Versionskandidaten statt einer neuen
|
||||
Nummer pro Bump. Das volle Modell - Zustandsort, Eskalationslogik, warum eine Nummer erst durch
|
||||
ein Release verbraucht wird - steht in
|
||||
[instructions/dev/version-parts.md](instructions/dev/version-parts.md) und
|
||||
[docs/version-model.md](docs/version-model.md). Hier nur der Ablauf, in der Reihenfolge, in der
|
||||
eine Sitzung ihn tatsächlich durchläuft:
|
||||
|
||||
1. **Bump eröffnet oder eskaliert den Kandidaten.**
|
||||
|
||||
```bash
|
||||
tools/wikitool version bump --minor --title "Was sich geändert hat"
|
||||
```
|
||||
|
||||
Schreibt `VERSION` als `X.Y.Z-beta.N` und öffnet (oder aktualisiert) den passenden
|
||||
`CHANGES.md`-Eintrag. Mehrere Bumps für dieselbe Änderung sind normal - jeder aktualisiert
|
||||
denselben Eintrag, statt einen neuen zu eröffnen.
|
||||
|
||||
2. **Der Eintrag bekommt seine Prosa.** `bump` schreibt nur das Skelett (Heading, Datum, Autor,
|
||||
die maschinenverwaltete Bump-Titel-Liste, ggf. Breaking-/Migration-Zeile). Der Fließtext
|
||||
darunter ist Autorenarbeit, wie bei `new` und der Seiten-Prosa.
|
||||
|
||||
3. **Verify laufen lassen, bevor irgendetwas gepublished wird:**
|
||||
|
||||
```bash
|
||||
cd tools && .venv/bin/python -m pytest -q
|
||||
tools/wikitool docs verify
|
||||
tools/wikitool instructions verify
|
||||
```
|
||||
|
||||
4. **`version release` fixiert den Kandidaten**, sobald er ausgeliefert werden soll:
|
||||
|
||||
```bash
|
||||
tools/wikitool version release --title "Zusammenfassender Titel"
|
||||
```
|
||||
|
||||
Streicht den `-beta.N`-Suffix aus `VERSION` und schließt den Changelog-Eintrag. `--title` ist
|
||||
optional - ohne ihn bleibt der Titel des letzten Bumps stehen; mit ihm bekommt ein Kandidat,
|
||||
der mehrere Bump-Titel gesammelt hat, eine zusammenfassende Überschrift. Committet und pusht
|
||||
nichts (Invariante 5 in [AGENTS.md](AGENTS.md)).
|
||||
|
||||
5. **Publish bewegt `VERSION` auf `main`.**
|
||||
|
||||
```bash
|
||||
tools/wikitool publish --message "..."
|
||||
```
|
||||
|
||||
Das Mass-Update-Gate und das Publish-Remote-Gate gelten wie bei jedem anderen Publish -
|
||||
siehe [instructions/gates.md](instructions/gates.md).
|
||||
|
||||
6. **CI übernimmt den Rest.** `.gitea/workflows/release.yml` reagiert auf jeden Push, der
|
||||
`VERSION` bewegt: Eine suffixbehaftete `VERSION` (ein Kandidat) lässt den Job sauber
|
||||
überspringen, bevor er die Releases-API überhaupt anfragt - Betas werden nie veröffentlicht.
|
||||
Eine suffixfreie `VERSION` baut die Distribution (`dist export`), erzeugt Tag und Release und
|
||||
lädt Tarball plus Prüfsumme hoch. **CI setzt den Tag, nie eine Sitzung** - das hält
|
||||
Invariante 5 intakt.
|
||||
|
||||
Die drei Verify-Befehle stehen oben in Schritt 3; was jeder von ihnen prüft, steht in
|
||||
[tools/CONTRACT.md](tools/CONTRACT.md) und wird dort von `docs verify` gegen die tatsächliche
|
||||
CLI gehalten. Hier steht es bewusst **nicht** noch einmal: eine zweite Beschreibung derselben
|
||||
Befehle ist genau die Kopie, die driftet (AGENTS.md Invariante 8), und dieses Dokument liegt
|
||||
außerhalb der Dateien, die der Kommandotabellen-Check von `docs verify` abdeckt - hier fällt eine
|
||||
Drift also niemandem auf. Was `pytest` an dieser Stelle vom Entwickler erwartet, steht in
|
||||
[instructions/dev/testing-conventions.md](instructions/dev/testing-conventions.md).
|
||||
|
||||
## Die CI-Hälfte
|
||||
|
||||
`.gitea/workflows/ci.yml` läuft auf jeden Push/PR gegen `main` (Content-Pfade ausgenommen) und
|
||||
führt Testsuite, `docs verify`, `instructions verify` sowie einen vollständigen
|
||||
`setup-instance.md`-Replay gegen einen frischen `dist export` aus - derselbe Pfad, den ein neuer
|
||||
Nutzer tatsächlich geht. `.gitea/workflows/nightly.yml` ist der Drift-Check gegen die Zeit statt
|
||||
gegen einen Commit. `.gitea/workflows/release.yml` ist Schritt 6 oben.
|
||||
|
||||
## Stack-Entwicklung als eigener Sitzungstyp
|
||||
|
||||
Der `stack-dev`-Skill (`instructions/dev/`, nur in diesem Ursprungs-Repo vorhanden) fasst die
|
||||
Regeln für eine Sitzung, die den Stack selbst statt Wiki-Inhalt bearbeitet: wann
|
||||
Quellenbindung nicht gilt, wo Design endet und die mechanische Phase beginnt (mit dem
|
||||
Modellwechsel-Hinweis), und dass Issue-Abschluss ein Body-Rewrite ist, kein Kommentar. Siehe
|
||||
[instructions/dev/issue-tracking.md](instructions/dev/issue-tracking.md) für den
|
||||
Issue-Tracker selbst.
|
||||
+71
-19
@@ -167,10 +167,17 @@ wenn nicht). `tools/wikitool version notes` druckt den Eintrag.
|
||||
|
||||
### Eine Instanz aktualisieren
|
||||
|
||||
Das Anwenden eines Updates ist ein bewusst manueller Vorgang - es schreibt in eine Instanz, die
|
||||
bereits Inhalt hat. Der Inhalt hat dabei eine **eigene Version**: `.wikitool-kb.json` sagt, in
|
||||
welcher Form die Seiten vorliegen, unabhängig davon, welche Maschinerie danebensteht. Genau
|
||||
dieser Unterschied ist der Zustand, in dem sich jede Instanz mitten im Upgrade befindet.
|
||||
Zwei Wege, je nachdem, wie diese Instanz entstanden ist. Ein **Clone mit gemeinsamer
|
||||
Git-History** (`upstream`-Remote auf das Ursprungs-Repo, siehe
|
||||
[instructions/private-instance.md](instructions/private-instance.md)) nimmt Stack-Updates per
|
||||
echtem Drei-Wege-Merge: `tools/wikitool upstream merge`. Alles Folgende gilt für eine **Instanz
|
||||
aus einem Tarball**, ohne gemeinsame History - der Weg unten unter „Eine Instanz aktualisieren"
|
||||
nutzt sie.
|
||||
|
||||
Das Anwenden eines Updates schreibt in eine Instanz, die bereits Inhalt hat. Der Inhalt hat dabei
|
||||
eine **eigene Version**: `.wikitool-kb.json` sagt, in welcher Form die Seiten vorliegen,
|
||||
unabhängig davon, welche Maschinerie danebensteht. Genau dieser Unterschied ist der Zustand, in
|
||||
dem sich jede Instanz mitten im Upgrade befindet.
|
||||
|
||||
1. **Vor dem Tausch** prüfen, was ansteht - solange `VERSION` noch die alte ist:
|
||||
|
||||
@@ -178,19 +185,49 @@ dieser Unterschied ist der Zustand, in dem sich jede Instanz mitten im Upgrade b
|
||||
tools/wikitool migrate status
|
||||
```
|
||||
|
||||
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` - **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`.
|
||||
Steht hier etwas aus, erst diese Migrationskette abschließen (Schritt 5 unten) - `dist upgrade`
|
||||
verweigert den Tausch sonst von selbst.
|
||||
|
||||
2. Release-Tarball herunterladen und die Release-Notes lesen (Weg A oben).
|
||||
3. **Maschinerie tauschen:**
|
||||
|
||||
```bash
|
||||
tools/wikitool dist upgrade <tarball-oder-verzeichnis> --dry-run
|
||||
```
|
||||
|
||||
**Beim ersten Sprung auf `4.5.0` oder höher gibt es dieses Kommando in der Instanz noch
|
||||
nicht** - es kam erst mit `4.5.0`. Dann das Werkzeug aus dem entpackten *neuen* Tarball
|
||||
verwenden, gegen die alte Instanz gerichtet:
|
||||
|
||||
```bash
|
||||
tar -xzf chemenu-stack-<version>.tar.gz
|
||||
CHEMENU_ROOT="$PWD" chemenu-stack-<version>/tools/wikitool \
|
||||
dist upgrade chemenu-stack-<version>.tar.gz --dry-run
|
||||
```
|
||||
|
||||
`CHEMENU_ROOT` sagt dem Paket, auf welchen Korpus es zeigen soll (siehe § Konfiguration);
|
||||
ohne die Variable würde es den entpackten Tarball selbst für die Instanz halten. Ab dem
|
||||
zweiten Upgrade trägt die Instanz das Kommando selbst und die kurze Form oben genügt.
|
||||
|
||||
Klassifiziert jede Datei aus dem `files`-Block der neuen `.wikitool-release.json`:
|
||||
unverändert seit der Installation, lokal verändert oder gelöscht, neu im Release, oder aus dem
|
||||
Release entfallen - und druckt die Migrationskette, die nach dem Tausch aussteht, ohne sie
|
||||
auszuführen. Ohne `--dry-run` schreibt der Befehl; eine lokal veränderte oder gelöschte Datei
|
||||
wird dabei **nie** stillschweigend überschrieben - der Lauf bricht mit der vollständigen Liste
|
||||
ab, es sei denn `--keep-local` ist gesetzt (dann bleibt jede davon unangetastet, erneut
|
||||
gemeldet). `--prune` entfernt zusätzlich Dateien, die der neue Release nicht mehr ausliefert
|
||||
und die seit der Installation unverändert sind. Voraussetzungen: ein sauberer Arbeitsbaum
|
||||
(kein Git-Repo ist ein WARN, keine Sperre), eine lokale `.wikitool-release.json` mit
|
||||
`files`-Block (fehlt sie, siehe „Fallstricke" unten), und `.wikitool-kb.json` vorhanden.
|
||||
Committet und pusht nichts (Invariante 5). Vollständiger Fehlerkontrakt:
|
||||
[tools/CONTRACT.md](tools/CONTRACT.md).
|
||||
|
||||
Eine lokal veränderte Stack-Datei ist damit sichtbar, statt von Hand gegen die sha256-Summen
|
||||
im `files`-Block geprüft werden zu müssen - genau der Schritt, der vor `4.5.0` hier stand.
|
||||
4. Bei einer Kompatibilitätsgrenze (`dist upgrade` meldet sie laut) die Release-Notes vor dem
|
||||
nächsten Schritt lesen: **Breaking Change:** und **Migration:** im Eintrag von
|
||||
`tools/wikitool version notes` sagen, was aufhört zu funktionieren und ob der Korpus
|
||||
umgeschrieben werden muss.
|
||||
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
|
||||
@@ -204,15 +241,30 @@ dieser Unterschied ist der Zustand, in dem sich jede Instanz mitten im Upgrade b
|
||||
`done` verweigert jede Version, die nicht das nächste Glied ist - eine übersprungene
|
||||
Migration hinterlässt einen Korpus in einer Form, die keine Version beschreibt. Ein
|
||||
abgebrochenes Upgrade wird durch erneutes `migrate status` fortgesetzt.
|
||||
6. Prüfen: `tools/wikitool migrate verify --from <commit vor der Migration>`, dann `doctor`,
|
||||
6. Prüfen: `tools/wikitool migrate verify --from <commit vor dem Tausch>`, dann `doctor`,
|
||||
`docs verify`, `instructions verify` und `lint`. Zum Schluss
|
||||
`tools/wikitool instructions sync` (die Skills sind Kopien) und die Agent-Session neu
|
||||
starten.
|
||||
starten. `dist upgrade` nennt diese Reihenfolge im eigenen Abschlussbericht, führt aber keinen
|
||||
der Schritte selbst aus.
|
||||
|
||||
`doctor` warnt, solange `kb_version` hinter `VERSION` zurückliegt und noch Migrationen offen
|
||||
sind. Einer Instanz, die älter ist als `.wikitool-kb.json`, fehlt die Datei ganz - dann einmalig
|
||||
`tools/wikitool migrate baseline <version>` aufrufen; geraten wird nichts.
|
||||
|
||||
**Fallstricke.** Eine Instanz ohne lokale `.wikitool-release.json` (oder eine ohne `files`-Block,
|
||||
aus der Zeit vor `4.5.0`) hat für `dist upgrade` keine Basis, gegen die es eine lokale Änderung
|
||||
erkennen könnte, und verweigert den Tausch - dafür gibt es heute keine Reparatur (siehe Gitea #7).
|
||||
Der Befehl lädt selbst nichts herunter: `<tarball-oder-verzeichnis>` muss vorher aus Weg A
|
||||
geholt werden, und ein Tarball muss genau ein Top-Level-Verzeichnis enthalten - die Form, in der
|
||||
`.gitea/workflows/release.yml` es baut.
|
||||
|
||||
Vor `4.5.0` stand hier ein rein manueller Ablauf (Maschinerie von Hand kopieren, `kb/CONTRACT.md`
|
||||
eingeschlossen, sha256-Vergleich von Hand). `dist upgrade` ersetzt genau diesen Teil; wer ihn
|
||||
dennoch von Hand nachvollziehen will oder muss (ein Werkzeug, das `wikitool` selbst nicht
|
||||
ausführen kann), findet die Dateiliste im `files`-Block der `.wikitool-release.json` und die
|
||||
Ausnahmen (`kb/CONVENTIONS.md`, `kb/*/COLLECTION.md`, `.wikitool-kb.json`) in
|
||||
[tools/CONTRACT.md](tools/CONTRACT.md)s `dist upgrade`-Zeile.
|
||||
|
||||
### Sonderfall: Update von 1.x auf 2.0.0
|
||||
|
||||
Mit `2.0.0` wurde das Ursprungs-Repo von `torben/llm-wiki-test1` auf `torben/chemenu`
|
||||
|
||||
@@ -115,6 +115,7 @@ chemenu/
|
||||
Dev-instance-only (see `tools/CONTRACT.md` for how it got here):
|
||||
|
||||
```
|
||||
├── DEVELOPMENT.md # Human-readable: the release workflow (version bump/release/publish/CI)
|
||||
└── commonplace/ # Vendored, read-only knowledge base
|
||||
```
|
||||
<!-- dist:strip-end -->
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# Ownership and Templates
|
||||
|
||||
Chemenu ships two kinds of files side by side, and at a glance they look the same: both are
|
||||
plain markdown, both sit in the repo root or under `kb/`, both get read at session start. But a
|
||||
stack upgrade treats them completely differently. Some - [AGENTS.md](../AGENTS.md),
|
||||
[kb/CONTRACT.md](../kb/CONTRACT.md), the per-stage contracts - are identical in every instance
|
||||
that runs this stack and are the next release's to replace (with one caveat about local edits,
|
||||
below). Others - `USER.md`,
|
||||
`SOUL.md`, `kb/CONVENTIONS.md`, `ENVIRONMENT.md` - describe one particular instance, and
|
||||
overwriting them would silently erase a choice someone made on purpose.
|
||||
|
||||
## Two different kinds of truth
|
||||
|
||||
The stack-owned files describe how the tool works. `kb/CONTRACT.md` opens by saying it holds
|
||||
what `tools/wikitool` enforces or what follows mechanically from how it operates - see
|
||||
[kb/CONTRACT.md](../kb/CONTRACT.md), lines 10-13. That kind of statement doesn't vary by
|
||||
instance: the compiler behaves the same way regardless of who is running it, so the sentence
|
||||
describing that behavior can be copied byte-for-byte into every checkout without becoming
|
||||
wrong anywhere.
|
||||
|
||||
The instance-owned files describe a choice: which language pages are written in, what tone the
|
||||
agent takes, who the operator is, which git remote is authoritative, which MCP servers are
|
||||
reachable. None of that follows from the tool's mechanics - two instances of the identical
|
||||
stack can answer all of these differently and both be correct. [AGENTS.md § Personalization](../AGENTS.md#personalization)
|
||||
frames the split the same way for `kb/CONTRACT.md` versus `kb/CONVENTIONS.md`: "the split is by
|
||||
who may change the sentence, not by what it is about." A rule about page structure could in
|
||||
principle have been written per-instance too, but then every instance answering "not German" to
|
||||
setup would be hand-editing a file the stack also ships, and the next `dist export` merge would
|
||||
hand the instance's own file back to it, discarding the customization.
|
||||
|
||||
## Why silent overwrite is the failure being designed against
|
||||
|
||||
A stack update is meant to be a routine, low-risk operation: pull the latest release, get
|
||||
whatever fixes and features shipped since the last one. That only stays low-risk if the update
|
||||
knows which files it's allowed to touch. If `USER.md` or `kb/CONVENTIONS.md` were treated the
|
||||
same as `AGENTS.md` - shipped and periodically re-copied - an upgrade would quietly replace a
|
||||
description of *this* operator, in *this* language, with whatever placeholder or default the
|
||||
stack maintainers wrote. The damage wouldn't be loud: nothing crashes, the files still parse,
|
||||
the agent just starts acting on the wrong premises until someone notices the voice or the
|
||||
language changed.
|
||||
|
||||
Keeping the boundary at the file level, rather than trying to merge changes within a shared
|
||||
file, means an upgrade never has to guess which lines are "stack" and which are "instance" -
|
||||
the file itself already answers that.
|
||||
|
||||
## Why the boundary is a predicate rather than a list
|
||||
|
||||
For a while the boundary was written down as a list of paths - once in `dist_cmd.py`, once in
|
||||
the merge procedure a private instance was told to run by hand, and once in the check that
|
||||
procedure ended with. Three copies of one fact, which is the shape [AGENTS.md](../AGENTS.md)
|
||||
invariant 8 exists to forbid, and they drifted exactly as predicted: the hand-run procedure was
|
||||
still naming three paths after the collection contracts had moved to the instance's side of the
|
||||
line, so it discarded upstream changes to files it had never heard of, while its own final check
|
||||
excluded the same three paths and therefore reported success.
|
||||
|
||||
`chemenu/ownership.py` replaced the lists with one question - is this path, under a content
|
||||
stage, the stack's or the instance's? - answered by shape rather than by enumeration:
|
||||
`<stage>/CONTRACT.md`, and anything ending `.template`. Both consumers ask it, so `dist export`
|
||||
and `wikitool upstream merge` cannot disagree, and a machinery file added under a content stage
|
||||
tomorrow is recognised by both without either being edited. The deeper point is not the
|
||||
deduplication: a list has to be maintained by whoever remembers it exists, and the failure mode
|
||||
when nobody does is silence, because a path the list has never heard of simply looks like
|
||||
content.
|
||||
|
||||
## Why a `.template`, not just an absent file
|
||||
|
||||
The mechanism for instance-owned content is a `.template` file the distribution ships instead
|
||||
of the real one - `USER.md.template`, `SOUL.md.template`, `kb/CONVENTIONS.md.template`,
|
||||
`ENVIRONMENT.md.template`. An alternative would have been to ship nothing at all and let a
|
||||
brand-new instance start from a blank page. The template exists because a blank page doesn't
|
||||
tell [instructions/setup-instance.md](../instructions/setup-instance.md) what shape the answer
|
||||
should take, and it gives nothing for a validator to check afterward.
|
||||
|
||||
A template carries a placeholder value - a sentinel - in the fields that need a real answer.
|
||||
Setup interviews the operator and replaces the sentinel with what they actually said. That
|
||||
gives `doctor` a mechanical way to tell "personalized" from "not yet": a file that still
|
||||
contains the sentinel hasn't been through setup, regardless of whether the file exists. That's
|
||||
also why `ENVIRONMENT.md` only warrants a WARN rather than a FAIL when absent - see
|
||||
[AGENTS.md § Environment](../AGENTS.md#environment) - while a missing or unfilled
|
||||
`USER.md`/`SOUL.md`/`kb/CONVENTIONS.md` is a harder failure: `ENVIRONMENT.md` describes one
|
||||
checkout among possibly several and is gitignored for that reason, so its absence is a normal
|
||||
state rather than a sign setup was skipped.
|
||||
|
||||
## The consequence in practice
|
||||
|
||||
An upgrade sorts every shipped path into three categories, not two - and the third one only
|
||||
becomes visible once an upgrade is a command rather than a hand-run copy:
|
||||
|
||||
- **Verbatim files** - `AGENTS.md`, `kb/CONTRACT.md`, the per-stage contracts, everything under
|
||||
`tools/`, `types/` and `instructions/` - are the release's to replace.
|
||||
- **`.template`-sourced files** - `USER.md`, `SOUL.md`, `kb/CONVENTIONS.md`, each
|
||||
`kb/<name>/COLLECTION.md`, `ENVIRONMENT.md`, the `root: kb` type-specs - are never written by
|
||||
an upgrade at all. The distribution ships only the `.template` beside them, so the filled file
|
||||
is out of reach by construction rather than by a rule someone has to remember.
|
||||
- **Seeded-once files** - `.wikitool-kb.json`, `CHANGES.md`, `kb/log.md`, `raw/*/.gitkeep` - are
|
||||
written into a *new* instance by `dist export` and belong to the instance from then on. They
|
||||
are the awkward category: they sit in the release stamp's file list like any other shipped
|
||||
file, so an upgrade has to exclude them deliberately (`chemenu.ownership.is_export_stub` and
|
||||
`is_upgrade_preserved`). An upgrade that re-seeded them would reset the record of which
|
||||
migrations ran, or erase the changelog the instance wrote for itself.
|
||||
|
||||
The first category carries a caveat that the word "verbatim" hides. It says who *decides* the
|
||||
content, not that overwriting is always safe: an instance can still have edited a verbatim file
|
||||
- a patched `tools/`, a locally adjusted instruction - and an upgrade assuming otherwise would
|
||||
destroy that silently. Avoiding that assumption is the whole reason `dist export` records a
|
||||
sha256 per shipped file in `.wikitool-release.json`. `wikitool dist upgrade` compares every
|
||||
candidate path against the digest recorded when it was installed, overwrites only what still
|
||||
matches, and refuses rather than overwrite what does not.
|
||||
|
||||
So the practical rule is narrower than "overwrite the verbatim files, leave the rest alone":
|
||||
overwrite the verbatim files *this instance has not touched*, never write the other two
|
||||
categories, and make a locally changed file a decision someone takes deliberately instead of
|
||||
one an upgrade takes for them. The template-sourced files were filled in once, by a person, for
|
||||
a reason, and nothing about a newer release of the stack's mechanics gives it standing to
|
||||
override that.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Why the pipeline has four stages
|
||||
|
||||
Chemenu could, in principle, be one directory: drop a file in, ask a question, get an answer
|
||||
computed fresh each time. It isn't built that way. The pipeline in
|
||||
[AGENTS.md](../AGENTS.md#routing) - `raw/` -> `[types/ + tools/]` -> `kb/` -> `reports/`, with
|
||||
`work/` alongside rather than inside it - separates *material* from *meaning* from
|
||||
*byproduct*, and each seam exists because collapsing it costs something specific.
|
||||
|
||||
## Why raw material stays untouched
|
||||
|
||||
[raw/CONTRACT.md](../raw/CONTRACT.md) keeps a source exactly as it arrived. The reasoning is
|
||||
simple once stated: the moment someone "cleans up" or reformats a source on the way in, the
|
||||
thing later claims get checked against is no longer the thing that was actually said. An
|
||||
immutable `raw/` means a citation always resolves to the original, not to somebody's tidied
|
||||
memory of it. It also draws a trust boundary in one place instead of scattering it - everything
|
||||
past `raw/` can be treated as reviewed, because nothing upstream of it silently already was.
|
||||
|
||||
## Why extraction happens once, through a schema
|
||||
|
||||
[types/type-spec.md](../types/type-spec.md) is what stands between a raw file and a `kb/` page:
|
||||
a type-spec defines what a conforming instance of a page looks like, and the compiler
|
||||
(`tools/wikitool`) applies it. The alternative - every query re-reading and re-interpreting the
|
||||
source on demand - would mean paying the cost of understanding the material every single time,
|
||||
and getting a slightly different answer each time depending on how the question was phrased.
|
||||
Extracting once, against a fixed schema, turns "re-read and re-guess" into "look up what was
|
||||
already compiled." That is the "never re-derive, always compile" principle from
|
||||
[AGENTS.md](../AGENTS.md): understanding a source is expensive and worth doing exactly once,
|
||||
after which it becomes a cheap, stable lookup.
|
||||
|
||||
## Why a `kb/` page has to stand on its own
|
||||
|
||||
[kb/CONTRACT.md](../kb/CONTRACT.md) sets the bar for the compiled layer: a page should answer a
|
||||
future question without sending the reader back to the source it came from. That's the payoff
|
||||
of compiling in the first place - if every answer still bottomed out in "go re-read the raw
|
||||
file," the `kb/` layer would just be a pointer with extra steps, and the cost of extraction
|
||||
would have bought nothing. A page that stands alone is what makes the corpus fast and
|
||||
consistent to query: the work of understanding is already sitting there, done.
|
||||
|
||||
## Why `reports/` doesn't need to be maintained
|
||||
|
||||
[reports/CONTRACT.md](../reports/CONTRACT.md) treats most of what lands in `reports/` -
|
||||
lint output, telemetry traces - as disposable. The structural content of a lint report can be
|
||||
recomputed from the tree at any commit, so keeping an old copy around would just be a second
|
||||
version of something the tool can already answer on demand, and a second copy is exactly the
|
||||
kind of thing that quietly goes stale. Treating it as derived output rather than a fourth thing
|
||||
to maintain means there is nothing there to fall out of sync - regenerating it is cheaper than
|
||||
reconciling it. The one part that genuinely can't be recomputed - the judgment a pass produced -
|
||||
is carried out into `kb/` or `kb/log.md` before the report itself is discarded, which is the
|
||||
distinction between what's recomputable and what isn't.
|
||||
|
||||
## Where `work/` fits
|
||||
|
||||
[work/CONTRACT.md](../work/CONTRACT.md) describes a workshop, not a fifth pipeline stage: a
|
||||
place for the notes, extracts and open decisions of a task that spans more than one session, on
|
||||
its way toward becoming a `kb/` page. It sits beside the raw -> kb -> reports flow rather than
|
||||
inside it - closer in spirit to a desk than to a conveyor belt.
|
||||
|
||||
## The shape this produces
|
||||
|
||||
Four stages, each answering a different question: `raw/` - what was actually said; `types/` +
|
||||
`tools/` - how to turn that into structured understanding; `kb/` - what is now known;
|
||||
`reports/` - what a pass over the corpus noticed in passing. Keeping them separate is what lets
|
||||
each one be trusted for what it is, instead of every layer having to double as all four at
|
||||
once.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Why the stack version splits compatibility from migration
|
||||
|
||||
A stack version number looks like it answers one question. It actually answers two, and the two
|
||||
are independent of each other.
|
||||
|
||||
## Two questions, not one
|
||||
|
||||
The first question is whether the new version is a drop-in replacement for the old one - whether
|
||||
an existing instance can install it, and can also go back, without anyone doing hand-work. That
|
||||
is what a version number *is*: a promise. The second question is whether the existing corpus in
|
||||
`kb/` needs to change shape to keep working under the new version. These sound like the same
|
||||
question, because most of the time a change that breaks compatibility also happens to touch
|
||||
content, and most of the time a change that leaves content untouched also happens to be
|
||||
compatible. The correlation is real; it just is not a law. `instructions/dev/version-parts.md`
|
||||
carries the actual test for telling them apart and the steps that follow from it - this page is
|
||||
about why the split exists at all.
|
||||
|
||||
## Why "kb/ untouched" is not proof of anything
|
||||
|
||||
The tempting shortcut is: if no page in `kb/` had to change, the bump can't be that serious. This
|
||||
is exactly backwards for a class of changes that live entirely outside the corpus - a renamed
|
||||
release artefact, a Python import path, an environment variable, the URL an instance's own
|
||||
updater points at. None of those touch a single page. All of them can strand an existing
|
||||
instance just as thoroughly as a rewritten type-spec would. The corpus is the part of the stack
|
||||
that looks at itself; the compatibility question is about everything an instance depends on to
|
||||
keep functioning, most of which the corpus never sees.
|
||||
|
||||
## Reading compatibility off the leftmost non-zero component
|
||||
|
||||
Semantic versioning gives every component a job, but only one of them is where an existing
|
||||
instance's tooling actually looks to decide "is this safe." On a `2.x` stack that is MAJOR; on a
|
||||
still-pre-1.0 `0.x` stack, by the same convention, it's MINOR - the leftmost slot that isn't
|
||||
pinned to zero is the one an automated updater treats as the compatibility boundary. Bump
|
||||
anything to its left, or bump that slot itself, and the promise changes. Everything to the right
|
||||
of it can move as freely as the project likes without touching that promise. This is why the
|
||||
question "is it boundary-crossing" always resolves to one specific digit, not to a feeling about
|
||||
how big the change is.
|
||||
|
||||
## Downgrade is half the promise
|
||||
|
||||
It's natural to test compatibility by only asking "does the upgrade work." The other half -
|
||||
"can an instance that upgraded put the old version back and land where it started" - carries
|
||||
equal weight, and it's the half that's easy to forget because forward motion is what everyone is
|
||||
testing for anyway. A state file the old version can no longer parse, a generated index in a new
|
||||
shape, a stamp file that got renamed: none of these have to break the upgrade to break the
|
||||
downgrade. An instance that can go forward but not back has already lost the property a
|
||||
compatible version number is supposed to guarantee.
|
||||
|
||||
## A promise made to a machine, not only to a person
|
||||
|
||||
A human reading a changelog can absorb "this technically isn't compatible but it's fine, just
|
||||
update those two things by hand." An instance's own update mechanism cannot. It reads a version
|
||||
number, decides whether to pull the new release, and has no channel for nuance - which is exactly
|
||||
why the update path itself is one of the sharpest ways to cross the boundary invisibly: if the
|
||||
new version moves where updates come from, the very channel that would have told an instance to
|
||||
adjust is the channel that just broke. The version number isn't documentation aimed at a reader;
|
||||
it's an input consumed by code that has no other way to ask.
|
||||
|
||||
## The 2.0.0 story
|
||||
|
||||
This isn't hypothetical for this stack. The rebranding that produced Chemenu renamed the repo,
|
||||
the release artefact, and the Python package - and left every page in `kb/` untouched. The first
|
||||
instinct was a MINOR bump, on the reasoning that nothing in the corpus needed migrating. That
|
||||
reasoning was correct on its own terms and answered the wrong question. Three things broke
|
||||
underneath it: every existing instance's `update_url` pointed at a repo path that no longer
|
||||
existed and, because it's a machine-written file, couldn't be hand-repaired; the release artefact
|
||||
name changed, breaking every download script and pin against it; and the import name changed,
|
||||
breaking anything importing the package from outside the shipped tree. The corpus had nothing to
|
||||
say about any of this, because none of it lived in the corpus.
|
||||
|
||||
What caught the mistake was a person looking at the diff and asking whether it really was a
|
||||
drop-in replacement, not a validator. No check in `docs verify` or anywhere else confirms that a
|
||||
version part was chosen correctly - it only confirms that a boundary-crossing bump documents
|
||||
what it breaks. The 2.0.0 entry in `CHANGES.md` carries the corrected reasoning in full, and the
|
||||
version bump that shipped it was `--major --no-migration`: boundary-crossing and untouched
|
||||
corpus, at the same time, which is precisely the combination the two-question split exists to
|
||||
make visible.
|
||||
|
||||
## Why a number is only spent by a release
|
||||
|
||||
Everything above is about what a version number *promises*. A separate question turned out to
|
||||
matter just as much in practice: how many numbers get handed out along the way to making one
|
||||
release. For a while the answer was "one per bump," and that turned out to be the wrong grain
|
||||
entirely.
|
||||
|
||||
Two mechanisms decide when a number gets minted, and they answer different questions. CI's
|
||||
version gate asks a *commit*-level one: has this tree changed since the last push, and if so
|
||||
has `VERSION` moved with it. A release asks something else entirely: is this a state worth
|
||||
handing to someone, under a number they will pin against. Tying the second to the first - every
|
||||
`VERSION` move firing the release workflow - answers the gate correctly and the release question
|
||||
by accident, because it treats every bump as if it were about to ship when most bumps are steps
|
||||
toward a release that has not happened yet.
|
||||
|
||||
The failure mode is not phantom numbers; every one of those releases was real, tagged and
|
||||
downloadable. It is that "real" stopped meaning anything. On 2026-09-03 this repository cut four
|
||||
releases in six hours - `4.3.0` through `4.3.3` - for one continuous arc of work, two of them for
|
||||
prose changes alone. Someone tracking the feed saw four upgrades and had no way to tell which, if
|
||||
any, was a moment worth stopping for. A release is a promise addressed to a consumer, and a
|
||||
promise made four times an afternoon is not a smaller promise, it is a less legible one.
|
||||
|
||||
The fix is not to slow the gate down - it still wants `VERSION` to move every time, and it still
|
||||
gets that. It is to stop treating every movement as a number worth publishing. Between two
|
||||
releases the stack now carries one running candidate, escalating through `-beta.N` as bumps
|
||||
accumulate, and only `version release` spends the number for real by fixing it and closing its
|
||||
changelog entry. A number is proposed by a bump and spent by a release; conflating the two was
|
||||
the actual defect, not the arithmetic of any single bump.
|
||||
|
||||
This is also why a candidate never gets to a distributed instance. The promise a released version
|
||||
makes - "install this, and it is exactly what its number says" - has no equivalent for something
|
||||
still being decided during a single dev checkout's session. `release.yml`'s only job with respect
|
||||
to this is refusing to act on a suffixed `VERSION` at all: not because a beta is unsafe, but
|
||||
because there is nothing yet to promise.
|
||||
|
||||
## Where the procedure lives
|
||||
|
||||
The drop-in test, the catalogue of changes that cross the boundary with no page touched, and the
|
||||
steps for a boundary-crossing bump - the `--breaking` line, the migration document or
|
||||
`--no-migration` reason, talking to the user before bumping - are one procedure, kept at one
|
||||
place: [instructions/dev/version-parts.md](../instructions/dev/version-parts.md).
|
||||
@@ -0,0 +1,57 @@
|
||||
# Why gates are code
|
||||
|
||||
Chemenu has three hard limits - the Mass-Update Gate, the Publish-Remote Gate, and the
|
||||
Iteration Budget Gate - and all three live inside `tools/wikitool`, not in a paragraph of
|
||||
instructions an agent reads and follows. The rules themselves, and what to do when one trips,
|
||||
are in [AGENTS.md § Gates](../AGENTS.md#gates) and [instructions/gates.md](../instructions/gates.md).
|
||||
This page is only about the design choice underneath them: why code, and why these three
|
||||
mechanisms in particular.
|
||||
|
||||
## A suggestion an agent can talk itself past
|
||||
|
||||
An instruction like "don't publish too much at once" or "don't loop forever" lives in the same
|
||||
place as every other piece of guidance a session is holding - alongside the task, the user's
|
||||
last message, and whatever context made the moment feel urgent. Under pressure, or with a
|
||||
plausible-sounding reason ("this batch is different, it's mechanical"), that guidance can be
|
||||
reasoned around without anyone deciding to break a rule. Nothing enforces it; it just competes
|
||||
for attention with everything else in the context window, and sometimes loses.
|
||||
|
||||
A check compiled into the tool doesn't have that problem, because it isn't part of the
|
||||
conversation at all. It runs before the command dispatches, regardless of how convincing the
|
||||
case for skipping it seemed a moment earlier. The difference isn't that code is smarter than a
|
||||
well-written instruction - it's that code doesn't get talked into anything.
|
||||
|
||||
## Why three different mechanisms, not one
|
||||
|
||||
The three gates ask three different questions, and each one's shape follows from what kind of
|
||||
question it is.
|
||||
|
||||
The Mass-Update Gate asks *is this change too large to publish unreviewed* - a judgment that
|
||||
varies changeset by changeset, so it clears with a `--confirm` token tied to the specific
|
||||
output the user just read. Approval is scoped to that one publish.
|
||||
|
||||
The Publish-Remote Gate asks something underneath that: *is this even the right repository*.
|
||||
That's not a per-push judgment, it's a standing property of the checkout - true or false for
|
||||
every publish that checkout will ever attempt, not just this one. A confirm token would let an
|
||||
agent clear it once and then treat the answer as settled, which is exactly backwards for a
|
||||
question whose answer shouldn't move at all mid-session. The only way past it is the user
|
||||
editing `.wikitool-remotes.json` directly, outside the gate's own flow.
|
||||
|
||||
The Iteration Budget Gate asks a third kind of question - not "is this instance correct" but
|
||||
"has this session stopped making progress." That's read from the shape of the call history
|
||||
itself (call count, repeated identical calls), not from anything about the content of any one
|
||||
call.
|
||||
|
||||
## Numbers that come from measurement, not intuition
|
||||
|
||||
The iteration ceiling didn't start where it sits now. It used to run 15-25, borrowed from a
|
||||
general rule of thumb, until four real ingest runs measured 24, 26, 29 and 30 calls apiece -
|
||||
every one of them an ordinary workflow doing nothing wrong, and every one of them at or past
|
||||
where the old ceiling would have refused it. A limit that the normal case keeps tripping stops
|
||||
functioning as a limit; it becomes background noise a session learns to route `--override-budget`
|
||||
around as a matter of course, and the whole point of a hard-coded check is that it isn't supposed
|
||||
to feel routine.
|
||||
|
||||
That's the deeper reason these numbers live in a tool rather than in prose: prose is read once
|
||||
and remembered loosely, but a threshold enforced every call is tested by every call, and a
|
||||
threshold that fails its own test gets noticed and re-measured rather than quietly ignored.
|
||||
@@ -6,10 +6,24 @@ description: Which Claude model and effort level to run a Claude Code session, a
|
||||
|
||||
# Pick the Claude model and effort level for the task at hand
|
||||
|
||||
Scale the model and effort to how much judgment the task actually needs. Running everything at
|
||||
the most capable model and highest effort is safe but wasteful: the gates in [gates.md](gates.md)
|
||||
are enforced in code, not by model judgment, so a weaker model cannot bypass them - it can only
|
||||
do a worse job of the calls the gates don't cover.
|
||||
Scale the model and effort to **what catches a mistake in this part of the work** - not to how
|
||||
important the task feels, and not to its name. Running everything at the most capable model and
|
||||
highest effort is safe but wasteful: the gates in [gates.md](gates.md) are enforced in code, not
|
||||
by model judgment, so a weaker model cannot bypass them - it can only do a worse job of the calls
|
||||
the gates don't cover.
|
||||
|
||||
That last clause is the whole rule, turned into a test. Where a check lives in code - `pytest`,
|
||||
`docs verify`, `instructions verify`, CI, the gates - a weaker model's mistake surfaces and costs
|
||||
one more round. Where the only enforcement is a session reading prose, the same mistake does not
|
||||
surface at all: it ships, and it stays until someone happens to notice. The two are not the same
|
||||
risk, and they should not get the same model. This is the argument
|
||||
[docs/why-gates-are-code.md](../docs/why-gates-are-code.md) makes about gates, applied to who is
|
||||
holding the keyboard.
|
||||
|
||||
Both directions cost something, which is why the axis matters rather than a blanket answer:
|
||||
over-provisioning is a standing cost paid every session, while under-provisioning in an unchecked
|
||||
phase is a silent error with a long tail. A corrective session, its bump, its CI runs and its
|
||||
release together cost more compute than the model difference they were saving.
|
||||
|
||||
Claude-Code-only, and imported by CLAUDE.md rather than linked from AGENTS.md: the model names,
|
||||
the `/code-review` effort dial and the `Agent` tool's `model:` override have no equivalent in the
|
||||
@@ -28,17 +42,41 @@ to *make*, not a setting to apply.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Recommend the session's model and effort by the skill in use**, when asked or when the
|
||||
mismatch is worth one sentence. Say it once and continue working either way - a session that
|
||||
argues about its own model instead of doing the task has already cost more than the model
|
||||
difference:
|
||||
1. **Recommend the session's model and effort by what catches a mistake in the phase it is in**,
|
||||
when asked or when the mismatch is worth one sentence. Say it once and continue working either
|
||||
way - a session that argues about its own model instead of doing the task has already cost
|
||||
more than the model difference:
|
||||
|
||||
| Skill / task | Model | Effort |
|
||||
|---|---|---|
|
||||
| `wiki-status`, simple `wiki-query` lookups | Sonnet | default |
|
||||
| `wiki-lint` | Sonnet | default |
|
||||
| `wiki-ingest`, `wiki-manage`, judgment-heavy `wiki-query` | Sonnet | high |
|
||||
| Stack development: `tools/`, `types/`, `instructions/` as code | Opus | high |
|
||||
| Phase / task | What catches a mistake here | Model | Effort |
|
||||
|---|---|---|---|
|
||||
| `wiki-status`, simple `wiki-query` lookups | the answer is re-checkable against the corpus | Sonnet | default |
|
||||
| `wiki-lint` | `lint` itself is the check | Sonnet | default |
|
||||
| `wiki-ingest`, `wiki-manage`, judgment-heavy `wiki-query` | `lint` and `docs verify`, partly - the judgment about a claim is not covered | Sonnet | high |
|
||||
| Stack dev: design, the version part, a boundary-crossing judgment | nothing - `docs verify` checks that a crossing documents itself, never that the part was right | Opus | high |
|
||||
| Stack dev: code, tests, mechanical doc sync (command tables, contract rows) | `pytest`, `docs verify`, `instructions verify`, CI | Sonnet | high |
|
||||
| Stack dev: closing an issue, `docs/` staleness, changelog prose | nothing, by construction - see below | Opus | high |
|
||||
|
||||
**Stack development is not one row**, which is the point of splitting it. The middle phase is
|
||||
where the tokens are and where the checks are, so it is the phase worth running cheaper. The
|
||||
two around it have no mechanical guard at all - a `docs/` page carries no normative sentence,
|
||||
so there is nothing for `docs verify` to check ([AGENTS.md](../AGENTS.md) § File naming), and
|
||||
the same holds for whatever tracker an instance keeps its open work in, which `wikitool`
|
||||
deliberately knows nothing about. Those two phases are short - minutes, not hours - so keeping
|
||||
them on the stronger model is cheap, and it protects the only work in the session that fails
|
||||
silently.
|
||||
|
||||
**Effort is the cheaper lever than the model.** Reach for it first: `medium` deliberately does
|
||||
not appear in this table for stack work, because multi-file consistency is exactly what a
|
||||
reduced effort level gives up. Sonnet at `high` is the floor for anything touching more than
|
||||
one file or a contract; `default` is for a single-file mechanical edit with a test behind it.
|
||||
|
||||
**A session cannot switch its own model**, so these rows only become real if someone offers the
|
||||
switch at the moment the phase changes - once, without arguing about it, and never as a reason
|
||||
to stop work that is already underway.
|
||||
<!-- dist:strip-start -->
|
||||
In this repo those moments are named: the `stack-dev` skill breaks for them at its steps 3
|
||||
(design settled, work turns mechanical) and 6 (publish done, the unchecked tail begins).
|
||||
<!-- dist:strip-end -->
|
||||
|
||||
2. **Pick a spawned subagent's model by what it does**, via the `Agent` tool's `model:`
|
||||
parameter - the values are `haiku`, `sonnet`, `opus`, `fable`:
|
||||
@@ -66,8 +104,14 @@ to *make*, not a setting to apply.
|
||||
mechanical one - `wikitool` carries the mechanical part regardless of which model is
|
||||
supervising it.
|
||||
- **Unsure which row applies?** Default to Sonnet at high effort, not the most capable model at
|
||||
the highest effort. Under-provisioning costs one worse answer in one session; reflexively
|
||||
over-provisioning is a standing cost paid every session.
|
||||
the highest effort. Under-provisioning *where a check exists* costs one worse answer in one
|
||||
session; reflexively over-provisioning is a standing cost paid every session.
|
||||
- **Unsure whether the phase is checked?** Treat it as unchecked. The asymmetry is not symmetric:
|
||||
a needless Opus phase costs money once, an unchecked Sonnet phase can ship something nobody
|
||||
looks at again.
|
||||
- **Mid-session and the phase changed, but nobody switched?** Do the work anyway - never block a
|
||||
publish or an issue close on a model the session cannot change itself. Say which phase ran on
|
||||
which model in the handover, so the gap is visible rather than silent.
|
||||
|
||||
## Scope
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
type: types/instruction.md
|
||||
name: corpus-policy
|
||||
description: What "curated enough" means for kb/ when it is demo and testbed at once, the measurable floors that define it, and what a reactive fix to the corpus may and may not do.
|
||||
---
|
||||
# Keep kb/ curated enough to develop against, without a second corpus
|
||||
|
||||
This instance runs one `kb/` for two purposes at once: a public demo and the testbed this stack
|
||||
is developed against. There is deliberately no fixture corpus, no `--with-demo` export, and no
|
||||
second repository - see Gitea #28. The corpus's size and shape are set by what targeted
|
||||
development needs, not by a synthetic fixture size or a demo aesthetic.
|
||||
|
||||
## When to run
|
||||
|
||||
- Before judging whether the corpus can exercise a change under development - ranking, index
|
||||
scaling, orphan detection, a new label, a new type-spec.
|
||||
- Before a reactive fix touches `kb/` content rather than the failing code - the floors below
|
||||
are what decides whether the fix may proceed as-is.
|
||||
- Picking up Gitea #28 or #30, or any issue that references this file.
|
||||
|
||||
## The floors
|
||||
|
||||
Each is mechanically checkable with an existing `wikitool` command; none needs new tool code.
|
||||
A floor exists to keep some class of bug observable, not to describe an aesthetic target - so
|
||||
when a session is about to make one of these numbers *worse*, that is the signal to stop and
|
||||
think, not a number to defend for its own sake.
|
||||
|
||||
| Floor | Check | Why this number |
|
||||
|---|---|---|
|
||||
| Every page type has ≥1 page | `wikitool search --field type=types/<t>.md` | A type with zero pages means its schema, its collection contract and its lint rules are unexercised |
|
||||
| Every declared subtype has ≥1 page | `wikitool search --field <x>_type=<v>` | Same reasoning, one level down - `entity_type`, `concept_type`, `source_type` |
|
||||
| ≥5 pages corpus-wide with ≥3 `sources:` entries | one-off script, see below | Provenance fan-in - multiple sources backing one claim - is a real case only a handful of pages exercise; fewer than 5 and a provenance-index bug can hide |
|
||||
| Orphan pages (no inbound link) between 1 and 10 | `wikitool lint` | Zero orphans makes orphan detection itself unobservable; more than 10 means the corpus stopped being curated |
|
||||
| Average outbound wikilinks per page ≥4 | one-off script, see below | Below this, ranking and graph-traversal work has too little structure to exercise |
|
||||
|
||||
A floor is a lower bound only. There is no upper bound on page count or on any of these numbers
|
||||
except the orphan ceiling above - a corpus that outgrows these floors through real ingests is
|
||||
not a problem this file cares about.
|
||||
|
||||
**Measured 2026-09-03** (see Gitea #28): 181 pages, 14/14 types and subtypes covered, 12 pages
|
||||
with ≥3 sources, 3 orphans, 6.2 average outbound links. All floors held without any manufactured
|
||||
content - the corpus was already big enough when the question was asked.
|
||||
|
||||
A type or subtype sitting at exactly the floor - one page - shows no set-level bugs, only that
|
||||
the type is *reachable*. That is a soft target for the next `wiki-ingest` that happens to
|
||||
produce a matching page, never a reason to write one: filing an unsourced page to clear a floor
|
||||
is exactly what AGENTS.md invariant 3 forbids, floor or no floor. The same holds for an
|
||||
authorised link label with zero live uses (`wikitool xref` reports these) - fill it when a real
|
||||
edge calls for it, never manufacture one to exercise the label.
|
||||
|
||||
To check the two floors without a dedicated command, walk `kb/**/*.md` (excluding
|
||||
`INDEX.md`/`COLLECTION.md`/`CONTRACT.md`/`CONVENTIONS.md`), parse frontmatter, and: count pages
|
||||
whose `related:` array (resolved against page titles) has ≥3 entries for outbound density; count
|
||||
`sources:` array length ≥3 for the provenance floor. `wikitool search` and `wikitool lint`
|
||||
cover everything else in the table.
|
||||
|
||||
## What a reactive fix may do to kb/ content
|
||||
|
||||
Three tiers, by how much of the corpus a change touches:
|
||||
|
||||
1. **Pointwise - always allowed.** Creating, updating, renaming or deleting a single page
|
||||
through the normal tools (`new`, `touch`, the page-lifecycle procedure), below the
|
||||
Mass-Update Gate's threshold. This is ordinary work and needs no special permission.
|
||||
2. **Corpus-wide - planned only, never reactive.** A migration, a vocabulary sweep, a bulk
|
||||
`touch` across many pages. This needs its own issue and, per `work/CONTRACT.md`, a `work/`
|
||||
run - never a same-session reaction to whatever the session was originally doing. If a
|
||||
session hits the Mass-Update Gate (exit 42, see `instructions/gates.md`) while working on
|
||||
something else, it does not fetch the `--confirm` token to push through: it stops, opens an
|
||||
issue for the corpus-wide change, and finishes the original task without it.
|
||||
3. **Reactive - never allowed.** Deleting or reshaping a page to make a failing test pass;
|
||||
restructuring corpus content to route around a tool bug (AGENTS.md invariant 7); using
|
||||
`kb/` as a scratch surface for a tool experiment. If a stack change under development needs a
|
||||
corpus shape that does not exist, build it as a pytest fixture (see the next section) -
|
||||
never manufacture it in `kb/`.
|
||||
|
||||
## Relationship to the test fixtures
|
||||
|
||||
`tools/chemenu/tests/conftest.py`'s `kb_dir`/`raw_dir` fixtures and `test_pipeline_l0.py` cover
|
||||
the **small, isolated** case: a handful of pages, built fresh per test, hermetic. `kb/` covers
|
||||
the **large, connected** case: 181+ pages, grown link density, real provenance history that no
|
||||
per-test fixture reconstructs economically. The cut: if a `tmp_path` tree can reproduce what the
|
||||
test needs, it belongs in a fixture; if the test needs density or scale that only a grown corpus
|
||||
has, it belongs against `kb/`. Neither absorbs the other's job - see
|
||||
[testing-conventions.md](testing-conventions.md).
|
||||
|
||||
## Decision points
|
||||
|
||||
- **A floor would be violated by an in-progress change - is that a blocker?** Only for the
|
||||
orphan ceiling and the type/subtype floors, since those two can go to zero. The density and
|
||||
provenance floors move gradually with ordinary ingests and are not gating on any single
|
||||
session.
|
||||
- **Corpus is "too small" for a feature under development?** That is not this file's problem to
|
||||
solve by adding pages - see tier 3 above. Either the feature waits for a real ingest to supply
|
||||
the shape, or it gets a pytest fixture.
|
||||
@@ -28,8 +28,14 @@ issues at that URL, which is exactly why `dist export` excludes
|
||||
an assumption nobody has checked, a decision that needs the user.
|
||||
- Picking an issue up: before doing anything else, read the body as the current
|
||||
spec, and re-label it if the ground has moved since.
|
||||
- **While working on one:** the body is updated as the state moves, not at the
|
||||
end (step 2). A session that is interrupted leaves the body as its handover.
|
||||
- Prioritising: deciding what to pick up next, or re-labelling after the ground
|
||||
moved.
|
||||
- Closing one: the body is rewritten to its final state first, and only then
|
||||
closed (step 7).
|
||||
- A rename or move ships: sweep the open issues for text that assumed the old
|
||||
name or path (§ Renames and other decay in the tracker).
|
||||
|
||||
## Steps
|
||||
|
||||
@@ -38,23 +44,93 @@ issues at that URL, which is exactly why `dist export` excludes
|
||||
specific files or commands involved. An issue that only makes sense to
|
||||
whoever wrote it is a note, and notes were the problem.
|
||||
|
||||
2. **Treat the body as the current truth, not as a historical first post.**
|
||||
Work on one issue spans several sessions, often weeks apart, and the body is
|
||||
the only thing that connects them: a session opening the issue must be able
|
||||
to reconstruct what is decided and what is still open from the body alone,
|
||||
without a human re-explaining it. So when the state changes, **rewrite the
|
||||
body** - do not append to a text that has become wrong. An additively grown
|
||||
log forces every later reader to reconstruct the current state by filtering
|
||||
the whole history.
|
||||
**Destructive steps carry the invariant they must not violate.** A body
|
||||
that prescribes a mechanism gets built as prescribed - including its
|
||||
bugs. Where a step deletes, overwrites, resets or moves, name the
|
||||
property that must still hold afterwards, not only the command that gets
|
||||
there. "Remove the working directory, then `git checkout HEAD --
|
||||
<stage>`" is a mechanism; "the content stages must afterwards match
|
||||
`HEAD` exactly, without any untracked or ignored file being touched" is
|
||||
the same instruction plus its test - a build instruction and an
|
||||
acceptance criterion at once, so the defect surfaces while the test is
|
||||
written rather than in review afterwards. #30's `upstream merge` body
|
||||
wrote the mechanism and got exactly that bug: a working-directory removal
|
||||
that took a stage's gitignored, unrecoverable data with it.
|
||||
|
||||
**An acceptance criterion states a checkable property, not an activity.**
|
||||
"Implement X" is done when someone says so; "after `upstream merge`,
|
||||
`reports/` still holds every file it held before" is done when it is
|
||||
true. This is not a ban on imperative steps - a numbered procedure can
|
||||
still produce a correct control flow, and that is its merit - it binds
|
||||
the destructive steps, and every box in the criteria list.
|
||||
|
||||
2. **The body is the working state, not a historical first post - keep it
|
||||
current as you go.** It is this stack's plan file: the same thing a harness's
|
||||
own plan document is, and it is maintained the same way. Not written once,
|
||||
not brought up to date at the end, but **updated whenever something in it
|
||||
stops being true** - a decision made, a criterion met, an approach ruled out,
|
||||
a new constraint found.
|
||||
|
||||
The test is an abort, not a milestone. A session can end at any moment - an
|
||||
interrupt, a context limit, a crash, a human walking away - and whatever the
|
||||
body says at that instant is the entire handover. So the standard is: **at
|
||||
every point, a fresh session must be able to open the body and pick the work
|
||||
up from there**, without a human re-explaining it and without reading back
|
||||
through the comments. If the body would mislead someone who read it right
|
||||
now, it is already out of date, whether or not the work is finished.
|
||||
|
||||
That means updating *during* the work, not only at its end:
|
||||
|
||||
- a decision gets made → the decision and its reasoning replace the question
|
||||
- an acceptance criterion is done → tick it, in the same session that did it
|
||||
- something turns out differently than the issue assumed → the assumption is
|
||||
corrected where it stands, not contradicted three paragraphs later
|
||||
- work is deferred or dropped → say so, with the reason, where the criterion is
|
||||
|
||||
**Rewrite, never append.** Do not add to a text that has become wrong: an
|
||||
additively grown log forces every later reader to reconstruct the current
|
||||
state by filtering the whole history, which is the exact cost the body exists
|
||||
to remove. Comments carry the history (step 3); the body carries the state.
|
||||
|
||||
Body rewrites and comments are an LLM session's job. A human normally
|
||||
touches only labels and metadata directly.
|
||||
|
||||
3. **Comment a changelog, never a copy.** Every body rewrite gets one short
|
||||
comment naming only what changed against the previous state - what is new,
|
||||
what is gone, what was corrected. Do not snapshot the old body into a
|
||||
comment: a full copy per revision forces a human to diff two prose texts,
|
||||
which is not a readable history, only another copy.
|
||||
**Reading an issue, the body is the state and comments are history.** A
|
||||
session picking an issue up reads the body as the spec; comments are read
|
||||
for provenance - why something was decided, what was tried - never as
|
||||
the current instruction. A recommendation in a comment can be older than
|
||||
the body's decision and read just as convincingly: on #30 an earlier
|
||||
comment recommended a smaller, `verify`-only command, while the body had
|
||||
since settled on building the full `merge` command. A session trusting
|
||||
the comment would have built the wrong thing, with a plausible
|
||||
justification out of this repo's own tracker.
|
||||
|
||||
**A body that is demonstrably wrong is corrected first, not worked
|
||||
around.** "Body beats comment" is a rule of precedence, not a licence to
|
||||
execute a stale spec. Where a comment or the tree proves a claim in the
|
||||
body false, the body is rewritten before the work starts - the rewrite
|
||||
above is the fix; leaning on the comments as the "real" state is not.
|
||||
#10 is the case: its body claimed coverage had never been measured while
|
||||
three comments carried a percentage, a statement count and a CI run
|
||||
number.
|
||||
|
||||
**Where two comments contradict each other, evidence decides, not
|
||||
recency.** On #10, one comment showed a retrieved artifact with zero
|
||||
items on a finished run - the report was not actually retrievable - and
|
||||
a later comment declared the same criterion met without re-checking. The
|
||||
later comment is not the newer truth, only the unchecked one. Resolve it
|
||||
into the body with the evidence named, or mark the point open.
|
||||
|
||||
3. **Comment a changelog, never a copy.** A body rewrite gets one short comment
|
||||
naming only what changed against the previous state - what is new, what is
|
||||
gone, what was corrected. Do not snapshot the old body into a comment: a full
|
||||
copy per revision forces a human to diff two prose texts, which is not a
|
||||
readable history, only another copy.
|
||||
|
||||
One comment per *session's worth* of change, not per edit. Step 2 asks the
|
||||
body to be kept current continuously, and a comment for every tick would bury
|
||||
the board in noise; the changelog line summarises what that session moved.
|
||||
Trivial upkeep - a typo, a tightened sentence - needs no comment at all.
|
||||
|
||||
```
|
||||
**Changelog:** Decision 2 tightened - `kind/` may now change over an
|
||||
@@ -127,10 +203,67 @@ issues at that URL, which is exactly why `dist export` excludes
|
||||
answered can drop a size and move `kind/decision` to `kind/build`. Silent
|
||||
re-labelling is how a board stops meaning anything.
|
||||
|
||||
7. **Close with what actually happened**, not with a commit hash alone: which
|
||||
proposals were implemented, which were deliberately left out and why, and
|
||||
what was verified. The issue is the only place that record survives - a
|
||||
changelog entry says what changed, not what was decided against.
|
||||
7. **Closing is the last body update, not a comment.** If step 2 was followed
|
||||
the body is already nearly there, and closing only settles what the final
|
||||
run established. If it was not, closing is where the whole debt comes due -
|
||||
and it comes due at the worst moment, because a closed body is the version
|
||||
everyone reads afterwards and nobody revisits.
|
||||
|
||||
Either way the body reaches its final state *before* the issue closes:
|
||||
proposals that were decided read as decided, a "to decide" section has become
|
||||
the decision with its reasoning, acceptance criteria are ticked or struck with
|
||||
a reason, and what was verified is named. Then close, with the one-line
|
||||
changelog comment step 3 asks for.
|
||||
|
||||
Record what actually happened, not a commit hash alone: which proposals were
|
||||
implemented, which were deliberately left out and why, and what was verified.
|
||||
The issue is the only place that record survives - a changelog entry says
|
||||
what changed, not what was decided against.
|
||||
|
||||
**A closing report in a comment does not satisfy this.** It reads as
|
||||
complete to whoever writes it and leaves a body still phrased as open work:
|
||||
unticked boxes, an undecided decision section, present tense about a defect
|
||||
that no longer exists. #44 closed exactly that way, with a thorough comment
|
||||
above a body that still asked for a decision that had already been made and
|
||||
shipped. Nothing mechanical catches it (see below), which is why it is a step
|
||||
rather than a habit.
|
||||
|
||||
## Renames and other decay in the tracker
|
||||
|
||||
A rename is not finished when the tree is green. Renaming a package, a path,
|
||||
a command, a flag or the repository itself moves text that lives outside the
|
||||
working tree, and the open issues are the largest such text. Nothing catches
|
||||
them - `wikitool` does not know this tracker exists and must not learn (see
|
||||
"What no tool checks" below) - so a pass over the open issues is part of the
|
||||
rename, in the session that did it, not a follow-up someone remembers.
|
||||
|
||||
Distinguish a wayfinder from a piece of evidence: a path meant to point at
|
||||
where something *is* gets pulled through; a path quoted for what was true at
|
||||
a time is left standing and dated. Note per corrected body what was pulled
|
||||
through and when, so the next pass can tell a checked body from one that
|
||||
merely looks right. Closed issues are out of scope - they guide nobody.
|
||||
|
||||
Renames are not the only thing that ages an issue text. A page a body cites
|
||||
can vanish from `kb/` (`wikitool search` against the cited titles is the
|
||||
second pass), and an old body can carry private infrastructure detail into
|
||||
what is now a public tracker - both found in the same issue, both worth the
|
||||
same look.
|
||||
|
||||
## What no tool checks
|
||||
|
||||
`wikitool` does not know this tracker exists, and should not learn. It ships to
|
||||
instances that have no issues at that URL, while this file and the workflow it
|
||||
describes are pruned by `dist export` - a Gitea client inside the shipped tool
|
||||
would be a dev-only dependency carried by every instance, to check a board none
|
||||
of them have. The tracker is reachable only through the `gitea-mcp` server, in a
|
||||
session, by an agent.
|
||||
|
||||
So there is no `docs verify` for the board. Nothing reports a closed issue whose
|
||||
body still reads as open, a body that contradicts its own comments, or an issue
|
||||
missing one of the four mandatory labels. Every one of those is caught by a
|
||||
session following this file, or not at all - which is the argument for the
|
||||
sequence in step 7 being explicit about the order (body first, then close),
|
||||
rather than leaving it to be inferred from step 2.
|
||||
|
||||
## Decision points
|
||||
|
||||
@@ -144,7 +277,8 @@ issues at that URL, which is exactly why `dist export` excludes
|
||||
- **Rewrite the body, or add a comment?** Rewrite whenever a reader of the body
|
||||
alone would otherwise be misled - a changed decision, a dropped criterion, a
|
||||
new constraint. A comment carries the changelog line for that rewrite, and
|
||||
nothing else that a future session needs in order to act.
|
||||
nothing else that a future session needs in order to act. Closing an issue is
|
||||
always a rewrite - see step 7.
|
||||
- **An old issue carries only `prio/` and `size/`?** Complete it to all four
|
||||
when you touch it, rather than in a sweep. The board reaches the new scheme
|
||||
issue by issue, as each is picked up.
|
||||
|
||||
@@ -43,18 +43,46 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
||||
engineering, memory and deploy-time learning; consult before a design decision in those
|
||||
areas.
|
||||
[issue-tracking.md](../issue-tracking.md) - open work lives in Gitea issues, one per work
|
||||
package, labelled `area/`, `kind/`, `prio/` and `size/`, with the body kept as the current
|
||||
truth rather than as a first post. There is no `TODO.md`. Read it before filing something
|
||||
for later, before editing an issue, or before deciding what to pick up next.
|
||||
package, labelled `area/`, `kind/`, `prio/` and `size/`. There is no `TODO.md`. **The body
|
||||
of the issue you are working on is this session's plan file:** keep it current as the state
|
||||
moves, so an interrupted session leaves a body the next one can resume from, *and* rewrite it
|
||||
to its final state before closing. Both halves bind; the second is step 6 below. Read it
|
||||
before filing something for later, before editing or closing an issue, or before deciding
|
||||
what to pick up next.
|
||||
[testing-conventions.md](../testing-conventions.md) - the suite runs against a deliberately
|
||||
empty machine; what the autouse fixture already neutralizes, and what a test still has to
|
||||
establish itself. Read it before adding or changing a test.
|
||||
[version-parts.md](../version-parts.md) - which part a change bumps: the drop-in test, the
|
||||
catalogue of breaks that cross the compatibility boundary with `kb/` untouched, and what to
|
||||
put in front of the user before a breaking bump. Read it before step 3.
|
||||
put in front of the user before a breaking bump. Read it before step 4.
|
||||
[corpus-policy.md](../corpus-policy.md) - what "curated enough" means for the shared
|
||||
demo/testbed `kb/`, the measurable floors that define it, and what a reactive fix may and may
|
||||
not do to corpus content. Read it before judging whether the corpus can exercise a change, or
|
||||
before any fix that would touch `kb/` content.
|
||||
More instructions are added here incrementally as stack-development needs come up - this
|
||||
list grows without needing this skill file to change shape.
|
||||
3. **Raise the version, if the change ships.** A change under `tools/`, `types/`,
|
||||
3. **Settle the design before building - and break there for the model switch.** These are two
|
||||
different kinds of work, and the split is not stylistic: design, the version part and any
|
||||
boundary judgment have **no** mechanical guard, while the code and tests that follow have
|
||||
`pytest`, `docs verify`, `instructions verify` and CI behind them.
|
||||
|
||||
So when the design is settled - the issue body says what will be built, the open questions are
|
||||
answered - stop and say so, in one sentence:
|
||||
|
||||
> Der Plan steht, ab hier ist die Arbeit mechanisch und durch Tests/CI abgedeckt. Wenn du auf
|
||||
> Opus bist, ist jetzt der Moment für `/model sonnet` bei Effort `high`.
|
||||
|
||||
**You cannot make this switch yourself** - the session's model is the user's `/model`, not a
|
||||
setting an agent applies. Offer it once and keep working either way; a session that argues
|
||||
about its own model has already cost more than the difference. If the design turns out not to
|
||||
be settled after all - a boundary crossing surfaces, an assumption breaks - that is a reason to
|
||||
offer the switch back up, not to decide it alone.
|
||||
|
||||
Effort is the cheaper lever than the model, and `high` is the floor for anything touching more
|
||||
than one file or a contract. Full table and reasoning:
|
||||
[claude-code-model-selection.md](../../claude-code-model-selection.md).
|
||||
|
||||
4. **Raise the version, if the change ships.** A change under `tools/`, `types/`,
|
||||
`instructions/`, `AGENTS.md` or a `CONTRACT.md` reaches every future instance, so it needs a
|
||||
version and a changelog entry:
|
||||
|
||||
@@ -91,13 +119,48 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
||||
Prose-only changes (`README.md`, `INSTALL.md`, `EVALS.md`) and the workflows under `.gitea/`
|
||||
do not need a bump - CI's version gate is scoped to what changes behaviour.
|
||||
|
||||
4. **Verify before publishing.** `tools/wikitool docs verify`, `tools/wikitool instructions
|
||||
5. **Verify before publishing.** `tools/wikitool docs verify`, `tools/wikitool instructions
|
||||
verify`, and the relevant `pytest` run in `tools/` - the same checks any stack change must
|
||||
pass, run explicitly rather than assumed. CI (`.gitea/workflows/ci.yml`) runs these plus a
|
||||
full `setup-instance.md` replay against a fresh `dist export`; a push to `main` that moves
|
||||
`VERSION` additionally triggers a tagged release. **CI does the tagging** - a session never
|
||||
creates a tag, which is what keeps AGENTS.md invariant 5 intact.
|
||||
|
||||
6. **Close the issue with a body rewrite, not a comment.** The last act of a session that
|
||||
finished a work package, and the one most easily skipped: by here the change is published and
|
||||
the issue feels done. It is not. The body is the version everyone reads afterwards and nobody
|
||||
revisits, so it is the one place the debt comes due at the worst moment.
|
||||
|
||||
**Break here too, in the other direction.** Everything left in the session - this rewrite,
|
||||
whether a `docs/` page's reasoning went stale, the changelog prose - is the unchecked kind of
|
||||
work again, the mirror of step 3. If the session dropped to Sonnet there, say so now:
|
||||
|
||||
> Ab hier greift kein maschineller Check mehr - Issue-Body, `docs/`-Veralterung und
|
||||
> Changelog-Prosa prüft nichts. Wenn du zurück auf Opus willst, ist jetzt der Moment.
|
||||
|
||||
Then **do the work regardless of the answer.** Never block a close on a model switch: the
|
||||
change is already published, and a session that stops here leaves exactly the state this step
|
||||
exists to prevent. If it ran on the cheaper model, name that in the handover rather than
|
||||
leaving it silent.
|
||||
|
||||
Rewrite it to its final state *first*, then close. The test is what a reader who opens the
|
||||
closed issue tomorrow would conclude:
|
||||
|
||||
- every acceptance criterion ticked, or struck with the reason it was dropped
|
||||
- proposals that were decided read as decided; a "to decide" section has become the decision
|
||||
and its reasoning
|
||||
- nothing left in the present tense about a defect that no longer exists
|
||||
- what was verified is named - which checks ran, which CI run - not a commit hash alone
|
||||
|
||||
Then one short comment naming what changed against the previous state, and nothing else.
|
||||
|
||||
**A closing report in a comment does not satisfy this**, however thorough: it reads as
|
||||
complete to whoever writes it and leaves a body still phrased as open work. Nothing
|
||||
mechanical catches it - `wikitool` does not know this tracker exists and must not learn it,
|
||||
since it ships to instances that have no board - so this step is the only enforcement there
|
||||
is. #44 and #45 both closed exactly this way, the second an hour after the rule was written.
|
||||
[issue-tracking.md](../issue-tracking.md) step 7 has the full shape.
|
||||
|
||||
## Decision points
|
||||
|
||||
- **Touches both stack code and wiki content in one session?** Apply this skill's rules to the
|
||||
@@ -108,7 +171,10 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
||||
user decides whether it is worth that: show them what breaks, what an instance has to do about
|
||||
it, and the alternatives (avoid the break with a shim, defer and batch it with the next one,
|
||||
or split it behind a deprecation window), then recommend one and wait for a go-ahead.
|
||||
[version-parts.md](../version-parts.md) step 4 has the full shape.
|
||||
[version-parts.md](../version-parts.md) step 4 has the full shape. A surfacing boundary crossing
|
||||
is also a reason to offer the model switch back up (step 3): the judgment it needs has no
|
||||
mechanical guard, and `docs verify` only checks that a crossing documents itself, never that the
|
||||
part was chosen correctly.
|
||||
|
||||
## Scope
|
||||
|
||||
|
||||
@@ -40,6 +40,48 @@ resolved paths and `conventions`' parsed `kb/CONVENTIONS.md`. A test that *rewri
|
||||
conventions file mid-test calls `conventions.reset_cache()` itself - the fixture answers for the
|
||||
boundary between tests, not for one inside a test.
|
||||
|
||||
## Which tree a test writes into
|
||||
|
||||
The environment is one half of the isolation; `config.ROOT` is the other. With `CHEMENU_ROOT`
|
||||
cleared, `ROOT` falls back to the checkout pytest is running from - deliberately, because most
|
||||
tests want the shipped `types/`. It also means that any code path resolving a file through
|
||||
`config.ROOT` or `config.KB_DIR` reaches **the real repository**, no matter which tree the
|
||||
fixture built.
|
||||
|
||||
Both corpus fixtures therefore repoint it: `raw_dir` and `kb_dir` each set
|
||||
`config.ROOT` to their `tmp_path` and re-declare the shipped `types/` through
|
||||
`use_shipped_type_specs()`. `config`'s module `__getattr__` resolves the derived paths on
|
||||
access, so repointing `ROOT` carries `KB_DIR`, `RAW_DIR` and the rest with it. A new fixture
|
||||
that builds a tree does the same thing - that is the rule here, not a per-test judgment.
|
||||
|
||||
`kb_dir` did not, until Gitea #44. Two things came of that. A test calling
|
||||
`kb_state.write_kb_state()` overwrote the real `.wikitool-kb.json`, which `git status` made
|
||||
visible within the minute. Quieter and worse: `lint`'s collection lookup resolved a page
|
||||
against `config.KB_DIR`, so every fixture page read back as "no collection" and the
|
||||
`unauthorised_labels` check skipped every edge in silence - the finding had no working test at
|
||||
all, and its green run read like an assurance.
|
||||
|
||||
Two guards came out of it, both in `conftest.py`:
|
||||
|
||||
| Guard | Default | Cost |
|
||||
|---|---|---|
|
||||
| `repository_tree_guard` (session) | on | two `git status --porcelain` calls per run |
|
||||
| `per_test_tree_guard` | off, `CHEMENU_TREE_GUARD=each` turns it on | one `git status` per test |
|
||||
|
||||
The session guard compares the working tree before against after and fails the run if anything
|
||||
moved, so it says nothing about uncommitted work a developer already had. It cannot name the
|
||||
test that did it; `CHEMENU_TREE_GUARD=each` can, and is the way to bisect once it fires. Where
|
||||
git is unavailable or the checkout is not a repository, both are silently inert.
|
||||
|
||||
Neither guard sees the second, quieter half: a check that silently *does nothing* under test
|
||||
writes no file. That one is only caught by a test that asserts the finding actually fires -
|
||||
which is why `test_unauthorised_label_is_judged_in_a_tree_that_is_not_the_configured_kb`
|
||||
lints a tree `ROOT` deliberately points away from.
|
||||
|
||||
**A function that takes a directory resolves against that directory.** `run_lint(kb_dir)`
|
||||
reading `config.KB_DIR` for one of its own lookups was the defect behind the quiet half, and
|
||||
no fixture can fix that shape from the outside.
|
||||
|
||||
## When to run
|
||||
|
||||
Whenever you add or change a test under `tools/chemenu/tests/`.
|
||||
@@ -80,7 +122,12 @@ Whenever you add or change a test under `tools/chemenu/tests/`.
|
||||
`conftest.py` in the same change. A variable the tool reads and the fixture does not clear
|
||||
is the exact hole this whole file is about, reopened.
|
||||
|
||||
5. **Verify against an empty machine before publishing**, not only in your own shell:
|
||||
5. **Writing a fixture that builds a tree?** Repoint `config.ROOT` at it and call
|
||||
`use_shipped_type_specs(monkeypatch)`, as `raw_dir` and `kb_dir` do - see
|
||||
[Which tree a test writes into](#which-tree-a-test-writes-into). A fixture that returns a
|
||||
path without repointing hands the code under test the real repository.
|
||||
|
||||
6. **Verify against an empty machine before publishing**, not only in your own shell:
|
||||
|
||||
```bash
|
||||
cd tools && env -i PATH="$PATH" HOME="$(mktemp -d)" \
|
||||
@@ -92,7 +139,7 @@ Whenever you add or change a test under `tools/chemenu/tests/`.
|
||||
`.venv/bin/python -m pytest -q`. A difference between the two is a leak, and the leaking
|
||||
variable belongs in step 4's list.
|
||||
|
||||
6. **Check the coverage report when adding tests to close a gap**, rather than guessing which
|
||||
7. **Check the coverage report when adding tests to close a gap**, rather than guessing which
|
||||
lines were uncovered:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -9,7 +9,7 @@ Two questions decide a version bump, and they are **not the same question**:
|
||||
|
||||
1. **Is the new version a drop-in replacement for the old one?** This is what the version
|
||||
number itself says. Compatibility is read off the **leftmost non-zero component** - on this
|
||||
stack (`2.x`) that is MAJOR, on a `0.x` stack it is MINOR. A bump that changes it is called
|
||||
stack (`4.x`) that is MAJOR, on a `0.x` stack it is MINOR. A bump that changes it is called
|
||||
*boundary-crossing* below, because that is the term `version bump` and `docs verify` use in
|
||||
their own messages.
|
||||
2. **Must existing content be migrated?** This is a *consequence* a boundary crossing may or
|
||||
@@ -20,6 +20,38 @@ Two questions decide a version bump, and they are **not the same question**:
|
||||
Getting these backwards is how a genuinely breaking change ships as a MINOR. It happened once
|
||||
already (see the case study at the end), which is why this file exists.
|
||||
|
||||
## The candidate model
|
||||
|
||||
Between two releases the stack carries **one running candidate**, not a fresh version per
|
||||
`bump`. Before that, every bump minted a number *and* a release: CI's version gate requires
|
||||
`VERSION` to move on every stack-touching push, and `release.yml` fires on every `VERSION`
|
||||
move, so releases were being cut at commit granularity. 2026-09-03 produced four of them in
|
||||
six hours (`4.3.0` through `4.3.3`) for one arc of work - all four real, none of them a
|
||||
meaningful unit to anyone downstream. A candidate closes that gap without touching the gate:
|
||||
`VERSION` still moves on every bump, it just escalates the *same* number instead of handing out
|
||||
a new one, and only `version release` turns it into something the release workflow acts on.
|
||||
|
||||
- **State lives in `VERSION` itself**, as an optional `-beta.N` suffix (`4.4.0-beta.3`). No
|
||||
second state file: the last release is read back out of `CHANGES.md` (the newest entry with no
|
||||
suffix), and the escalation stage is the difference between the candidate's base and that
|
||||
release - derived, not stored.
|
||||
- **`--major`/`--minor`/`--patch` is max-wins escalation**, not a step you can undo. A `--patch`
|
||||
bump on a candidate already at MINOR only advances its bump count (`N`); nothing ever steps a
|
||||
candidate back down. Declaring the part is still your judgment call, made the same way the
|
||||
steps below describe - `escalate()` only ever raises it further.
|
||||
- **A candidate is never released.** Pre-release is a dev-checkout state; `release.yml` only acts
|
||||
on a suffix-free `VERSION`, so a distributed instance never sees a `-beta.` version at all, and
|
||||
its parser never has to know the suffix exists.
|
||||
- **One `CHANGES.md` entry per candidate**, not per bump. The first bump of a candidate opens it
|
||||
(heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list seeded with that
|
||||
bump's `--title`); every later bump of the *same* candidate updates that entry in place -
|
||||
heading, date and the bumps list all move, but the entry's own prose (written below the
|
||||
skeleton, by hand) is left alone. `version notes` therefore still prints exactly one entry per
|
||||
release, whatever a candidate's history of bumps looked like.
|
||||
- **`version bump` opens or continues a candidate; `version release` fixes one.** Only `release`
|
||||
strips the suffix and turns the entry into a real, closed release - see its own row in
|
||||
`tools/CONTRACT.md`. Nothing else does, and nothing auto-fixes a candidate on its own.
|
||||
|
||||
## When to run
|
||||
|
||||
Before every `tools/wikitool version bump` - the `stack-dev` skill's step 3 sends you here.
|
||||
@@ -63,9 +95,11 @@ the three-line test below is usually enough.
|
||||
| Fix, no interface change | `--patch` |
|
||||
| New capability, drop-in in both directions | `--minor` |
|
||||
|
||||
4. **Stop and talk to the user before a boundary-crossing bump.** It is expensive in a way the
|
||||
other two parts are not: every existing instance pays for it, once, by hand. Put in front of
|
||||
them, in this order:
|
||||
4. **Stop and talk to the user before the bump that first escalates a candidate past the
|
||||
boundary.** It is expensive in a way the other two parts are not: every existing instance pays
|
||||
for it, once, by hand. That escalation happens exactly once per candidate - a later bump that
|
||||
keeps the candidate at the same stage (another `--major` on one already there, say) does not
|
||||
re-cross anything and needs no second conversation. Put in front of the user, in this order:
|
||||
|
||||
- **What breaks**, concretely - which file, which name, which call site.
|
||||
- **What each existing instance must do**, as the steps they would actually run.
|
||||
@@ -81,8 +115,9 @@ the three-line test below is usually enough.
|
||||
|
||||
Then wait for an explicit go-ahead. Do not bump across the boundary on your own initiative.
|
||||
|
||||
5. **Record the break in the bump itself.** A boundary-crossing bump requires
|
||||
`--breaking "<what breaks>"`, which writes a `**Breaking Change:**` line into the entry:
|
||||
5. **Record the break in the escalation bump itself.** The bump that first crosses the boundary
|
||||
requires `--breaking "<what breaks>"`, which writes a `**Breaking Change:**` line into the
|
||||
entry:
|
||||
|
||||
```bash
|
||||
tools/wikitool version bump --major \
|
||||
@@ -91,22 +126,33 @@ the three-line test below is usually enough.
|
||||
--no-migration "<why no page has to change>" # only if that is true
|
||||
```
|
||||
|
||||
`--breaking` is refused on a bump that crosses nothing, and required on one that does;
|
||||
`docs verify` checks the newest boundary-crossing entry still carries the line. Write it for
|
||||
the operator of an instance that has not read this repository: what stops working, and what
|
||||
they do about it.
|
||||
The line, once written, stays in the entry across every later bump of the same candidate -
|
||||
a follow-up `--major` does not need to repeat `--breaking`, because the entry it would repeat
|
||||
it into is the same one. `--breaking` is refused on a bump that crosses nothing, and required
|
||||
on the one that does. `docs verify` checks the newest boundary-crossing entry still carries
|
||||
the line. Write it for the operator of an instance that has not read this repository: what
|
||||
stops working, and what they do about it.
|
||||
|
||||
6. **Then answer the migration question separately.** Boundary-crossing and
|
||||
content-migrating are independent:
|
||||
|
||||
- Content must change → write the migration document under `instructions/migrations/` per
|
||||
[migrate-corpus.md](../migrate-corpus.md). `bump` finds it by its `migrates_to:` field.
|
||||
[migrate-corpus.md](../migrate-corpus.md). The escalation bump finds it by the document's
|
||||
`migrates_to:` field, matched against the candidate's **base** - a document targets the
|
||||
release the candidate will become, never a `-beta.N` form of it.
|
||||
- Content need not change → `--no-migration "<reason>"`, which records that in the entry.
|
||||
|
||||
Both are also needed by `docs verify`, for the same reason: an instance that learns it must
|
||||
migrate, with nothing telling it how, is a dead end.
|
||||
migrate, with nothing telling it how, is a dead end. Like `--breaking`, both persist across
|
||||
later bumps of the same candidate without being repeated.
|
||||
|
||||
7. **Write the entry's body.** `bump` leaves it empty on purpose. A boundary-crossing entry
|
||||
7. **Fix the candidate once it is ready to ship.** `version bump` only ever opens or escalates
|
||||
one; nothing turns it into a release except `tools/wikitool version release`, which strips the
|
||||
`-beta.N` suffix and closes the entry - see its row in `tools/CONTRACT.md`. That is also the
|
||||
point to pass a summarising `--title` if the candidate collected several bump titles along the
|
||||
way; without one, the heading simply keeps whichever bump last set it.
|
||||
|
||||
8. **Write the entry's body.** `bump` leaves it empty on purpose. A boundary-crossing entry
|
||||
earns a paragraph that says *why this is breaking* - it is the one thing a future reader
|
||||
cannot reconstruct from the diff, and it is what the next session in this position will read
|
||||
instead of guessing.
|
||||
|
||||
@@ -103,6 +103,26 @@ The setup this gate exists for - a private instance that takes stack updates fro
|
||||
upstream - is [private-instance.md](private-instance.md). Step 4 there arms it, deliberately
|
||||
*before* the first `publish`: added afterwards it leaves open exactly the window it closes.
|
||||
|
||||
### Mass-Update Gate blind spot: `upstream merge`
|
||||
|
||||
`upstream merge` (a private instance taking a stack update - see
|
||||
[private-instance.md](private-instance.md)) can update or delete dozens of stack-owned paths in
|
||||
one commit, and the Mass-Update Gate does not see any of it. The gate counts *working-tree*
|
||||
changes before `publish` stages them; by the time `upstream merge` commits, the change is
|
||||
already history, and the commit it made is not what a later `publish` would be staging - that
|
||||
publish sees only whatever this session adds on top. A merge touching 200 files therefore goes
|
||||
out ungated the moment it is pushed.
|
||||
|
||||
This is not a hole to patch by making `upstream merge` route through the gate: the gate's
|
||||
question ("is this too much to publish?") does not apply to a change that only ever touches
|
||||
stack-owned paths that are, by definition, not this instance's own content. The check that
|
||||
actually matters here is `upstream merge`'s own postcheck - it re-verifies the merge commit
|
||||
against `upstream verify`'s logic immediately after committing, and exits 1 with the offending
|
||||
paths if anything landed outside a stack-owned one. **The merge commit is deliberately left in
|
||||
place** rather than reverted: it exists, a human has to look at it, and a command that quietly
|
||||
repaired its own mistake would hide the one event worth seeing. That postcheck is the safeguard
|
||||
for this command, not the Mass-Update Gate.
|
||||
|
||||
## Iteration Budget Gate and loop-breaker
|
||||
|
||||
Every `wikitool` call is counted per session. Calls are refused past **60 in a session**, or
|
||||
|
||||
@@ -68,7 +68,7 @@ stack's hardcoded behaviour until the conventions file existed.
|
||||
|---|---|
|
||||
| `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 |
|
||||
| Naming | Human-readable titles with spaces; singular for entities; a decision named like any other concept, no `adr-NNN-` prefix; `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 |
|
||||
|
||||
@@ -108,69 +108,53 @@ So the merge has to be scoped. That is the procedure below, and it is not option
|
||||
|
||||
## 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.
|
||||
```bash
|
||||
tools/wikitool upstream merge --remote upstream --branch main
|
||||
```
|
||||
|
||||
**Three files under those stages are machinery, not content**, and forcing them back is how an
|
||||
upstream contract change gets silently discarded:
|
||||
Take the machinery, never the content. This is the command form of the same idea a hand-rolled
|
||||
merge would need: hold the merge open, force the content stages back to your own state, restore
|
||||
only the paths that are machinery, and only then let it close. Which paths those are is not a
|
||||
short literal list any more (see below) - it is `chemenu.ownership.is_stack_owned`, the same
|
||||
predicate `dist_cmd.py`'s export reads, so a stack change that adds a new machinery path under a
|
||||
content stage is recognised automatically rather than needing this document edited first.
|
||||
|
||||
| Path | Why it must take the upstream side |
|
||||
**What counts as machinery under a content stage**, for readers who want the shape rather than
|
||||
the code:
|
||||
|
||||
| Path | Why it takes 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 |
|
||||
| `<stage>/CONTRACT.md` (`kb/CONTRACT.md`, `raw/CONTRACT.md`, `work/CONTRACT.md`, `reports/CONTRACT.md`) | The stack's own stage contract. Every rule in it is enforced by `wikitool`; an instance never edits it |
|
||||
| any `*.template` under a content stage (`kb/CONVENTIONS.md.template`, each `kb/<name>/COLLECTION.md.template`, and any later one) | The template your filled file was adopted from. The filled file is yours; the template is the stack's |
|
||||
|
||||
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.
|
||||
Everything else under `kb/`, `raw/`, `work/` and `reports/` is yours, `kb/CONVENTIONS.md` and
|
||||
each `kb/<name>/COLLECTION.md` included - they bind your corpus, and they are exactly what
|
||||
`upstream merge` protects.
|
||||
|
||||
```bash
|
||||
BEFORE=$(git rev-parse HEAD)
|
||||
git fetch upstream
|
||||
**Your local, uncommitted-by-design files under those stages survive.** Forcing a content stage
|
||||
back to your own state removes only what git tracks, never the directory wholesale - which
|
||||
matters because `reports/` is gitignored apart from its contract, so it holds data that is in no
|
||||
commit and cannot be recomputed: the telemetry traces `eval score` reads, saved eval reports,
|
||||
past lint reports. A merge has no business touching any of it, and does not.
|
||||
|
||||
# --no-commit holds the merge open; it may report conflicts under kb/ or raw/,
|
||||
# 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
|
||||
# pre-merge commit while the merge is open, so this restores exactly your side.
|
||||
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. 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 \
|
||||
| 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.
|
||||
The command itself checks its own result the same way `upstream verify` would, immediately
|
||||
after committing, and refuses loudly - without rolling the commit back - if anything landed
|
||||
outside a stack-owned path. A refusal there is a bug report, not something to work around by
|
||||
hand; see [tools/CONTRACT.md](../tools/CONTRACT.md) for the full error contract, including what
|
||||
a real conflict in `tools/`/`types/`/`instructions/` leaves behind.
|
||||
|
||||
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.
|
||||
|
||||
**Why not just `git merge upstream/main`?** Because of the table above: a page the upstream
|
||||
*adds* arrives with no conflict and no message. You would find out when `lint` starts reporting
|
||||
pages you never wrote - if you noticed at all.
|
||||
**Why not just `git merge upstream/main`?** A page the upstream *adds* arrives with no conflict
|
||||
and no message under a plain merge - measured in the table further up this document. You would
|
||||
find out when `lint` starts reporting pages you never wrote, if you noticed at all. `upstream
|
||||
merge` closes exactly that gap: the content stages never see the upstream's version at all.
|
||||
|
||||
**Checking a merge you resolved by hand instead** (or auditing a past one): `tools/wikitool
|
||||
upstream verify --since <rev-before> --until <rev-after>` runs the same check `upstream merge`
|
||||
runs on itself, without doing the merge.
|
||||
|
||||
## Where stack development happens
|
||||
|
||||
@@ -186,17 +170,21 @@ merge above. Nothing is lost by the detour: the fix has to pass that CI either w
|
||||
|
||||
## Decision points
|
||||
|
||||
- **Merge conflict in `kb/` or `raw/`?** Expected, and already handled: the update procedure
|
||||
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 - 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.
|
||||
- **Merge conflict in `kb/`, `raw/`, `work/` or `reports/`?** Expected, and already handled:
|
||||
`upstream merge` overwrites those stages with your own afterwards, so the conflict resolves
|
||||
itself. Never resolve one by hand with `git add -A` in a merge you are running yourself
|
||||
instead - that is exactly how the upstream version, which git left sitting in your working
|
||||
tree, gets committed into your instance.
|
||||
- **`upstream merge` exits 1 after committing?** Read the message: its own postcheck found
|
||||
content outside a stack-owned path in the commit it just made. The commit is **not** rolled
|
||||
back - inspect it (`git show`, or `tools/wikitool upstream verify --since <before> --until
|
||||
HEAD`) and decide by hand whether to revert it, fix forward, or report it as a stack bug. This
|
||||
should not happen; if it does, `chemenu.ownership.is_stack_owned` disagreed with itself between
|
||||
the restore and the check, which is exactly what the shared predicate is meant to prevent.
|
||||
- **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.
|
||||
step "Where stack development happens" says not to do. `upstream merge` leaves the merge open
|
||||
rather than guessing - take the upstream side for the named paths and re-file the change as an
|
||||
issue there, or resolve deliberately and finish the commit yourself.
|
||||
- **...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
|
||||
|
||||
+2
-1
@@ -62,7 +62,8 @@ There is no `## Siehe auch` region any more. It was the reciprocal half of a bid
|
||||
- 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`.
|
||||
- A decision (`concept_type: decision`) is named like any other concept - no `adr-NNN-` prefix.
|
||||
See [kb/concepts/COLLECTION.md § Decisions](concepts/COLLECTION.md#decisions).
|
||||
- Prefer readability over convention when the two conflict.
|
||||
|
||||
What to name a thing: projects use their repository or common name; systems a descriptive
|
||||
|
||||
+18
-10
@@ -27,19 +27,27 @@ tone, relationship labels, the confidence rubric. Neither is restated here.
|
||||
|
||||
`concept` (`tools/wikitool types describe concept`).
|
||||
|
||||
## Decisions and ADRs
|
||||
## Decisions
|
||||
|
||||
An architectural decision is a concept page, prefixed as
|
||||
[kb/CONVENTIONS.md § Naming](../CONVENTIONS.md#naming) says. It records:
|
||||
An architectural decision is an ordinary concept page with `concept_type: decision`
|
||||
(`tools/wikitool types describe concept`) - not a separate format, and not a separate location.
|
||||
There is no `adr-NNN-`-prefixed filename and no dedicated directory: naming follows
|
||||
[kb/CONVENTIONS.md § Naming](../CONVENTIONS.md#naming) like every other concept, and the page
|
||||
lives in `kb/concepts/` like every other concept.
|
||||
|
||||
- **Context** - what forced a decision.
|
||||
- **Decision** - what was chosen.
|
||||
- **Consequences** - what this costs, not only what it buys.
|
||||
- **Status** - proposed / accepted / deprecated / superseded.
|
||||
- Links to every entity the decision affects.
|
||||
The body is organic prose under this collection's usual sections, not a fixed template. What it
|
||||
still has to carry: what was decided, what forced the decision, what it costs (not only what it
|
||||
buys), and a link to every entity the decision affects. A `**Status:**` line is optional - most
|
||||
decision pages in this instance carry none, because the page's own prose already says whether the
|
||||
decision stands.
|
||||
|
||||
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.
|
||||
A decision superseded by a later one is never deleted or rewritten. The new page declares
|
||||
`supersedes` pointing at it; the old one needs no edge back, because its inbound view renders the
|
||||
replacement.
|
||||
|
||||
`concept_type: decision` is also the one subtype [kb/CONTRACT.md](../CONTRACT.md)'s confidence
|
||||
machinery treats differently: `confidence decay` skips it structurally, because elapsed time does
|
||||
not falsify a decision - only a later decision superseding it does.
|
||||
|
||||
## Authorised labels
|
||||
|
||||
|
||||
+12
-4
@@ -68,19 +68,23 @@ 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 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) |
|
||||
| `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). The name is about documentation parity, not about the `docs/` directory - it neither reads nor requires one, the same way `kb/` predates the collection it now checks |
|
||||
| `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/` (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 |
|
||||
| `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), `docs/` 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 |
|
||||
| `dist upgrade <source> [--dry-run] [--keep-local] [--prune] [--pre]` | Apply a stack update `dist export` produced - the write half of `version check`. Never downloads anything: `<source>` is an already-fetched export directory or `.tar.gz` release archive (verified against a sibling `.sha256` if one is present; WARNs, does not block, if it is absent), which must unpack to exactly one top-level directory - the shape `.gitea/workflows/release.yml` packs. The write set is exactly the *new* `.wikitool-release.json`'s `files` block, minus what an export re-seeds from a blank template every time (`kb/log.md`, `raw/*/.gitkeep` - `chemenu.ownership.is_export_stub`) or seeds once and the instance owns from then on (`.wikitool-kb.json`, `CHANGES.md` - `chemenu.ownership.is_upgrade_preserved`), plus the stamp itself, always rewritten. Every candidate path is classified against the *local* `.wikitool-release.json`'s recorded digest for it: unchanged is overwritten silently, absent from the old stamp is created, and locally modified or locally deleted is **never** silently overwritten - the run aborts with the full list unless `--keep-local` says to proceed and leave every one of them untouched. After a `--keep-local` run the new stamp is still written whole, so it records the release's digest for files that were deliberately *not* written: the stamp is the baseline for the next comparison, not a literal inventory of what is on disk. That is what keeps a skipped file diverging - and therefore reported - on every later run, rather than quietly reading as current once it has been skipped once. A path in the old stamp but not the new one is reported as no longer part of the release and left alone unless `--prune` is passed, which removes it only if it is still unchanged since installation. Reports the migration chain the new machinery would owe (`chemenu.kb_state.chain` over the *new* tree's `instructions/migrations/`, read via a `directory` argument to `load_migrations`) but never runs any of it - there is no `migrate run`. Refuses before touching the source at all when: `VERSION` or `.wikitool-release.json` (with a `files` block) is missing locally, `.wikitool-kb.json` is missing, a migration is already outstanding against the *installed* machinery, or the working tree is dirty (not being a git repository at all is a WARN, not a refusal). Refuses after reading the source when: it carries no `VERSION`/`.wikitool-release.json`/`files` block, its version is older than or equal to the installed one (equal is a no-op success), or it is a pre-release (`-beta.N`) without `--pre`. Reports, but does not block on, a crossed compatibility boundary. Never touches git - no commit, no push (invariant 5). See Gitea #7 and `INSTALL.md` § "Eine Instanz aktualisieren" |
|
||||
| `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 |
|
||||
| `version bump --major\|--minor\|--patch --title "<...>" [--breaking "<what breaks>"] [--no-migration "<reason>"] [--dry-run]` | Raise or continue the **one running candidate** between two releases - `VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. `--major/--minor/--patch` is **max-wins escalation** against the last release (patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and escalation never steps back down. Opens the matching `CHANGES.md` entry on the first bump of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. Refuses more or fewer than one part, an empty title, and a `VERSION`/newest-changelog-entry mismatch. Compatibility follows the **leftmost non-zero component** of the candidate's base, which for this stack (at `1.0.0` and up) 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. The bump that first escalates a candidate past the boundary requires `--breaking "<what stops working>"` and, on top of it, a migration document targeting the candidate's base or `--no-migration "<reason>"`; both lines are written once and persist over later bumps of the same candidate without being repeated, and both are refused on a bump that crosses nothing at all. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
|
||||
| `version release [--title "<...>"] [--dry-run]` | Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHANGES.md` entry, ending the pre-release phase `version bump` started. Without `--title` the heading keeps whichever bump last set it; with it, the heading's title is replaced - the normal case for a candidate that collected several bump titles, since the entry wants a summarising heading rather than the most recent one. Leaves the entry's machine-managed bump-title list untouched, as the record of what happened. Commits nothing and pushes nothing (invariant 5) - the following `publish` moves `VERSION` onto `main`, which `release.yml` reacts to. Refuses when `VERSION` is already a release (no running candidate to fix), or when the changelog's newest entry does not match `VERSION` |
|
||||
| `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 |
|
||||
| `upstream merge [--remote upstream] [--branch main] [--no-fetch]` | Take a stack update into a private instance's branch, machinery only - the code procedure behind `instructions/private-instance.md` § "Taking a stack update". Refuses on a dirty working tree, a merge already in progress, or a remote that does not resolve; WARNs (does not block) when `.wikitool-remotes.json` is absent, pointing at the setup step that arms it. Fetches `<remote>/<branch>` (unless `--no-fetch`) and reports "already up to date" if nothing new exists. Otherwise opens `git merge --no-commit --no-ff <remote>/<branch>` - and stops, untouched, if git refused to open a merge at all (unrelated histories), since without a `MERGE_HEAD` every stack path would read as "the upstream deleted it". Then forces every content stage (`kb/`, `raw/`, `work/`, `reports/`) back to the local side by removing **only the paths tracked in either tree** and checking `HEAD`'s back out - never the stage directory wholesale, because `reports/` is gitignored apart from its contract and holds local, non-recomputable data (telemetry traces `eval score` reads, saved eval and lint reports) that no merge has business deleting. Then restores from the upstream side exactly the paths `chemenu.ownership.is_stack_owned` recognises as machinery (`<stage>/CONTRACT.md`, and anything ending `.template` under a content stage) - including a deletion, if the upstream removed one. A real conflict left in `tools/`, `types/` or `instructions/` after that leaves the merge open, uncommitted, and exits 1 rather than guessing. Commits with `git commit --no-edit`, then re-checks the resulting range with the same logic as `upstream verify`; a finding there is a loud, uncommitted-nothing-rolled-back error, because the merge commit already exists and needs a human's eyes, not an automatic repair. Never pushes. Not idempotent - see the tool error contract below |
|
||||
| `upstream verify --since <rev> [--until HEAD]` | Compare two revisions: did anything under a content stage (`kb/`, `raw/`, `work/`, `reports/`) change except through a stack-owned path? Shares its check with `upstream merge`'s own postcheck, so a hand-resolved merge conflict, or a future `dist upgrade` (#7), can be verified the same way. Exit 1 with the offending paths if anything leaked; otherwise reports which stack-owned paths legitimately moved. Read-only and exempt from the Iteration Budget Gate, like `migrate verify` |
|
||||
| `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
|
||||
@@ -189,14 +193,18 @@ is atomic, and whether a retry is safe.
|
||||
| `instructions sync` / `verify` / `list` | Nothing found under `instructions/`, a malformed instruction or `SKILL.md`, a published copy that drifted from its source, an instruction nothing references (or, for `manual: true`, one that IS linked from AGENTS.md or a skill and so risks running implicitly), something under `instructions/dev/` referenced from outside it and outside a dist:strip block, or (sync) a target directory that is not a published skill and `--force` was not passed | `sync` rewrites one directory per target (idempotent); `verify`/`list` are read-only | Fix the flagged file, then re-run. For drift, re-run `sync`: the source under `instructions/` always wins, and a published copy is never edited directly |
|
||||
| `docs verify` | A command, contract, or type-form mismatch was found | Read-only | Fix the documentation it names, then re-run |
|
||||
| `dist export` | Target exists and is not empty, is not a directory, or the tree has no readable `VERSION` | Yes - nothing is written until every file is planned | Point `<target>` at an empty (or new) directory and retry. Never merge into a non-empty one by hand |
|
||||
| `dist upgrade` | Missing local `VERSION`/`.wikitool-release.json`(`files`)/`.wikitool-kb.json`, a migration already outstanding against the installed machinery, a dirty working tree, a source with no `VERSION`/stamp/`files` block, a source version that is older than, equal to, or (without `--pre`) a pre-release relative to the installed one, or one or more locally changed files without `--keep-local` | **Yes for the refusal cases above - nothing is written.** Once writing starts it is a plain sequential file copy with no partial-state cleanup: an interruption mid-copy (killed process, disk full) can leave the tree part-old, part-new | For every refusal above: fix the named precondition and retry - none of them are transient. For locally changed files: reconcile them by hand and retry, or re-run with `--keep-local` to proceed and leave them untouched (repeatable - it reports the same files again on every subsequent run until they stop diverging). An interrupted write is not resumed automatically; compare the tree against the printed classification and finish or revert by hand |
|
||||
| `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 |
|
||||
| `version bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, a missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry naming different versions, an escalation to a boundary crossing 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 escalates or continues the candidate again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying |
|
||||
| `version release` | A missing `VERSION`/`CHANGES.md`, `VERSION` already a release (no running candidate), or `VERSION` and the changelog's newest entry naming different versions | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run fails outright once the suffix is gone. If the outcome is uncertain, read `VERSION` before retrying - a release-shaped `VERSION` means it already ran |
|
||||
| `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 *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 |
|
||||
| `upstream merge` | Dirty working tree, a merge already in progress, the remote does not resolve, git refused to open the merge at all (unrelated histories), or a real conflict remains in `tools/`/`types/`/`instructions/` after the content stages and stack-owned paths were restored | **No** - can leave an open, uncommitted merge behind on refusal after fetching | **Not idempotent, and not safe to retry unchanged.** For a dirty tree or an in-progress merge: fix the named precondition and retry once. For a real conflict: **do not retry, do not force** - resolve the named paths by hand (take the upstream side, or re-file the local change as an issue against the public repo per `instructions/private-instance.md`) and either `git commit --no-edit` yourself or `git merge --abort`. If the postcheck after commit finds a leak, the merge commit already exists and is **not** rolled back automatically - inspect it by hand; this is a bug report, not a retry |
|
||||
| `upstream verify` | A leak was found (content changed under a content stage through a path that is not stack-owned), or `--since`/`--until` is not a revision in this repository | Read-only | A finding is not fixed by re-running - it names the paths that leaked. Fix the revision argument and retry for the second case |
|
||||
| `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` |
|
||||
| `eval sessions` | Never fails; an empty list is a valid answer | Read-only | - |
|
||||
|
||||
@@ -43,6 +43,7 @@ tools/
|
||||
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
|
||||
ownership.py the stack-vs-instance boundary under a content stage - one predicate, read by `dist_cmd.py` and `commands/upstream_cmd.py` so the two cannot answer it differently
|
||||
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
|
||||
|
||||
@@ -31,6 +31,7 @@ try:
|
||||
search as search_module,
|
||||
touch as touch_module,
|
||||
types_cmd,
|
||||
upstream_cmd,
|
||||
version_cmd,
|
||||
work_cmd,
|
||||
xref,
|
||||
@@ -69,6 +70,7 @@ app.add_typer(eval_cmd.app, name="eval")
|
||||
app.add_typer(dist_cmd.app, name="dist")
|
||||
app.add_typer(version_cmd.app, name="version")
|
||||
app.add_typer(migrate_cmd.app, name="migrate")
|
||||
app.add_typer(upstream_cmd.app, name="upstream")
|
||||
app.command("new")(new_page.new_page_command)
|
||||
app.command("touch")(touch_module.touch_command)
|
||||
app.command("rename")(page_ops.rename_command)
|
||||
|
||||
@@ -11,6 +11,14 @@ Keeping the undecayed anchor in `confidence_base` is what makes repeated runs
|
||||
idempotent - decaying the stored `confidence` in place (the pre-2026-08-13
|
||||
behavior) compounded on every run, because the elapsed-months factor kept
|
||||
growing while the multiplicand had already shrunk.
|
||||
|
||||
Pages with `concept_type: decision` are skipped structurally, not as an
|
||||
interim measure. The formula models staleness - a claim that nobody has
|
||||
re-checked in a while becomes less trustworthy - and a decision is not a
|
||||
claim about the world that time can falsify. What retires a decision is a
|
||||
later decision superseding it, never elapsed months on its own; that is a
|
||||
category the decay formula does not have a term for, so it does not apply
|
||||
one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -118,6 +126,8 @@ def confidence_decay(
|
||||
missing_base = []
|
||||
|
||||
for title, page in sorted(pages.items()):
|
||||
if page.frontmatter.get("concept_type") == "decision":
|
||||
continue
|
||||
confidence = page.frontmatter.get("confidence")
|
||||
if confidence is None:
|
||||
continue
|
||||
|
||||
@@ -33,14 +33,20 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, NamedTuple, Optional, Union
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import config, conventions, kb_collections, kb_state, version as version_mod
|
||||
from chemenu.commands._util import fail, rel_path, success, today_iso
|
||||
from chemenu import config, conventions, kb_collections, kb_state, ownership, version as version_mod
|
||||
from chemenu.commands._util import console, fail, rel_path, success, today_iso
|
||||
|
||||
app = typer.Typer(help="Build a distributable copy of the wiki machinery.")
|
||||
|
||||
@@ -73,6 +79,15 @@ DIST_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "dist_templates"
|
||||
# read server is part of what an instance *has*, even though its dependency is
|
||||
# optional. A distribution whose server is present but undocumented is one
|
||||
# whose operator finds the module by reading the source.
|
||||
#
|
||||
# `DEVELOPMENT.md` is deliberately **absent** from this tuple, unlike every
|
||||
# other root doc above. It documents the release workflow (`version bump` ->
|
||||
# `version release` -> `publish` -> CI tags) and points at `instructions/dev/`,
|
||||
# which this same function excludes wholesale a few lines down - a distributed
|
||||
# instance has no release workflow, no CI and no issue board, so it has
|
||||
# nothing for that document to describe. Do not "fix" this by adding it back:
|
||||
# a root file absent from ROOT_FILES is silently skipped by every export, and
|
||||
# that silence is the correct behaviour here, not a gap.
|
||||
ROOT_FILES = (
|
||||
"AGENTS.md", "CLAUDE.md", "README.md", "EVALS.md", "INSTALL.md", "INSTALL-MCP.md",
|
||||
".gitignore", "VERSION",
|
||||
@@ -123,8 +138,15 @@ INSTRUCTIONS_EXCLUDE_DIRS = {"dev"}
|
||||
RAW_SUBDIRS = ("articles", "documents", "notes", "assets")
|
||||
|
||||
# Stage contracts that are not collections and carry no pages: copied as a
|
||||
# single file each, nothing else from their directory.
|
||||
CONTRACT_ONLY_STAGES = ("raw/CONTRACT.md", "reports/CONTRACT.md", "work/CONTRACT.md")
|
||||
# single file each, nothing else from their directory. `kb/` is excluded here
|
||||
# - it is a content stage too, but it has collections underneath it, so its
|
||||
# contract is handled by `build_plan` alongside them rather than as a bare
|
||||
# stage copy. Derived from `ownership.CONTENT_STAGES` rather than listed
|
||||
# again, so the set this loop copies and the set `upstream merge` restores
|
||||
# cannot name a different stage without one of them failing its own test.
|
||||
CONTRACT_ONLY_STAGES = tuple(
|
||||
f"{stage}/CONTRACT.md" for stage in ownership.CONTENT_STAGES if stage != "kb"
|
||||
)
|
||||
|
||||
# Single tracked files copied out of an otherwise-untouched, partially-ignored
|
||||
# directory. `.claude/` holds the harness's own session-tracing config
|
||||
@@ -347,6 +369,12 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
|
||||
for hook_dir in HOOK_DIRS:
|
||||
plan.update(_copy_tree(config.ROOT / hook_dir, hook_dir, frozenset()))
|
||||
|
||||
# docs/ is stack background - why the stack is built the way it is - and
|
||||
# ships verbatim like instructions/ and types/: it carries no page, no
|
||||
# frontmatter, and (AGENTS.md § File naming) no normative sentence, so
|
||||
# there is nothing instance-owned in it to split off as a .template.
|
||||
plan.update(_copy_tree(config.ROOT / "docs", "docs", 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
|
||||
@@ -394,8 +422,14 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
|
||||
# machinery expects - which is exactly what makes the initial declaration
|
||||
# safe to write here rather than leaving it to `migrate baseline`. Only an
|
||||
# instance predating this file has to answer that question by hand.
|
||||
#
|
||||
# `.base`, not the raw `VERSION`: a content shape has no beta channel
|
||||
# (`kb_state.read_kb_version` refuses one), so exporting mid-candidate
|
||||
# still declares the release the content is shaped for, not the candidate
|
||||
# in progress. The stamp below carries the honest, suffix-inclusive value -
|
||||
# the two files answer different questions.
|
||||
plan[kb_state.KB_STATE_FILENAME] = PlannedFile(
|
||||
kb_state.render_kb_state(version_mod.read_version(), [])
|
||||
kb_state.render_kb_state(version_mod.read_version().base, [])
|
||||
)
|
||||
|
||||
# Last, so it can digest everything above it. It is the one file in the
|
||||
@@ -418,19 +452,20 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
|
||||
# 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
|
||||
# `COLLECTION.md` and `CONVENTIONS.md` are deliberately *not* allowed through
|
||||
# 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.
|
||||
#
|
||||
# What counts as machinery under kb/ or raw/ is no longer a second list here:
|
||||
# it is `ownership.is_stack_owned`, the same predicate `upstream merge` and
|
||||
# `upstream verify` restore/check against. Only the export-only stubs
|
||||
# (`ownership.EXPORT_STUB_NAMES`) are allowed here without also being
|
||||
# stack-owned - a merge keeps the *local* copy of those, while export writes a
|
||||
# fresh one regardless of either side, so the two callers genuinely disagree
|
||||
# about them and each keeps its own allowance for that one case.
|
||||
_CONTENT_PREFIXES = ("kb/", "raw/")
|
||||
_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)
|
||||
|
||||
|
||||
@@ -452,7 +487,11 @@ def find_leaks(plan: dict[str, PlannedFile]) -> list[str]:
|
||||
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:
|
||||
elif (
|
||||
relative.startswith(_CONTENT_PREFIXES)
|
||||
and not ownership.is_stack_owned(relative)
|
||||
and not ownership.is_export_stub(name)
|
||||
):
|
||||
leaks.append(f"{relative} (wiki content, not machinery)")
|
||||
return leaks
|
||||
|
||||
@@ -492,7 +531,8 @@ def export_command(
|
||||
):
|
||||
"""Export a contentless, distributable copy of this repo's machinery:
|
||||
AGENTS.md/README.md (dev-instance-only marker blocks removed),
|
||||
instructions/ (no instructions/dev/), types/, tools/ (no venv/caches),
|
||||
instructions/ (no instructions/dev/), types/, docs/ verbatim,
|
||||
tools/ (no venv/caches),
|
||||
the .github/hooks/+.vibe session-tracing config plus .claude/settings.json,
|
||||
kb/CONTRACT.md plus a COLLECTION.md.template per collection and
|
||||
kb/CONVENTIONS.md.template (no pages, no areas), empty
|
||||
@@ -547,3 +587,405 @@ def run_export(target: Path, dry_run: bool = False, origin: Optional[Origin] = N
|
||||
|
||||
_write_plan(target, plan)
|
||||
success(f"Exported {len(plan)} file(s) to {rel_path(target)}.")
|
||||
|
||||
|
||||
# --- dist upgrade ------------------------------------------------------------
|
||||
#
|
||||
# Apply a release `dist export` produced, rather than merely detecting one
|
||||
# (`version check`). Gitea #7 has the full design; the short version: the
|
||||
# write set is exactly the *new* stamp's `files` block, minus the paths an
|
||||
# export seeds once and the instance owns from then on
|
||||
# (`ownership.is_export_stub`, `ownership.is_upgrade_preserved`), plus the
|
||||
# stamp itself. Every candidate path is classified against the *old* stamp's
|
||||
# recorded digest - unchanged, locally modified, or locally deleted - and a
|
||||
# modified/deleted file is never silently overwritten. This never calls a
|
||||
# release feed; the caller supplies an already-downloaded tree or archive.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileClassification:
|
||||
"""The four-way split of every path `dist upgrade` would touch, plus the
|
||||
fifth direction (`removed`) that has no write set of its own."""
|
||||
|
||||
unchanged: list[str]
|
||||
modified: list[str]
|
||||
deleted: list[str]
|
||||
new: list[str]
|
||||
removed: list[str]
|
||||
|
||||
@property
|
||||
def blocked(self) -> list[str]:
|
||||
"""Locally changed paths - modified or deleted - which are never
|
||||
silently overwritten."""
|
||||
return sorted(self.modified + self.deleted)
|
||||
|
||||
|
||||
def _write_candidates(new_files: dict) -> set[str]:
|
||||
"""Every path `dist upgrade` may write, from the new stamp's `files`
|
||||
block: everything except the paths an export re-seeds from a blank
|
||||
template every time (`ownership.is_export_stub`) and the paths an export
|
||||
seeds once and the instance owns afterward (`ownership.is_upgrade_preserved`).
|
||||
The release stamp itself is added separately - it is never a member of its
|
||||
own `files` block, see `build_stamp`."""
|
||||
return {
|
||||
relative
|
||||
for relative in new_files
|
||||
if not ownership.is_export_stub(Path(relative).name)
|
||||
and not ownership.is_upgrade_preserved(relative)
|
||||
}
|
||||
|
||||
|
||||
def _classify_files(old_files: dict, new_files: dict) -> FileClassification:
|
||||
candidates = _write_candidates(new_files)
|
||||
recorded_for_candidates = {r: d for r, d in old_files.items() if r in candidates}
|
||||
statuses = kb_state.compare_against_stamp(recorded_for_candidates)
|
||||
|
||||
unchanged: list[str] = []
|
||||
modified: list[str] = []
|
||||
deleted: list[str] = []
|
||||
new: list[str] = []
|
||||
for relative in sorted(candidates):
|
||||
if relative not in old_files:
|
||||
new.append(relative)
|
||||
continue
|
||||
status = statuses[relative]
|
||||
if status == kb_state.UNCHANGED:
|
||||
unchanged.append(relative)
|
||||
elif status == kb_state.MODIFIED:
|
||||
modified.append(relative)
|
||||
else:
|
||||
deleted.append(relative)
|
||||
|
||||
removed = sorted(set(old_files) - set(new_files))
|
||||
return FileClassification(unchanged, modified, deleted, new, removed)
|
||||
|
||||
|
||||
def _verify_sha256_sidecar(archive: Path) -> None:
|
||||
"""WARN, never fail, on a missing sidecar - only a corrupted one that
|
||||
*is* present is a reason to stop, per Gitea #7's design table."""
|
||||
sidecar = archive.with_name(archive.name + ".sha256")
|
||||
if not sidecar.is_file():
|
||||
console.print(
|
||||
f"[yellow]WARN[/yellow] No {sidecar.name} beside {archive.name} - the archive's "
|
||||
"integrity is not being checked before it is extracted."
|
||||
)
|
||||
return
|
||||
expected = sidecar.read_text(encoding="utf-8").strip().split()[0:1]
|
||||
actual = hashlib.sha256(archive.read_bytes()).hexdigest()
|
||||
if not expected or expected[0].lower() != actual.lower():
|
||||
fail(
|
||||
f"{archive.name} does not match {sidecar.name}: expected "
|
||||
f"{expected[0] if expected else '(unreadable)'}, got {actual}. Re-download the "
|
||||
"release archive rather than trusting one that failed its own checksum."
|
||||
)
|
||||
|
||||
|
||||
def _extract_single_top_level_dir(archive: Path, dest: Path) -> Path:
|
||||
"""Extract `archive` into `dest` and return the one top-level directory it
|
||||
contained - the shape `.gitea/workflows/release.yml` packs (see its
|
||||
`Build the distribution tarball` step). Refuses anything else rather than
|
||||
guessing which part is the machinery."""
|
||||
with tarfile.open(archive) as tf:
|
||||
names = [n for n in tf.getnames() if n not in ("", ".")]
|
||||
top_levels = {n.split("/", 1)[0] for n in names}
|
||||
if len(top_levels) != 1:
|
||||
fail(
|
||||
f"{archive.name} does not have exactly one top-level directory (found "
|
||||
f"{len(top_levels)}: {', '.join(sorted(top_levels)) or '(empty archive)'}) - this "
|
||||
"is not the shape a release tarball has, and `dist upgrade` refuses to guess "
|
||||
"which part is the machinery."
|
||||
)
|
||||
return dest # unreachable: fail() raises typer.Exit
|
||||
try:
|
||||
tf.extractall(dest, filter="data") # noqa: S202 - trusted local archive, path-checked below
|
||||
except TypeError:
|
||||
# Python < 3.12 has no `filter=` argument. Same guard by hand:
|
||||
# refuse any member whose extracted path would land outside dest.
|
||||
resolved_dest = dest.resolve()
|
||||
for member in tf.getmembers():
|
||||
if not (resolved_dest / member.name).resolve().is_relative_to(resolved_dest):
|
||||
fail(
|
||||
f"{archive.name} contains a path that escapes the extraction directory: "
|
||||
f"{member.name}"
|
||||
)
|
||||
return dest # unreachable
|
||||
tf.extractall(dest) # noqa: S202 - every member path-checked above
|
||||
return dest / next(iter(top_levels))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _resolved_source(source: Path):
|
||||
"""Yield the directory holding a distribution export: `source` itself if
|
||||
it already is one, or the single top-level directory of a `.tar.gz`
|
||||
extracted into a scratch directory that is cleaned up afterward."""
|
||||
if source.is_dir():
|
||||
yield source
|
||||
return
|
||||
if not source.is_file():
|
||||
fail(f"{source} does not exist.")
|
||||
return
|
||||
_verify_sha256_sidecar(source)
|
||||
with tempfile.TemporaryDirectory(prefix="wikitool-upgrade-") as tmp:
|
||||
yield _extract_single_top_level_dir(source, Path(tmp))
|
||||
|
||||
|
||||
def _git_working_tree_status() -> Optional[str]:
|
||||
"""`git status --porcelain` for `config.ROOT`, or None if it is not a git
|
||||
repository at all - which is a valid, if unprotected, state for a tarball
|
||||
instance, not a reason to refuse."""
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(config.ROOT), "status", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout if result.returncode == 0 else None
|
||||
|
||||
|
||||
def _report_plan(
|
||||
classification: FileClassification,
|
||||
migration_chain: list["kb_state.Migration"],
|
||||
boundary_crossing: bool,
|
||||
local_version: "version_mod.Version",
|
||||
new_version: "version_mod.Version",
|
||||
) -> None:
|
||||
console.print(f"{local_version} -> {new_version}")
|
||||
if boundary_crossing:
|
||||
console.print(
|
||||
f"[bold yellow]Crosses a compatibility boundary[/bold yellow] "
|
||||
f"({local_version.compat_key} -> {new_version.compat_key}) - this is not a drop-in "
|
||||
"swap; check the release notes before proceeding."
|
||||
)
|
||||
console.print(
|
||||
f"{len(classification.unchanged)} unchanged, {len(classification.new)} new, "
|
||||
f"{len(classification.blocked)} locally changed, {len(classification.removed)} removed "
|
||||
"from the release."
|
||||
)
|
||||
if classification.modified:
|
||||
console.print(f"[bold]Locally modified ({len(classification.modified)}):[/bold]")
|
||||
for relative in classification.modified:
|
||||
console.print(f" - {relative}")
|
||||
if classification.deleted:
|
||||
console.print(f"[bold]Locally deleted ({len(classification.deleted)}):[/bold]")
|
||||
for relative in classification.deleted:
|
||||
console.print(f" - {relative}")
|
||||
if classification.removed:
|
||||
console.print("[dim]No longer part of the release, not written or removed by default:[/dim]")
|
||||
for relative in classification.removed:
|
||||
console.print(f" [dim]- {relative}[/dim]")
|
||||
if migration_chain:
|
||||
console.print(
|
||||
f"[cyan]{len(migration_chain)} migration(s) will be outstanding after this "
|
||||
"upgrade, in this order:[/cyan]"
|
||||
)
|
||||
for position, migration in enumerate(migration_chain, start=1):
|
||||
console.print(f" {position}. {migration.target} {migration.name} ({migration.kind})")
|
||||
console.print("Report only - `dist upgrade` never runs a migration. See `wikitool migrate status`.")
|
||||
|
||||
|
||||
@app.command("upgrade")
|
||||
def upgrade_command(
|
||||
source: Path = typer.Argument(
|
||||
..., help="An extracted distribution directory, or a release .tar.gz archive"
|
||||
),
|
||||
dry_run: bool = typer.Option(
|
||||
False, "--dry-run", help="Classify and report, without writing anything"
|
||||
),
|
||||
keep_local: bool = typer.Option(
|
||||
False, "--keep-local",
|
||||
help="Proceed even with locally changed files - leave each one untouched rather than aborting",
|
||||
),
|
||||
prune: bool = typer.Option(
|
||||
False, "--prune",
|
||||
help="Also delete files the new release no longer ships, if they are unchanged since install",
|
||||
),
|
||||
allow_pre: bool = typer.Option(
|
||||
False, "--pre", help="Allow a pre-release (-beta.N) source tree - release.yml never publishes one",
|
||||
),
|
||||
):
|
||||
"""Apply a stack update `dist export` produced - the write half of
|
||||
`version check`. Never downloads anything: `source` is an already-fetched
|
||||
export directory or `.tar.gz` archive. Writes exactly the new release
|
||||
stamp's `files` block, minus what an export re-seeds every time
|
||||
(`kb/log.md`, `raw/*/.gitkeep`) or seeds once and the instance owns from
|
||||
then on (`.wikitool-kb.json`, `CHANGES.md`), classifying every candidate
|
||||
against the *old* stamp's recorded digest: unchanged files are
|
||||
overwritten silently, new files are created, and a locally modified or
|
||||
deleted file is never silently overwritten - `dist upgrade` aborts unless
|
||||
`--keep-local` says to leave it alone. Reports the migration chain the new
|
||||
machinery would owe without running any of it (there is no `migrate run`).
|
||||
Refuses on a missing local release stamp, a downgrade, a pre-release
|
||||
source without `--pre`, or a dirty working tree. Never touches git.
|
||||
See Gitea #7 and `INSTALL.md` § "Eine Instanz aktualisieren"."""
|
||||
run_upgrade(
|
||||
source, dry_run=dry_run, keep_local=keep_local, prune=prune, allow_pre=allow_pre
|
||||
)
|
||||
|
||||
|
||||
def run_upgrade(
|
||||
source: Path,
|
||||
dry_run: bool = False,
|
||||
keep_local: bool = False,
|
||||
prune: bool = False,
|
||||
allow_pre: bool = False,
|
||||
) -> None:
|
||||
"""The upgrade itself, free of Typer's option objects - see `run_export`
|
||||
for why this split exists."""
|
||||
try:
|
||||
local_version = version_mod.read_version()
|
||||
except version_mod.VersionError as exc:
|
||||
fail(f"{exc} - this tree has no stack version to upgrade from.")
|
||||
return
|
||||
|
||||
old_stamp = version_mod.read_stamp()
|
||||
if not old_stamp or not isinstance(old_stamp.get("files"), dict):
|
||||
fail(
|
||||
f"No local {version_mod.RELEASE_STAMP_FILENAME} (or it carries no `files` block). "
|
||||
"Without it, `dist upgrade` cannot tell a file this instance edited from one it "
|
||||
"merely received, and it refuses to guess. A checkout with shared git history takes "
|
||||
"stack updates via `wikitool upstream merge` instead - it has the same information "
|
||||
"as a merge base. A tarball instance that has lost its stamp has no repair path "
|
||||
"today; see Gitea #7 \"Bewusst offen gelassen\"."
|
||||
)
|
||||
return
|
||||
old_files = old_stamp["files"]
|
||||
|
||||
try:
|
||||
kb_version = kb_state.read_kb_version()
|
||||
except version_mod.VersionError as exc:
|
||||
fail(str(exc))
|
||||
return
|
||||
if kb_version is None:
|
||||
fail(
|
||||
f"{kb_state.KB_STATE_FILENAME} is missing - this instance has never declared what "
|
||||
"shape its content is in. Run `wikitool migrate baseline <version>` before upgrading."
|
||||
)
|
||||
return
|
||||
|
||||
outstanding = kb_state.chain(kb_state.load_migrations(), kb_version, local_version.base)
|
||||
if outstanding:
|
||||
fail(
|
||||
f"{len(outstanding)} migration(s) are already outstanding against the installed "
|
||||
f"machinery ({kb_version} -> {local_version}) - `wikitool migrate status` names them. "
|
||||
"Finish them before upgrading further: a machinery swap on top of an unfinished "
|
||||
"migration leaves the corpus in a shape no version describes."
|
||||
)
|
||||
return
|
||||
|
||||
tree_status = _git_working_tree_status()
|
||||
if tree_status is None:
|
||||
console.print(
|
||||
"[yellow]WARN[/yellow] Not a git repository (or git is unavailable) - proceeding "
|
||||
"without the dirty-tree check a repository would get."
|
||||
)
|
||||
elif tree_status.strip():
|
||||
fail(
|
||||
"Working tree is not clean (`git status --porcelain` printed something). "
|
||||
"`dist upgrade` refuses to start on a dirty tree so a refusal never has to guess "
|
||||
"which changes were already there. Commit or stash first."
|
||||
)
|
||||
return
|
||||
|
||||
with _resolved_source(source) as new_root:
|
||||
version_path = new_root / version_mod.VERSION_FILENAME
|
||||
if not version_path.is_file():
|
||||
fail(f"{rel_path(new_root)} has no VERSION - not a distribution export.")
|
||||
return
|
||||
try:
|
||||
new_version = version_mod.Version.parse(version_path.read_text(encoding="utf-8"))
|
||||
except version_mod.VersionError as exc:
|
||||
fail(str(exc))
|
||||
return
|
||||
|
||||
if new_version.is_prerelease and not allow_pre:
|
||||
fail(
|
||||
f"{new_version} is a running candidate (-beta.N). `.gitea/workflows/release.yml` "
|
||||
"never publishes one, so a candidate tree can only come from a dev checkout by "
|
||||
"hand - pass --pre if that is deliberate."
|
||||
)
|
||||
return
|
||||
if new_version < local_version:
|
||||
fail(f"{new_version} is older than the installed {local_version} - refusing a downgrade.")
|
||||
return
|
||||
if new_version == local_version:
|
||||
success(f"Already at {local_version}. Nothing to do.")
|
||||
return
|
||||
|
||||
stamp_path = new_root / version_mod.RELEASE_STAMP_FILENAME
|
||||
if not stamp_path.is_file():
|
||||
fail(f"{rel_path(new_root)} has no {version_mod.RELEASE_STAMP_FILENAME} - not a distribution export.")
|
||||
return
|
||||
try:
|
||||
new_stamp = json.loads(stamp_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
fail(f"{version_mod.RELEASE_STAMP_FILENAME} in the source is not readable JSON: {exc}")
|
||||
return
|
||||
new_files = new_stamp.get("files") if isinstance(new_stamp, dict) else None
|
||||
if not isinstance(new_files, dict):
|
||||
fail(f"{version_mod.RELEASE_STAMP_FILENAME} in the source carries no `files` block.")
|
||||
return
|
||||
|
||||
classification = _classify_files(old_files, new_files)
|
||||
migration_chain = kb_state.chain(
|
||||
kb_state.load_migrations(new_root / "instructions" / kb_state.MIGRATIONS_SUBDIR),
|
||||
kb_version,
|
||||
new_version.base,
|
||||
)
|
||||
boundary_crossing = local_version.compat_key != new_version.compat_key
|
||||
|
||||
_report_plan(classification, migration_chain, boundary_crossing, local_version, new_version)
|
||||
|
||||
# Dry-run's whole purpose is to preview this classification - including
|
||||
# the blocked list - without raising, so it must be checked before the
|
||||
# abort below rather than after: a blocked file must never turn
|
||||
# `--dry-run` into a non-zero exit, or the flag stops being safe to run
|
||||
# freely.
|
||||
if dry_run:
|
||||
success(f"Dry run: would upgrade {local_version} -> {new_version}. Nothing written.")
|
||||
return
|
||||
|
||||
if classification.blocked and not keep_local:
|
||||
fail(
|
||||
f"{len(classification.blocked)} locally changed file(s) (listed above) would be "
|
||||
"silently overwritten. Pass --keep-local to upgrade anyway and leave every one of "
|
||||
"them untouched, or reconcile them by hand first. Nothing was written."
|
||||
)
|
||||
return
|
||||
|
||||
to_write = sorted(classification.unchanged + classification.new)
|
||||
for relative in to_write:
|
||||
src = new_root / relative
|
||||
dst = config.ROOT / relative
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
shutil.copy2(stamp_path, config.ROOT / version_mod.RELEASE_STAMP_FILENAME)
|
||||
|
||||
pruned: list[str] = []
|
||||
if prune:
|
||||
for relative in classification.removed:
|
||||
digest = old_files.get(relative)
|
||||
if digest is None:
|
||||
continue
|
||||
status = kb_state.compare_against_stamp({relative: digest}).get(relative)
|
||||
if status != kb_state.UNCHANGED:
|
||||
continue
|
||||
target = config.ROOT / relative
|
||||
if target.is_file():
|
||||
target.unlink()
|
||||
pruned.append(relative)
|
||||
|
||||
skipped = classification.blocked if keep_local else []
|
||||
summary = (
|
||||
f"Upgraded {local_version} -> {new_version}: {len(to_write)} file(s) written"
|
||||
+ (f", {len(skipped)} left untouched (--keep-local)" if skipped else "")
|
||||
+ (f", {len(pruned)} pruned" if pruned else "")
|
||||
+ "."
|
||||
)
|
||||
if migration_chain:
|
||||
summary += (
|
||||
f" {len(migration_chain)} migration(s) now outstanding - run `wikitool migrate status`."
|
||||
)
|
||||
summary += (
|
||||
" Nothing was committed. Now run, in order: `wikitool instructions sync`, `doctor`, "
|
||||
"`docs verify`, `instructions verify`, `lint` - then restart the agent session."
|
||||
)
|
||||
success(summary)
|
||||
|
||||
@@ -453,10 +453,12 @@ def check_version_changelog() -> list[str]:
|
||||
|
||||
This is the check that makes `version bump` more than a convenience: a
|
||||
version raised with nothing written about it would ship a release whose
|
||||
notes describe the previous one. A changelog with *no* versioned entry at
|
||||
all is fine - that is a fresh distribution, and this repo's own pre-
|
||||
versioning history, neither of which claims to describe the current
|
||||
version.
|
||||
notes describe the previous one. `VERSION` may name a running candidate
|
||||
(`-beta.N`) rather than a release - `Version.parse`/equality read the
|
||||
suffix like any other component, so a candidate is compared exactly like a
|
||||
release here. A changelog with *no* versioned entry at all is fine - that
|
||||
is a fresh distribution, and this repo's own pre-versioning history,
|
||||
neither of which claims to describe the current version.
|
||||
"""
|
||||
version_path = config.ROOT / version_mod.VERSION_FILENAME
|
||||
if not version_path.is_file():
|
||||
@@ -483,15 +485,6 @@ def check_version_changelog() -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def _second_changes_version(text: str) -> Optional["version_mod.Version"]:
|
||||
"""The version named by the second-newest versioned entry, or None."""
|
||||
seen = [
|
||||
version_mod.Version.parse(match.group(1))
|
||||
for match in version_mod._CHANGES_ENTRY_RE.finditer(text)
|
||||
]
|
||||
return seen[1] if len(seen) > 1 else None
|
||||
|
||||
|
||||
def check_migration_for_boundary() -> list[str]:
|
||||
"""A version that crosses the compatibility boundary must say how to cross it.
|
||||
|
||||
@@ -501,9 +494,12 @@ def check_migration_for_boundary() -> list[str]:
|
||||
document targeting it, or an explicit statement in its changelog entry that
|
||||
no content has to change.
|
||||
|
||||
Only the newest entry is checked. Older boundaries were either satisfied
|
||||
when they were written or cannot be fixed retroactively, and re-reporting
|
||||
them forever would make the check noise.
|
||||
Only the newest entry is checked, against the **last release** rather than
|
||||
the entry beneath it - between two candidates of the same running upgrade
|
||||
(`4.4.0-beta.2` above `4.4.0-beta.1`) there is no boundary at all, and
|
||||
comparing to the entry beneath would find none even when the candidate
|
||||
genuinely crosses one relative to what is actually installed anywhere. See
|
||||
instructions/dev/version-parts.md.
|
||||
"""
|
||||
from chemenu import kb_state
|
||||
|
||||
@@ -514,15 +510,15 @@ def check_migration_for_boundary() -> list[str]:
|
||||
|
||||
text = changes_path.read_text(encoding="utf-8")
|
||||
current = version_mod.top_changes_version(text)
|
||||
previous = _second_changes_version(text)
|
||||
previous = version_mod.last_release(text)
|
||||
if current is None or previous is None:
|
||||
return [] # the first versioned entry has no predecessor to cross from
|
||||
return [] # no release recorded yet to cross from (fresh distribution)
|
||||
if current.compat_key == previous.compat_key:
|
||||
return []
|
||||
|
||||
if version_mod.MIGRATION_NONE_MARKER in (version_mod.changes_section(text, current) or ""):
|
||||
return []
|
||||
if any(m.target == current for m in kb_state.load_migrations()):
|
||||
if any(m.target == current.base for m in kb_state.load_migrations()):
|
||||
return []
|
||||
|
||||
return [
|
||||
@@ -544,8 +540,9 @@ def check_breaking_change_for_boundary() -> list[str]:
|
||||
import name or flag - satisfies that check and still leaves every existing
|
||||
instance with something to do by hand.
|
||||
|
||||
Only the newest entry is checked, for the same reason: older crossings are
|
||||
history, and re-reporting them forever would make the check noise.
|
||||
Only the newest entry is checked, against the **last release** - see
|
||||
`check_migration_for_boundary` for why the entry beneath it is the wrong
|
||||
comparison once a candidate can span more than one bump.
|
||||
"""
|
||||
changes_path = config.ROOT / version_mod.CHANGES_FILENAME
|
||||
version_path = config.ROOT / version_mod.VERSION_FILENAME
|
||||
@@ -554,9 +551,9 @@ def check_breaking_change_for_boundary() -> list[str]:
|
||||
|
||||
text = changes_path.read_text(encoding="utf-8")
|
||||
current = version_mod.top_changes_version(text)
|
||||
previous = _second_changes_version(text)
|
||||
previous = version_mod.last_release(text)
|
||||
if current is None or previous is None:
|
||||
return [] # the first versioned entry has no predecessor to cross from
|
||||
return [] # no release recorded yet to cross from (fresh distribution)
|
||||
if current.compat_key == previous.compat_key:
|
||||
return []
|
||||
|
||||
|
||||
@@ -383,7 +383,8 @@ def check_stack_version() -> Check:
|
||||
)
|
||||
|
||||
origin = "development tree" if stamp is None else f"distribution, exported {stamp.get('exported_at', 'unknown')}"
|
||||
return Check("stack-version", "OK", f"{current} ({origin})")
|
||||
candidate = " - a running pre-release candidate, not yet fixed by `version release`" if current.is_prerelease else ""
|
||||
return Check("stack-version", "OK", f"{current} ({origin}){candidate}")
|
||||
|
||||
|
||||
def check_kb_version() -> Check:
|
||||
@@ -420,7 +421,7 @@ def check_kb_version() -> Check:
|
||||
"never lagged behind its machinery",
|
||||
)
|
||||
if kb_version < stack:
|
||||
pending = kb_state.chain(kb_state.load_migrations(), kb_version, stack)
|
||||
pending = kb_state.chain(kb_state.load_migrations(), kb_version, stack.base)
|
||||
if pending:
|
||||
return Check(
|
||||
"kb-version", "WARN",
|
||||
|
||||
@@ -160,7 +160,7 @@ def status_command(
|
||||
)
|
||||
return
|
||||
|
||||
pending = kb_state.chain(migrations, kb_version, stack)
|
||||
pending = kb_state.chain(migrations, kb_version, stack.base)
|
||||
offered = kb_state.offers(migrations, kb_state.applied_names(kb_state.read_kb_state()))
|
||||
divergent = kb_state.divergent_files()
|
||||
|
||||
@@ -271,7 +271,7 @@ def done_command(
|
||||
)
|
||||
return
|
||||
|
||||
expected = kb_state.next_link(migrations, kb_version, stack)
|
||||
expected = kb_state.next_link(migrations, kb_version, stack.base)
|
||||
if expected is None:
|
||||
fail(
|
||||
f"Nothing is outstanding: content is at {kb_version}, machinery at {stack}, and no "
|
||||
@@ -297,7 +297,7 @@ def done_command(
|
||||
return
|
||||
|
||||
kb_state.write_kb_state(target, applied)
|
||||
remaining = kb_state.chain(migrations, target, stack)
|
||||
remaining = kb_state.chain(migrations, target, stack.base)
|
||||
success(
|
||||
f"Content is now {target} ({expected.name}). "
|
||||
+ (
|
||||
|
||||
@@ -101,6 +101,13 @@ SKIP_COMMAND_PATHS = {
|
||||
("migrate", "list"),
|
||||
("migrate", "status"),
|
||||
("migrate", "verify"),
|
||||
# `upstream verify` only reads two git revisions and reports what changed -
|
||||
# the same argument as `migrate verify`: a check that costs budget is one
|
||||
# an agent starts skipping. `upstream merge` stays counted: it mutates the
|
||||
# branch and can leave an open merge behind on refusal, so it belongs on
|
||||
# the non-idempotent list (AGENTS.md's tool error contract) rather than
|
||||
# the exempt one.
|
||||
("upstream", "verify"),
|
||||
}
|
||||
|
||||
# Commands exempt regardless of their first argument, because that argument is
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""`wikitool upstream` - take a stack update from a public upstream into a
|
||||
private instance's `main` without letting the upstream's own content (a demo
|
||||
corpus, a workshop run) ride along.
|
||||
|
||||
`git merge upstream/main` on its own treats a moved corpus dangerously
|
||||
asymmetrically: a page the instance deleted and the upstream edited reports as
|
||||
a conflict, a page the upstream *added* stages silently, and a page both sides
|
||||
deleted is the only harmless case. `instructions/private-instance.md`'s prose
|
||||
procedure closes that, by holding the merge open, forcing the content stages
|
||||
(`ownership.CONTENT_STAGES`) back to the local side, and then restoring only
|
||||
the paths `ownership.is_stack_owned` recognises as machinery. `upstream merge`
|
||||
is that procedure in code, so the path set it acts on cannot drift from the
|
||||
one `dist_cmd.py` ships - both read `chemenu.ownership` - and so a conflict in
|
||||
the machinery layers, or a machinery file the upstream deleted, gets an
|
||||
explained stop instead of a silently wrong commit.
|
||||
|
||||
`upstream verify` is the other half: given two revisions, did anything change
|
||||
under a content stage except through a stack-owned path? It shares
|
||||
`_content_leaks` with the postcheck `upstream merge` runs on itself, so a
|
||||
hand-resolved merge or a future `dist upgrade` (Gitea #7) can be checked the
|
||||
same way.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import config, ownership
|
||||
from chemenu.commands import git_publish
|
||||
from chemenu.commands._util import console, fail, success
|
||||
|
||||
app = typer.Typer(help="Take a stack update from a public upstream, machinery only.")
|
||||
|
||||
|
||||
def _run(args: list[str]):
|
||||
import subprocess
|
||||
|
||||
return subprocess.run(args, cwd=config.ROOT, capture_output=True, text=True)
|
||||
|
||||
|
||||
def _rev_parse(rev: str) -> Optional[str]:
|
||||
result = _run(["git", "rev-parse", "--verify", "-q", rev])
|
||||
return result.stdout.strip() if result.returncode == 0 else None
|
||||
|
||||
|
||||
def _git_dir() -> Optional[Path]:
|
||||
result = _run(["git", "rev-parse", "--git-dir"])
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
path = Path(result.stdout.strip())
|
||||
return path if path.is_absolute() else config.ROOT / path
|
||||
|
||||
|
||||
def _working_tree_dirty() -> bool:
|
||||
result = _run(["git", "status", "--porcelain"])
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
def _merge_in_progress() -> bool:
|
||||
git_dir = _git_dir()
|
||||
return git_dir is not None and (git_dir / "MERGE_HEAD").exists()
|
||||
|
||||
|
||||
def _remote_resolves(remote: str) -> bool:
|
||||
return _run(["git", "remote", "get-url", remote]).returncode == 0
|
||||
|
||||
|
||||
def _is_ancestor(ancestor: str, of: str) -> bool:
|
||||
return _run(["git", "merge-base", "--is-ancestor", ancestor, of]).returncode == 0
|
||||
|
||||
|
||||
def _tree_has_path(rev: str, path: str) -> bool:
|
||||
return _run(["git", "rev-parse", "--verify", "-q", f"{rev}:{path}"]).returncode == 0
|
||||
|
||||
|
||||
def _tree_paths(rev: str) -> set[str]:
|
||||
result = _run(["git", "ls-tree", "-r", "--name-only", "-z", rev])
|
||||
if result.returncode != 0:
|
||||
return set()
|
||||
return {p for p in result.stdout.split("\0") if p}
|
||||
|
||||
|
||||
def _content_leaks(since: str, until: str) -> list[str]:
|
||||
"""Paths under a content stage that changed between `since` and `until`
|
||||
through something other than a stack-owned path. Shared by `upstream
|
||||
merge`'s own postcheck and `upstream verify`, so the two cannot disagree
|
||||
about what a clean update looks like."""
|
||||
result = _run(["git", "diff", "--name-only", "-z", since, until, "--", *ownership.CONTENT_STAGES])
|
||||
if result.returncode != 0:
|
||||
fail(
|
||||
f"`git diff {since} {until}` failed - is {since} a revision in this repository?\n"
|
||||
f"{result.stderr}"
|
||||
)
|
||||
return []
|
||||
changed = [p for p in result.stdout.split("\0") if p]
|
||||
return sorted(p for p in changed if not ownership.is_stack_owned(p))
|
||||
|
||||
|
||||
def _stack_paths_changed(since: str, until: str) -> list[str]:
|
||||
"""The subset of the same diff that *is* a stack-owned path - the paths
|
||||
that legitimately moved, for the success message."""
|
||||
result = _run(["git", "diff", "--name-only", "-z", since, until, "--", *ownership.CONTENT_STAGES])
|
||||
changed = [p for p in result.stdout.split("\0") if p]
|
||||
return sorted(p for p in changed if ownership.is_stack_owned(p))
|
||||
|
||||
|
||||
# --- upstream merge ---------------------------------------------------------
|
||||
|
||||
|
||||
def _prune_empty_dirs(stage: str) -> None:
|
||||
"""Remove directories left empty under `stage` after tracked files were
|
||||
deleted. git tracks no directories, so an emptied one is invisible to
|
||||
`git status` and would otherwise linger in the working tree as litter -
|
||||
an empty `kb/<area>/` that only ever existed in the upstream's corpus.
|
||||
Never touches a directory that still holds anything, ignored files
|
||||
included."""
|
||||
stage_dir = config.ROOT / stage
|
||||
if not stage_dir.is_dir():
|
||||
return
|
||||
for path in sorted(stage_dir.rglob("*"), key=lambda p: len(p.parts), reverse=True):
|
||||
if path.is_dir() and not any(path.iterdir()):
|
||||
path.rmdir()
|
||||
|
||||
|
||||
def _restore_stage_to_local(stage: str, tracked_paths: set[str]) -> None:
|
||||
"""Force one content stage back to the local (HEAD) side, whatever the
|
||||
merge did to it.
|
||||
|
||||
Deletes **only what git tracks on either side** - never the stage
|
||||
directory wholesale. That distinction is the whole point of this function:
|
||||
`reports/` is gitignored except its contract (see .gitignore), so a
|
||||
content stage's working tree legitimately holds local data that is not in
|
||||
any tree and not recomputable - the telemetry traces `eval score` reads,
|
||||
saved eval reports, past lint reports. A blanket `rm -rf` of the stage
|
||||
takes all of it out as collateral for a merge that was never about it.
|
||||
|
||||
Handles a stage that exists only in MERGE_HEAD too (the upstream
|
||||
introduced it): what the merge wrote is removed, and there is simply
|
||||
nothing to check out from HEAD afterwards.
|
||||
"""
|
||||
prefix = f"{stage}/"
|
||||
stage_paths = [p for p in tracked_paths if p.startswith(prefix)]
|
||||
if not stage_paths:
|
||||
return
|
||||
|
||||
_run(["git", "rm", "-rq", "--cached", "--ignore-unmatch", stage])
|
||||
for relative in stage_paths:
|
||||
target = config.ROOT / relative
|
||||
if target.is_file() or target.is_symlink():
|
||||
target.unlink()
|
||||
_prune_empty_dirs(stage)
|
||||
if _tree_has_path("HEAD", stage):
|
||||
_run(["git", "checkout", "HEAD", "--", stage])
|
||||
|
||||
|
||||
def _remote_gate_warning() -> None:
|
||||
if git_publish.read_allowed_push_urls() is not None:
|
||||
return
|
||||
console.print(
|
||||
"[bold yellow]WARN[/bold yellow] No .wikitool-remotes.json in this checkout - the "
|
||||
"Publish-Remote Gate is unarmed, so a future `publish` to the wrong remote would not "
|
||||
"be caught. `upstream merge` never pushes and proceeds regardless, but a checkout that "
|
||||
"takes stack updates from a public upstream should arm the gate before its next publish "
|
||||
"- see instructions/private-instance.md step 4."
|
||||
)
|
||||
|
||||
|
||||
def _precondition_failure(remote: str) -> Optional[str]:
|
||||
if _working_tree_dirty():
|
||||
return (
|
||||
"Working tree is not clean (`git status --porcelain` printed something). "
|
||||
"`upstream merge` refuses to start on a dirty tree so a refusal never has to "
|
||||
"guess which changes were already there. Commit or stash first."
|
||||
)
|
||||
if _merge_in_progress():
|
||||
return (
|
||||
"A merge is already in progress (.git/MERGE_HEAD exists). Resolve or abort it "
|
||||
"(`git merge --abort`) before running `upstream merge`."
|
||||
)
|
||||
if not _remote_resolves(remote):
|
||||
return f"Remote '{remote}' does not resolve (`git remote get-url {remote}` failed)."
|
||||
return None
|
||||
|
||||
|
||||
def _unresolved_conflict_message(unresolved: list[str], remote: str, branch: str) -> str:
|
||||
listed = "\n".join(f" - {p}" for p in unresolved)
|
||||
return (
|
||||
f"A real conflict remains in the machinery layers after restoring the content stages "
|
||||
f"and the stack-owned paths from {remote}/{branch}:\n{listed}\n\n"
|
||||
"The merge is left open, uncommitted - nothing was written to the branch. Per "
|
||||
"instructions/private-instance.md's decision points: this means the checkout changed "
|
||||
"the stack locally, which private instances do not do. Take the upstream side for "
|
||||
"these paths (`git checkout --theirs -- <path>` then `git add`) and re-file the local "
|
||||
"change as an issue against the public repo, or resolve deliberately and "
|
||||
"`git commit --no-edit` yourself. `git merge --abort` gives up the merge entirely."
|
||||
)
|
||||
|
||||
|
||||
def _postcheck_failure_message(leaks: list[str], before: str) -> str:
|
||||
listed = "\n".join(f" - {p}" for p in leaks)
|
||||
return (
|
||||
f"The merge commit exists (content stages are not what they were before this ran), "
|
||||
f"but it changed content outside of a stack-owned path:\n{listed}\n\n"
|
||||
f"This was NOT rolled back - the state belongs in front of you, not behind an automatic "
|
||||
f"repair the command applies to itself. Compare against the pre-merge commit ({before}) "
|
||||
"and decide by hand whether to revert the merge commit, cherry-pick around it, or fix "
|
||||
"forward. This is a bug in `upstream merge` or in `ownership.is_stack_owned` if it "
|
||||
"reproduces - please report it rather than working around it silently."
|
||||
)
|
||||
|
||||
|
||||
def _merge_success_message(
|
||||
changed: list[str], deleted: list[str], remote: str, branch: str
|
||||
) -> str:
|
||||
"""What the merge actually did, measured against the pre-merge commit
|
||||
rather than against what was restored.
|
||||
|
||||
`changed` is the real diff - restoring every stack-owned path from
|
||||
MERGE_HEAD touches each of them whether or not the upstream moved any, so
|
||||
reporting the restore list would claim seven updates for a merge that
|
||||
changed one file, and a reader who checks would find the report wrong.
|
||||
"""
|
||||
deleted_set = set(deleted)
|
||||
lines = [
|
||||
f"Merged {remote}/{branch}. Content stages "
|
||||
f"({', '.join(ownership.CONTENT_STAGES)}) are unchanged."
|
||||
]
|
||||
if changed:
|
||||
lines.append(f"Stack paths changed ({len(changed)}):")
|
||||
lines += [
|
||||
f" - {p}" + (" (deleted, following the upstream)" if p in deleted_set else "")
|
||||
for p in changed
|
||||
]
|
||||
else:
|
||||
lines.append("No stack-owned path changed.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@app.command("merge")
|
||||
def merge_command(
|
||||
remote: str = typer.Option("upstream", "--remote", help="Remote to merge from"),
|
||||
branch: str = typer.Option("main", "--branch", help="Branch to merge"),
|
||||
no_fetch: bool = typer.Option(
|
||||
False, "--no-fetch", help="Skip `git fetch <remote>` - use whatever is already fetched"
|
||||
),
|
||||
):
|
||||
"""Merge `<remote>/<branch>` into the current branch, machinery only:
|
||||
every path under a content stage (kb/, raw/, work/, reports/) is forced
|
||||
back to the local side except a stack-owned path (`<stage>/CONTRACT.md`,
|
||||
or anything ending `.template` under a content stage), which is taken
|
||||
from the upstream - including a deletion, if the upstream removed one. A
|
||||
real conflict elsewhere (tools/, types/, instructions/) leaves the merge
|
||||
open and unresolved rather than guessing. Not idempotent: it can leave an
|
||||
open merge behind on refusal. See instructions/private-instance.md."""
|
||||
problem = _precondition_failure(remote)
|
||||
if problem:
|
||||
fail(problem)
|
||||
return
|
||||
|
||||
_remote_gate_warning()
|
||||
|
||||
before = _rev_parse("HEAD")
|
||||
if before is None:
|
||||
fail("HEAD does not resolve - is this a git repository with at least one commit?")
|
||||
return
|
||||
|
||||
if not no_fetch:
|
||||
fetch_result = _run(["git", "fetch", remote, branch])
|
||||
if fetch_result.returncode != 0:
|
||||
fail(f"`git fetch {remote} {branch}` failed:\n{fetch_result.stderr}")
|
||||
return
|
||||
|
||||
remote_ref = f"{remote}/{branch}"
|
||||
if _rev_parse(remote_ref) is None:
|
||||
fail(f"'{remote_ref}' does not resolve - fetch it first, or check --remote/--branch.")
|
||||
return
|
||||
|
||||
if _is_ancestor(remote_ref, "HEAD"):
|
||||
success(f"Already up to date with {remote_ref}.")
|
||||
return
|
||||
|
||||
# The exit code is deliberately not the test - conflicts under the content
|
||||
# stages are expected here and are exactly what the next steps undo. What
|
||||
# *is* load-bearing is that a merge actually opened: without MERGE_HEAD,
|
||||
# `_tree_paths("MERGE_HEAD")` is empty, and every stack-owned path in HEAD
|
||||
# would then read as "the upstream deleted it" and be removed. A merge git
|
||||
# refused to start (unrelated histories, an ignored file in the way) must
|
||||
# therefore stop here, with the tree untouched.
|
||||
merge_result = _run(["git", "merge", "--no-commit", "--no-ff", remote_ref])
|
||||
if not _merge_in_progress():
|
||||
fail(
|
||||
f"`git merge --no-commit --no-ff {remote_ref}` did not open a merge, so there is "
|
||||
f"nothing to scope - the working tree is unchanged:\n"
|
||||
f"{merge_result.stdout}{merge_result.stderr}"
|
||||
)
|
||||
return
|
||||
|
||||
merge_head_paths = _tree_paths("MERGE_HEAD")
|
||||
head_paths = _tree_paths("HEAD")
|
||||
tracked_paths = merge_head_paths | head_paths
|
||||
|
||||
for stage in ownership.CONTENT_STAGES:
|
||||
_restore_stage_to_local(stage, tracked_paths)
|
||||
|
||||
stack_paths = sorted(
|
||||
p for p in (merge_head_paths | head_paths) if ownership.is_stack_owned(p)
|
||||
)
|
||||
|
||||
# Only the deletions are recorded: what was *restored* is every stack-owned
|
||||
# path in MERGE_HEAD, which is not the same question as what changed - the
|
||||
# success message asks git for that instead.
|
||||
deleted: list[str] = []
|
||||
for relative in stack_paths:
|
||||
if relative in merge_head_paths:
|
||||
checkout = _run(["git", "checkout", "MERGE_HEAD", "--", relative])
|
||||
if checkout.returncode != 0:
|
||||
fail(
|
||||
f"`git checkout MERGE_HEAD -- {relative}` failed even though it is listed "
|
||||
f"in MERGE_HEAD's own tree:\n{checkout.stderr}\nThe merge is left open."
|
||||
)
|
||||
return
|
||||
else:
|
||||
_run(["git", "rm", "-q", "--cached", "--ignore-unmatch", relative])
|
||||
target = config.ROOT / relative
|
||||
if target.exists():
|
||||
target.unlink()
|
||||
deleted.append(relative)
|
||||
|
||||
unresolved = [p for p in _run(["git", "diff", "--name-only", "--diff-filter=U"]).stdout.splitlines() if p]
|
||||
if unresolved:
|
||||
fail(_unresolved_conflict_message(unresolved, remote, branch))
|
||||
return
|
||||
|
||||
commit_result = _run(["git", "commit", "--no-edit"])
|
||||
if commit_result.returncode != 0:
|
||||
fail(f"`git commit --no-edit` failed:\n{commit_result.stderr}")
|
||||
return
|
||||
|
||||
leaks = _content_leaks(before, "HEAD")
|
||||
if leaks:
|
||||
fail(_postcheck_failure_message(leaks, before))
|
||||
return
|
||||
|
||||
success(
|
||||
_merge_success_message(_stack_paths_changed(before, "HEAD"), deleted, remote, branch)
|
||||
)
|
||||
|
||||
|
||||
# --- upstream verify ---------------------------------------------------------
|
||||
|
||||
|
||||
def _verify_failure_message(leaks: list[str], since: str, until: str) -> str:
|
||||
listed = "\n".join(f" - {p}" for p in leaks)
|
||||
return (
|
||||
f"Content under a content stage (kb/, raw/, work/, reports/) changed between {since} "
|
||||
f"and {until} through a path that is not stack-owned:\n{listed}\n\n"
|
||||
"That is upstream content (or an equivalent local change) that reached this range "
|
||||
"outside of a stack-owned path - inspect it before trusting this range as machinery-only."
|
||||
)
|
||||
|
||||
|
||||
def _verify_success_message(stack_moved: list[str], since: str, until: str) -> str:
|
||||
if not stack_moved:
|
||||
return f"No content changed between {since} and {until} under kb/, raw/, work/, reports/."
|
||||
listed = "\n".join(f" - {p}" for p in stack_moved)
|
||||
return (
|
||||
f"Clean: only stack-owned paths changed under kb/, raw/, work/, reports/ between "
|
||||
f"{since} and {until}:\n{listed}"
|
||||
)
|
||||
|
||||
|
||||
@app.command("verify")
|
||||
def verify_command(
|
||||
since: str = typer.Option(..., "--since", help="Git revision to compare from"),
|
||||
until: str = typer.Option("HEAD", "--until", help="Git revision to compare to"),
|
||||
):
|
||||
"""Check that nothing under a content stage changed between --since and
|
||||
--until except through a stack-owned path. Read-only, and exempt from the
|
||||
Iteration Budget Gate - the same treatment `migrate verify` gets, for the
|
||||
same reason: a check an agent has to ration is a check that gets skipped."""
|
||||
leaks = _content_leaks(since, until)
|
||||
if leaks:
|
||||
fail(_verify_failure_message(leaks, since, until))
|
||||
return
|
||||
success(_verify_success_message(_stack_paths_changed(since, until), since, until))
|
||||
@@ -1,13 +1,17 @@
|
||||
"""`wikitool version` - report, bump, and check the stack's version.
|
||||
"""`wikitool version` - report, bump, release, and check the stack's version.
|
||||
|
||||
Three jobs that all hang off one number (see `chemenu/version.py` for what
|
||||
that number means):
|
||||
Four jobs that all hang off one number (see `chemenu/version.py` for what that
|
||||
number means, and `instructions/dev/version-parts.md` for the candidate model):
|
||||
|
||||
- `version show` answers "which stack is this instance running", offline, from
|
||||
`VERSION` plus the release stamp `dist export` writes.
|
||||
- `version bump` moves it, and writes the changelog *heading* that has to
|
||||
accompany the move - the same structure-by-tool/prose-by-author split as
|
||||
`new`. `docs verify` then holds the two together.
|
||||
- `version bump` raises or continues the one running candidate between two
|
||||
releases, and writes the changelog *heading* that has to accompany it - the
|
||||
same structure-by-tool/prose-by-author split as `new`. `docs verify` then
|
||||
holds the two together.
|
||||
- `version release` fixes that candidate: strips its `-beta.N` suffix and
|
||||
closes its changelog entry. It is the only thing that turns a candidate into
|
||||
a number a release actually consumes.
|
||||
- `version check` is the one command in `wikitool` that makes a network call.
|
||||
It is deliberately its own command: nothing else reaches for it implicitly,
|
||||
it needs no key, it times out, and a feed that cannot be reached is reported
|
||||
@@ -188,34 +192,36 @@ def bump_command(
|
||||
major: bool = typer.Option(False, "--major", help="Bump MAJOR (resets MINOR and PATCH)"),
|
||||
minor: bool = typer.Option(False, "--minor", help="Bump MINOR (resets PATCH)"),
|
||||
patch: bool = typer.Option(False, "--patch", help="Bump PATCH"),
|
||||
title: str = typer.Option(..., "--title", help="One-line title for the new CHANGES.md entry"),
|
||||
title: str = typer.Option(..., "--title", help="One-line title for the new/updated CHANGES.md entry"),
|
||||
breaking: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--breaking",
|
||||
help="What stops working, for a boundary-crossing bump (recorded in CHANGES.md). Required on one, refused on any other",
|
||||
help="What stops working, for the bump that first escalates to a boundary crossing (recorded in CHANGES.md). Required there, refused on a bump that crosses nothing",
|
||||
),
|
||||
no_migration: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--no-migration",
|
||||
help="Why this boundary-crossing bump needs no content migration (recorded in CHANGES.md)",
|
||||
help="Why the escalation to a boundary crossing needs no content migration (recorded in CHANGES.md)",
|
||||
),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Report the change without writing"),
|
||||
):
|
||||
"""Raise the stack version and open its `CHANGES.md` entry.
|
||||
"""Raise or continue the running candidate, and open or update its
|
||||
`CHANGES.md` entry.
|
||||
|
||||
Writes `VERSION` and inserts the entry's heading, date and author - the
|
||||
entry's body stays the author's to write, the same way `new` produces
|
||||
frontmatter and leaves the prose. `docs verify` afterwards enforces that
|
||||
the two agree, so a bump with no entry cannot reach a release.
|
||||
Between two releases the stack carries **one** candidate, not a fresh
|
||||
number per bump: `--patch/--minor/--major` is max-wins escalation against
|
||||
the last release, never a step back down, and the candidate's bump count
|
||||
(`-beta.N`) advances either way. See
|
||||
`instructions/dev/version-parts.md` for the full model, and
|
||||
`version release` for what fixes a candidate into a release.
|
||||
|
||||
A bump that crosses the compatibility boundary - one whose new version is
|
||||
not a drop-in replacement, whether or not any content moves - requires
|
||||
`--breaking "<what stops working>"`, and on top of that either a migration
|
||||
document for the new version or `--no-migration "<reason>"`. An instance
|
||||
learning that it must migrate, with nothing telling it what broke or how to
|
||||
cross, is the gap these close. Which part to pass stays a judgment call
|
||||
this command does not make - it enforces only that a crossing says what it
|
||||
costs."""
|
||||
A bump whose escalation first crosses the compatibility boundary - the new
|
||||
version is not a drop-in replacement, whether or not any content moves -
|
||||
requires `--breaking "<what stops working>"`, and on top of that either a
|
||||
migration document for the new base or `--no-migration "<reason>"`. Both
|
||||
lines are written into the entry once and then persist across every later
|
||||
bump at the same stage: a follow-up bump need not repeat them, and passing
|
||||
either on a bump that crosses nothing at all is refused."""
|
||||
selected = [name for name, chosen in (("major", major), ("minor", minor), ("patch", patch)) if chosen]
|
||||
if len(selected) != 1:
|
||||
fail("Pass exactly one of --major / --minor / --patch")
|
||||
@@ -226,7 +232,6 @@ def bump_command(
|
||||
|
||||
try:
|
||||
current = version_mod.read_version()
|
||||
new_version = current.bumped(selected[0])
|
||||
except VersionError as exc:
|
||||
fail(str(exc))
|
||||
return
|
||||
@@ -236,19 +241,29 @@ def bump_command(
|
||||
fail(f"{version_mod.CHANGES_FILENAME} is missing - a bump has nowhere to record itself")
|
||||
return
|
||||
text = changes.read_text(encoding="utf-8")
|
||||
existing = version_mod.top_changes_version(text)
|
||||
if existing is not None and existing >= new_version:
|
||||
|
||||
top_entry = version_mod.top_changes_version(text)
|
||||
if top_entry is not None and top_entry != current:
|
||||
fail(
|
||||
f"{version_mod.CHANGES_FILENAME} already documents {existing}, which is not older "
|
||||
f"than {new_version} - bump past it, or fix the changelog"
|
||||
f"{version_mod.CHANGES_FILENAME}'s newest entry is {top_entry}, but "
|
||||
f"{version_mod.VERSION_FILENAME} is {current} - they must agree before a bump. "
|
||||
"Fix whichever is wrong."
|
||||
)
|
||||
return
|
||||
|
||||
last_release = version_mod.last_release(text)
|
||||
new_version = version_mod.escalate(last_release, current, selected[0])
|
||||
|
||||
author = config.default_author() or "unknown"
|
||||
crossing = new_version.compat_key != current.compat_key
|
||||
crossing = last_release is not None and new_version.compat_key != last_release.compat_key
|
||||
was_already_crossing = (
|
||||
last_release is not None
|
||||
and current.is_prerelease
|
||||
and current.compat_key != last_release.compat_key
|
||||
)
|
||||
boundary = " (crosses a compatibility boundary - instances must migrate)" if crossing else ""
|
||||
|
||||
if crossing and not breaking:
|
||||
if crossing and not was_already_crossing and not breaking:
|
||||
fail(
|
||||
f"{current} -> {new_version} crosses the compatibility boundary, so it is not a "
|
||||
f"drop-in replacement - re-run with --breaking \"<what stops working, and what an "
|
||||
@@ -265,14 +280,14 @@ def bump_command(
|
||||
)
|
||||
return
|
||||
|
||||
if crossing and not no_migration:
|
||||
if crossing and not was_already_crossing and not no_migration:
|
||||
from chemenu import kb_state
|
||||
|
||||
if not any(m.target == new_version for m in kb_state.load_migrations()):
|
||||
if not any(m.target == new_version.base for m in kb_state.load_migrations()):
|
||||
fail(
|
||||
f"{current} -> {new_version} crosses the compatibility boundary, so every existing "
|
||||
f"instance must migrate - but no migration document targets {new_version}.\n"
|
||||
f"Write one under {rel_path(kb_state.migrations_dir())}/{new_version}-<slug>.md "
|
||||
f"instance must migrate - but no migration document targets {new_version.base}.\n"
|
||||
f"Write one under {rel_path(kb_state.migrations_dir())}/{new_version.base}-<slug>.md "
|
||||
f"(see instructions/migrate-corpus.md), or, if no content actually has to change, "
|
||||
f're-run with --no-migration "<reason>".'
|
||||
)
|
||||
@@ -298,6 +313,76 @@ def bump_command(
|
||||
encoding="utf-8",
|
||||
)
|
||||
success(
|
||||
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and opened "
|
||||
f"the {version_mod.CHANGES_FILENAME} entry - write its body before publishing."
|
||||
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and "
|
||||
f"the {version_mod.CHANGES_FILENAME} entry - write its prose before publishing, and "
|
||||
f"`version release` once the candidate is ready to ship."
|
||||
)
|
||||
|
||||
|
||||
@app.command("release")
|
||||
def release_command(
|
||||
title: Optional[str] = typer.Option(
|
||||
None, "--title", help="Replace the entry's heading title (default: the last bump's)"
|
||||
),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Report the change without writing"),
|
||||
):
|
||||
"""Fix the running candidate: strip its `-beta.N` suffix and close its
|
||||
`CHANGES.md` entry.
|
||||
|
||||
Ends the pre-release phase this checkout has been in since its last
|
||||
`version bump` - the candidate's base becomes the release. Without
|
||||
`--title` the heading keeps whichever bump last set it; with it, the
|
||||
heading gets a summarising title instead, which is the normal case for a
|
||||
candidate that collected several bump titles along the way. The
|
||||
machine-managed list of those titles is left in the entry as the record of
|
||||
what happened, not replaced.
|
||||
|
||||
Commits nothing and pushes nothing (AGENTS.md invariant 5) - the following
|
||||
`publish` moves `VERSION` onto `main` and is what `release.yml` reacts to.
|
||||
Refuses when `VERSION` is already a release: there is no running candidate
|
||||
to fix."""
|
||||
try:
|
||||
current = version_mod.read_version()
|
||||
except VersionError as exc:
|
||||
fail(str(exc))
|
||||
return
|
||||
|
||||
if not current.is_prerelease:
|
||||
fail(
|
||||
f"{version_mod.VERSION_FILENAME} is already {current}, a release - there is no running "
|
||||
"candidate to fix. `version release` only ends a pre-release phase that `version bump` "
|
||||
"started."
|
||||
)
|
||||
return
|
||||
|
||||
changes = version_mod.changes_file()
|
||||
if not changes.is_file():
|
||||
fail(f"{version_mod.CHANGES_FILENAME} is missing - the candidate has nowhere to be fixed")
|
||||
return
|
||||
text = changes.read_text(encoding="utf-8")
|
||||
|
||||
top_entry = version_mod.top_changes_version(text)
|
||||
if top_entry != current:
|
||||
fail(
|
||||
f"{version_mod.CHANGES_FILENAME}'s newest entry is {top_entry}, but "
|
||||
f"{version_mod.VERSION_FILENAME} is {current} - they must agree before a release. "
|
||||
"Fix whichever is wrong."
|
||||
)
|
||||
return
|
||||
|
||||
new_version = current.base
|
||||
|
||||
if dry_run:
|
||||
success(f"Dry run: {current} -> {new_version} (release). Nothing written.")
|
||||
return
|
||||
|
||||
version_mod.write_version(new_version)
|
||||
changes.write_text(
|
||||
version_mod.release_entry(text, today_iso(), title.strip() if title else None),
|
||||
encoding="utf-8",
|
||||
)
|
||||
success(
|
||||
f"{current} -> {new_version} (release). Wrote {version_mod.VERSION_FILENAME} and fixed the "
|
||||
f"{version_mod.CHANGES_FILENAME} entry - `publish` next, which moves VERSION onto main and "
|
||||
"is what release.yml reacts to."
|
||||
)
|
||||
|
||||
+78
-28
@@ -78,6 +78,10 @@ def read_kb_version() -> Optional[Version]:
|
||||
None is a real state, not an error: an instance created before the KB
|
||||
version existed has content of unknown vintage, and guessing would be
|
||||
worse than asking (`migrate baseline`).
|
||||
|
||||
Refuses a pre-release (`-beta.N`): a content *shape* has no beta channel,
|
||||
only the machinery does, so a `kb_version` naming one means something
|
||||
wrote a stack version into this field by hand or by mistake.
|
||||
"""
|
||||
state = read_kb_state()
|
||||
if state is None:
|
||||
@@ -85,7 +89,13 @@ def read_kb_version() -> Optional[Version]:
|
||||
raw = state.get("kb_version")
|
||||
if not raw:
|
||||
return None
|
||||
return Version.parse(str(raw))
|
||||
version = Version.parse(str(raw))
|
||||
if version.is_prerelease:
|
||||
raise VersionError(
|
||||
f"{KB_STATE_FILENAME} names a pre-release kb_version ({version}) - content has no "
|
||||
"beta channel, only the stack version does"
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
def read_kb_state() -> Optional[dict]:
|
||||
@@ -119,21 +129,27 @@ def migrations_dir() -> Path:
|
||||
return config.INSTRUCTIONS_DIR / MIGRATIONS_SUBDIR
|
||||
|
||||
|
||||
def load_migrations() -> list[Migration]:
|
||||
"""Every migration document, sorted by target version.
|
||||
def load_migrations(directory: Optional[Path] = None) -> list[Migration]:
|
||||
"""Every migration document under `directory`, sorted by target version.
|
||||
|
||||
A malformed one is skipped rather than fatal here - `instructions verify`
|
||||
is what reports it, and `migrate status` staying usable while one document
|
||||
is broken is worth more than a second error path.
|
||||
`directory` defaults to this instance's own `instructions/migrations/`.
|
||||
`dist upgrade` (Gitea #7) passes the *new* tree's migrations directory
|
||||
instead: the migrations owed after an upgrade are documented in the
|
||||
machinery being installed, not in the one still on disk - an old instance
|
||||
cannot know a new version's migration chain by reading its own tree.
|
||||
|
||||
A malformed document is skipped rather than fatal here - `instructions
|
||||
verify` is what reports it, and `migrate status` staying usable while one
|
||||
document is broken is worth more than a second error path.
|
||||
"""
|
||||
from chemenu.frontmatter_io import read_page
|
||||
|
||||
directory = migrations_dir()
|
||||
if not directory.is_dir():
|
||||
base = directory if directory is not None else migrations_dir()
|
||||
if not base.is_dir():
|
||||
return []
|
||||
|
||||
migrations: list[Migration] = []
|
||||
for path in sorted(directory.glob("*.md")):
|
||||
for path in sorted(base.glob("*.md")):
|
||||
try:
|
||||
frontmatter, _ = read_page(path)
|
||||
except Exception: # noqa: BLE001 - a broken document is verify's finding, not ours
|
||||
@@ -171,6 +187,13 @@ def chain(
|
||||
Targets above the installed machinery are excluded: the instance has no code
|
||||
for them yet.
|
||||
|
||||
`stack_version` must be release-shaped (no `-beta.N`) - pass `.base` when
|
||||
the installed machinery is a running candidate. A migration document
|
||||
targets a release (`migrates_to: 4.4.0`), and a candidate's own version
|
||||
sorts *before* that release (`4.4.0-beta.1 < 4.4.0`), so comparing against
|
||||
the raw candidate would drop its own target out of the interval right
|
||||
when the machinery that owes it is installed.
|
||||
|
||||
`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
|
||||
@@ -215,25 +238,60 @@ def next_link(
|
||||
|
||||
# --- what this instance changed about what it was given --------------------
|
||||
|
||||
# What `compare_against_stamp` answers for one path: present and matching its
|
||||
# recorded digest, present but not matching, or gone entirely. `dist upgrade`
|
||||
# needs the three-way answer to tell a locally deleted file from a locally
|
||||
# edited one; `divergent_files` (below) only ever needed the yes/no of
|
||||
# "does this count as diverged", which both `MODIFIED` and `DELETED` answer
|
||||
# the same way.
|
||||
UNCHANGED = "unchanged"
|
||||
MODIFIED = "modified"
|
||||
DELETED = "deleted"
|
||||
|
||||
|
||||
def compare_against_stamp(stamp_files: dict, root: Optional[Path] = None) -> dict[str, str]:
|
||||
"""Classify every path in `stamp_files` (relative -> recorded sha256, the
|
||||
shape of a release stamp's own `files` block) against what is actually on
|
||||
disk under `root` - `UNCHANGED`, `MODIFIED`, or `DELETED`.
|
||||
|
||||
`root` defaults to `config.ROOT`. The general form `divergent_files` is
|
||||
built on: that function only ever asks the question against this
|
||||
instance's own tree, but `dist upgrade` (Gitea #7) asks it against an
|
||||
already-installed tree while planning what to write, and a *second* time
|
||||
against the tree it just wrote, before recording the new stamp - two trees
|
||||
neither of which is necessarily `config.ROOT`.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
base = root if root is not None else config.ROOT
|
||||
result: dict[str, str] = {}
|
||||
for relative, digest in sorted(stamp_files.items()):
|
||||
path = base / relative
|
||||
if not path.is_file():
|
||||
result[relative] = DELETED
|
||||
continue
|
||||
current = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
result[relative] = UNCHANGED if current == digest else MODIFIED
|
||||
return result
|
||||
|
||||
|
||||
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*.
|
||||
has been writing since the stamp existed. 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* - `dist upgrade` (Gitea #7) is that
|
||||
later upgrade, built on the general `compare_against_stamp` above.
|
||||
|
||||
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.
|
||||
That distinction is also 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:
|
||||
@@ -246,13 +304,5 @@ def divergent_files() -> Optional[list[str]]:
|
||||
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
|
||||
statuses = compare_against_stamp(recorded)
|
||||
return [relative for relative, status in statuses.items() if status != UNCHANGED]
|
||||
|
||||
@@ -165,9 +165,15 @@ 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.
|
||||
# Resolved against the directory `run_lint()` was handed, not against
|
||||
# `config.KB_DIR`. A page under a tree that is not the configured corpus -
|
||||
# every fixture tree, and any `lint <path>` aimed elsewhere - raised
|
||||
# `ValueError` here and read as "no collection", which made the label
|
||||
# authorisation below skip the edge in silence rather than judge it
|
||||
# (Gitea #44).
|
||||
def _collection_of(page):
|
||||
try:
|
||||
return page.path.relative_to(config.KB_DIR).parts[0]
|
||||
return page.path.relative_to(kb_dir).parts[0]
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
@@ -211,7 +217,9 @@ def run_lint(kb_dir: Path) -> dict:
|
||||
destination = _collection_of(target_page)
|
||||
if destination is None:
|
||||
continue
|
||||
allowed = kb_collections.authorised_labels(source_collection, destination)
|
||||
allowed = kb_collections.authorised_labels(
|
||||
source_collection, destination, kb_dir
|
||||
)
|
||||
if edge.label not in allowed:
|
||||
unauthorised_labels.append(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""The ownership boundary for a path under a content stage: does it belong to
|
||||
the *stack* (ships with every distribution, wins over local content when a
|
||||
private instance merges from a public upstream) or to the *instance* (never
|
||||
ships filled, wins over the upstream's version)?
|
||||
|
||||
One predicate, so `dist_cmd.py` (export) and `upstream_cmd.py` (merge/verify)
|
||||
answer the same question about the same paths instead of each keeping its own
|
||||
literal list that can drift out of sync with the other - see AGENTS.md
|
||||
invariant 8, and Gitea #30 for the incident that made the drift concrete
|
||||
(the private-instance merge procedure hardcoded a three-path list that
|
||||
`dist_cmd.py` had already outgrown).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# The stages whose content belongs to *this instance*, not the stack. Mirrors
|
||||
# the sentence .gitignore already makes about raw/, kb/ and work/ being the
|
||||
# repo's content, plus reports/ - only reports/CONTRACT.md is tracked there,
|
||||
# the rest is gitignored, so restoring it is a no-op today. It stays in the
|
||||
# set anyway: a set that is "almost" this one is the beginning of the same
|
||||
# drift this module exists to end.
|
||||
CONTENT_STAGES = ("kb", "raw", "work", "reports")
|
||||
|
||||
# Bare filenames `dist export` overwrites with a fresh stub rather than
|
||||
# shipping the stack's own copy. Not stack-owned: an upstream merge takes the
|
||||
# *local* side for these (they are the instance's own log/placeholder),
|
||||
# while `dist export` writes a brand-new one regardless of either side.
|
||||
EXPORT_STUB_NAMES = ("log.md", ".gitkeep")
|
||||
|
||||
# The single machinery filename directly under a content stage's own root.
|
||||
_STAGE_CONTRACT_NAME = "CONTRACT.md"
|
||||
|
||||
|
||||
def is_stack_owned(relative: str) -> bool:
|
||||
"""Whether `relative` - a path under a content stage, e.g. "kb/CONTRACT.md"
|
||||
or "kb/entities/COLLECTION.md.template" - is machinery: it ships with
|
||||
every distribution, and it is the side an upstream merge keeps.
|
||||
|
||||
True for exactly two shapes:
|
||||
|
||||
- `<stage>/CONTRACT.md`, directly under a content stage's own root. Not
|
||||
recursive: `kb/<collection>/COLLECTION.md` sits one level deeper and is
|
||||
instance-owned (see kb/CONTRACT.md's collection-ownership split).
|
||||
- Any path under a content stage ending in `.template` - by construction
|
||||
the stack's own copy of something the instance adopts by renaming
|
||||
(`kb/CONVENTIONS.md.template` and every `kb/<name>/COLLECTION.md.template`
|
||||
today; a future stack-owned template under a content stage falls under
|
||||
this rule automatically, with no code change here).
|
||||
|
||||
False for everything else under a content stage, `EXPORT_STUB_NAMES`
|
||||
included - those are handled separately by whichever caller cares about
|
||||
them, because the two callers disagree about which side wins for a stub.
|
||||
"""
|
||||
parts = relative.split("/")
|
||||
if len(parts) < 2 or parts[0] not in CONTENT_STAGES:
|
||||
return False
|
||||
if relative.endswith(".template"):
|
||||
return True
|
||||
return len(parts) == 2 and parts[1] == _STAGE_CONTRACT_NAME
|
||||
|
||||
|
||||
def is_export_stub(name: str) -> bool:
|
||||
"""Whether `name` (a bare filename, not a path) is one `dist export`
|
||||
overwrites with a fresh stub of its own rather than shipping verbatim."""
|
||||
return name in EXPORT_STUB_NAMES
|
||||
|
||||
|
||||
# Root-relative paths `dist export` seeds once, from a template it owns, and
|
||||
# which the instance owns exclusively from that point on. `dist upgrade`
|
||||
# (Gitea #7) must never overwrite them, even though they sit in the release
|
||||
# stamp's `files` block like any other planned file - the same shape as
|
||||
# `EXPORT_STUB_NAMES` above, but keyed by full path rather than bare filename,
|
||||
# since nothing else at the repo root gets this treatment and a bare-filename
|
||||
# match would be too broad here.
|
||||
#
|
||||
# `.wikitool-kb.json` is `migrate done`'s state file: an upgrade that resets it
|
||||
# declares a content shape nobody actually produced. `CHANGES.md` is the
|
||||
# instance's own changelog, not the stack's - `dist export` seeds it from a
|
||||
# blank template (`dist_templates/CHANGES.md`) the same way it seeds
|
||||
# `kb/log.md`, and overwriting it on upgrade would erase every entry the
|
||||
# instance ever wrote for itself.
|
||||
UPGRADE_PRESERVED_PATHS = (".wikitool-kb.json", "CHANGES.md")
|
||||
|
||||
|
||||
def is_upgrade_preserved(relative: str) -> bool:
|
||||
"""Whether `relative` (a plan-relative path from the repo root, e.g.
|
||||
"CHANGES.md") is one `dist upgrade` must never write."""
|
||||
return relative in UPGRADE_PRESERVED_PATHS
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -37,6 +38,78 @@ _GIT_ENV = (
|
||||
)
|
||||
|
||||
|
||||
def _working_tree_state() -> str | None:
|
||||
"""`git status --porcelain` for the checkout the tests live in, or None if
|
||||
there is no git available to ask."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(config._PACKAGE_ROOT), "status", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return result.stdout if result.returncode == 0 else None
|
||||
|
||||
|
||||
_TREE_GUARD_MESSAGE = (
|
||||
"A test wrote into the repository checkout instead of into its tmp_path.\n"
|
||||
"`git status --porcelain` moved while the suite ran:\n\n"
|
||||
" before:\n{before}\n"
|
||||
" after:\n{after}\n\n"
|
||||
"This is the class of bug Gitea #44 describes: code under test resolves a "
|
||||
"path through `config.ROOT`/`config.KB_DIR` rather than through the "
|
||||
"directory the fixture handed it, so the write lands in the real tree. Fix "
|
||||
"the fixture (repoint `config.ROOT`, as `raw_dir` and `kb_dir` do) or the "
|
||||
"code path, never the symptom.\n"
|
||||
"To find the test that did it, re-run with CHEMENU_TREE_GUARD=each - the "
|
||||
"guard then checks after every test and fails on the first one that moves "
|
||||
"the tree."
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def repository_tree_guard():
|
||||
"""Fail the run if the suite moved a file in the real checkout.
|
||||
|
||||
Two `git status` calls for the whole session, which is why this is on by
|
||||
default: it catches the whole class rather than the one case that was
|
||||
noticed. It compares before against after rather than demanding a clean
|
||||
tree, so it says nothing about a developer's own uncommitted work.
|
||||
|
||||
It cannot name the culprit - set `CHEMENU_TREE_GUARD=each` for that, which
|
||||
trades a `git status` per test for a failure on the test that did it.
|
||||
"""
|
||||
before = _working_tree_state()
|
||||
yield
|
||||
after = _working_tree_state()
|
||||
if before is None or after is None or before == after:
|
||||
return
|
||||
raise AssertionError(
|
||||
_TREE_GUARD_MESSAGE.format(before=before or "(clean)", after=after or "(clean)")
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def per_test_tree_guard(repository_tree_guard):
|
||||
"""The bisect half of `repository_tree_guard`, off unless asked for.
|
||||
|
||||
`CHEMENU_TREE_GUARD=each` turns the session-wide "something moved the tree"
|
||||
into "this test moved the tree", at the cost of a `git status` per test.
|
||||
"""
|
||||
if os.environ.get("CHEMENU_TREE_GUARD") != "each":
|
||||
yield
|
||||
return
|
||||
before = _working_tree_state()
|
||||
yield
|
||||
after = _working_tree_state()
|
||||
if before is not None and after is not None and before != after:
|
||||
raise AssertionError(
|
||||
_TREE_GUARD_MESSAGE.format(before=before or "(clean)", after=after or "(clean)")
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def hermetic_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Cut every test off from the machine it runs on.
|
||||
@@ -159,13 +232,25 @@ def raw_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kb_dir(tmp_path: Path) -> Path:
|
||||
def kb_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""A minimal fixture kb/ with the standard collection layout, populated
|
||||
with a handful of pages covering entities/concepts/sources/comparisons.
|
||||
|
||||
Every collection carries a COLLECTION.md, both because that is what makes it
|
||||
a collection and because the scanner must prove it skips them at a depth the
|
||||
kb-root meta files never reach."""
|
||||
kb-root meta files never reach.
|
||||
|
||||
`config.ROOT` is repointed for the same reason `raw_dir` does it, one
|
||||
collection over: code under test that resolves a path through
|
||||
`config.ROOT`/`config.KB_DIR` rather than through the directory it was
|
||||
handed otherwise reaches the *real* repository. That was not theoretical
|
||||
either - a test calling `kb_state.write_kb_state()` overwrote this
|
||||
checkout's `.wikitool-kb.json`, and `lint`'s collection lookup answered
|
||||
"no collection" for every fixture page, which left `unauthorised_labels`
|
||||
with no working test at all (Gitea #44).
|
||||
"""
|
||||
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||
use_shipped_type_specs(monkeypatch)
|
||||
kb = tmp_path / "kb"
|
||||
for sub in ("entities/projects", "entities/systems", "entities/tools",
|
||||
"entities/technologies", "entities/people",
|
||||
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
from chemenu import config
|
||||
from chemenu.commands import confidence_decay
|
||||
from chemenu.commands.confidence_decay import FLOOR, compute_decay
|
||||
from chemenu.frontmatter_io import read_page
|
||||
from chemenu.frontmatter_io import read_page, write_page
|
||||
|
||||
|
||||
def test_no_decay_at_zero_months():
|
||||
@@ -68,3 +68,22 @@ def test_decay_skips_pages_without_a_base(decay_wiki):
|
||||
confidence_decay.confidence_decay(apply=True)
|
||||
frontmatter, _ = read_page(decay_wiki / "entities/systems/aurora.md")
|
||||
assert frontmatter["confidence"] == 0.9
|
||||
|
||||
|
||||
def test_decay_skips_decision_pages(decay_wiki):
|
||||
"""A decision is not falsified by elapsed time, only by a later decision
|
||||
superseding it - `concept_type: decision` is a categorical skip, not
|
||||
something an old `modified` date should ever decay (Gitea #38)."""
|
||||
decision_path = decay_wiki / "concepts" / "some-decision.md"
|
||||
write_page(
|
||||
decision_path,
|
||||
{
|
||||
"type": "types/concept.md", "concept_type": "decision",
|
||||
"tags": [], "created": "2015-01-01", "modified": "2015-01-01",
|
||||
"related": [], "sources": [], "confidence": 0.9, "confidence_base": 0.9,
|
||||
},
|
||||
"\n# some-decision\n",
|
||||
)
|
||||
confidence_decay.confidence_decay(apply=True)
|
||||
frontmatter, _ = read_page(decision_path)
|
||||
assert frontmatter["confidence"] == 0.9
|
||||
|
||||
@@ -135,6 +135,10 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
f"<!-- {config.TEMPLATE_SENTINEL} -->\n# conventions template\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
docs_dir = root / "docs"
|
||||
docs_dir.mkdir()
|
||||
(docs_dir / "why-gates-are-code.md").write_text("# Why gates are code\n", encoding="utf-8")
|
||||
|
||||
for relative in ("raw/CONTRACT.md", "reports/CONTRACT.md", "work/CONTRACT.md"):
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -156,6 +160,11 @@ def test_plan_never_includes_commonplace(repo):
|
||||
assert "commonplace" not in combined
|
||||
|
||||
|
||||
def test_plan_ships_docs_verbatim(repo):
|
||||
plan = dist_cmd.build_plan()
|
||||
assert plan["docs/why-gates-are-code.md"].content == "# Why gates are code\n"
|
||||
|
||||
|
||||
def test_plan_never_includes_instructions_dev(repo):
|
||||
"""instructions/dev/ - flat dev-only instructions and the nested skill
|
||||
that switches a session into tool-development mode - is pruned
|
||||
@@ -421,6 +430,22 @@ def test_plan_declares_the_fresh_instance_content_version(repo):
|
||||
assert state["applied"] == []
|
||||
|
||||
|
||||
def test_kb_version_is_the_candidates_base_while_the_stamp_stays_honest(repo):
|
||||
"""Exporting mid-candidate answers two different questions: the stamp says
|
||||
what was actually exported (suffix included - "an export says what it
|
||||
is"), the KB version says what shape the content is built for. A content
|
||||
shape has no beta channel, so it must be the base."""
|
||||
from chemenu import kb_state
|
||||
|
||||
(repo / "VERSION").write_text("0.4.0-beta.2\n", encoding="utf-8")
|
||||
plan = dist_cmd.build_plan()
|
||||
stamp = json.loads(plan[version_mod.RELEASE_STAMP_FILENAME].content)
|
||||
state = json.loads(plan[kb_state.KB_STATE_FILENAME].content)
|
||||
assert stamp["version"] == "0.4.0-beta.2"
|
||||
assert plan["VERSION"].content.strip() == "0.4.0-beta.2"
|
||||
assert state["kb_version"] == "0.4.0"
|
||||
|
||||
|
||||
def test_export_refuses_a_tree_with_no_version(repo, tmp_path):
|
||||
(repo / "VERSION").unlink()
|
||||
target = tmp_path / "dist"
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
"""Tests for `wikitool dist upgrade`: classification against the locally
|
||||
installed release stamp, the write set, migration-chain reporting without
|
||||
execution, and every refusal before anything is written. See Gitea #7."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import typer
|
||||
|
||||
from chemenu import config, kb_state, version as version_mod
|
||||
from chemenu.commands import dist_cmd
|
||||
|
||||
|
||||
def _digest(text: str) -> str:
|
||||
return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _write_stamp(path: Path, version: str, files: dict[str, str]) -> None:
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": version_mod.STAMP_SCHEMA,
|
||||
"version": version,
|
||||
"exported_at": "2026-01-01",
|
||||
"source_repo": None,
|
||||
"source_commit": None,
|
||||
"release_url": None,
|
||||
"update_url": version_mod.DEFAULT_UPDATE_URL,
|
||||
"files": files,
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def write_migration(directory: Path, target: str, slug: str) -> None:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
(directory / f"{target}-{slug}.md").write_text(
|
||||
"---\n"
|
||||
"type: types/instruction.md\n"
|
||||
f"name: {target}-{slug}\n"
|
||||
f"description: Migration to {target}.\n"
|
||||
"manual: true\n"
|
||||
f"migrates_to: {target}\n"
|
||||
"migration_kind: assisted\n"
|
||||
"obligation: required\n"
|
||||
"---\n\n# Migration\n\nSteps.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def set_kb_version(root: Path, version: str) -> None:
|
||||
(root / kb_state.KB_STATE_FILENAME).write_text(
|
||||
json.dumps({"schema": 1, "kb_version": version, "applied": []}), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""A minimal tarball instance: VERSION 1.0.0, a local release stamp
|
||||
recording two tracked files exactly as installed, content stays at
|
||||
1.0.0 with nothing outstanding, and no .git directory (the WARN path
|
||||
for the dirty-tree check)."""
|
||||
root = tmp_path / "instance"
|
||||
root.mkdir()
|
||||
monkeypatch.setattr(config, "ROOT", root)
|
||||
|
||||
(root / "VERSION").write_text("1.0.0\n", encoding="utf-8")
|
||||
(root / "AGENTS.md").write_text("core\n", encoding="utf-8")
|
||||
(root / "tools").mkdir()
|
||||
(root / "tools" / "wikitool").write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
|
||||
files = {
|
||||
"AGENTS.md": _digest("core\n"),
|
||||
"tools/wikitool": _digest("#!/bin/sh\n"),
|
||||
}
|
||||
_write_stamp(root / version_mod.RELEASE_STAMP_FILENAME, "1.0.0", files)
|
||||
set_kb_version(root, "1.0.0")
|
||||
return root
|
||||
|
||||
|
||||
def _release(tmp_path: Path, name: str, version: str, files_content: dict[str, str]) -> Path:
|
||||
"""A second, independent tree shaped like a `dist export` output: VERSION,
|
||||
a release stamp whose `files` block matches `files_content` exactly, and
|
||||
the files themselves."""
|
||||
root = tmp_path / name
|
||||
root.mkdir()
|
||||
(root / "VERSION").write_text(f"{version}\n", encoding="utf-8")
|
||||
files: dict[str, str] = {}
|
||||
for relative, content in files_content.items():
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
files[relative] = _digest(content)
|
||||
_write_stamp(root / version_mod.RELEASE_STAMP_FILENAME, version, files)
|
||||
return root
|
||||
|
||||
|
||||
# --- classification / dry-run -----------------------------------------------
|
||||
|
||||
|
||||
def test_dry_run_classifies_every_case_and_writes_nothing(instance, tmp_path):
|
||||
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
||||
(instance / "tools" / "wikitool").unlink()
|
||||
|
||||
release = _release(
|
||||
tmp_path, "release", "1.1.0",
|
||||
{
|
||||
"AGENTS.md": "core\n", # locally modified
|
||||
"tools/wikitool": "#!/bin/sh\n", # locally deleted
|
||||
"types/entity.md": "new page type\n", # new in the release
|
||||
},
|
||||
)
|
||||
|
||||
dist_cmd.run_upgrade(release, dry_run=True)
|
||||
|
||||
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
|
||||
assert not (instance / "tools" / "wikitool").exists()
|
||||
assert not (instance / "types" / "entity.md").exists()
|
||||
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
|
||||
|
||||
|
||||
def test_removed_file_is_reported_and_left_alone_without_prune(instance, tmp_path):
|
||||
release = _release(tmp_path, "release", "1.1.0", {"AGENTS.md": "core\n"})
|
||||
# tools/wikitool is in the old stamp but absent from the new one.
|
||||
dist_cmd.run_upgrade(release, dry_run=True)
|
||||
assert (instance / "tools" / "wikitool").is_file()
|
||||
|
||||
|
||||
# --- local changes are never silently overwritten ---------------------------
|
||||
|
||||
|
||||
def test_locally_modified_file_blocks_the_upgrade_by_default(instance, tmp_path):
|
||||
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
||||
release = _release(
|
||||
tmp_path, "release", "1.1.0",
|
||||
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n"},
|
||||
)
|
||||
with pytest.raises(typer.Exit) as excinfo:
|
||||
dist_cmd.run_upgrade(release)
|
||||
assert excinfo.value.exit_code == 1
|
||||
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
|
||||
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
|
||||
|
||||
|
||||
def test_locally_deleted_file_blocks_the_upgrade_by_default(instance, tmp_path):
|
||||
(instance / "tools" / "wikitool").unlink()
|
||||
release = _release(
|
||||
tmp_path, "release", "1.1.0",
|
||||
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh v2\n"},
|
||||
)
|
||||
with pytest.raises(typer.Exit):
|
||||
dist_cmd.run_upgrade(release)
|
||||
assert not (instance / "tools" / "wikitool").exists()
|
||||
|
||||
|
||||
def test_keep_local_proceeds_and_leaves_the_changed_file_untouched(instance, tmp_path):
|
||||
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
||||
release = _release(
|
||||
tmp_path, "release", "1.1.0",
|
||||
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n", "types/entity.md": "new\n"},
|
||||
)
|
||||
dist_cmd.run_upgrade(release, keep_local=True)
|
||||
|
||||
# The locally changed file is untouched...
|
||||
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
|
||||
# ...but everything unchanged/new was still written.
|
||||
assert (instance / "tools" / "wikitool").read_text(encoding="utf-8") == "#!/bin/sh\n"
|
||||
assert (instance / "types" / "entity.md").read_text(encoding="utf-8") == "new\n"
|
||||
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.1.0"
|
||||
|
||||
|
||||
# --- the write set -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_unchanged_and_new_files_are_written_silently(instance, tmp_path):
|
||||
release = _release(
|
||||
tmp_path, "release", "1.1.0",
|
||||
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n", "types/entity.md": "new\n"},
|
||||
)
|
||||
dist_cmd.run_upgrade(release)
|
||||
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "core\n"
|
||||
assert (instance / "types" / "entity.md").read_text(encoding="utf-8") == "new\n"
|
||||
stamp = json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())
|
||||
assert stamp["version"] == "1.1.0"
|
||||
assert stamp["files"]["types/entity.md"] == _digest("new\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("preserved", [".wikitool-kb.json", "CHANGES.md", "kb/log.md", "raw/notes/.gitkeep"])
|
||||
def test_seeded_once_paths_are_never_written_even_if_the_release_stamp_lists_them(
|
||||
instance, tmp_path, preserved
|
||||
):
|
||||
"""The write set is the new stamp's `files` block minus what an export
|
||||
re-seeds every time or seeds once and the instance owns from then on -
|
||||
this is the AGENTS.md invariant 8 test: no separate literal list here,
|
||||
only `chemenu.ownership`."""
|
||||
(instance / "CHANGES.md").write_text("instance's own changelog\n", encoding="utf-8")
|
||||
(instance / kb_state.KB_STATE_FILENAME).write_text(
|
||||
json.dumps({"schema": 1, "kb_version": "1.0.0", "applied": [{"migration": "x"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
release = _release(
|
||||
tmp_path, "release", "1.1.0",
|
||||
{
|
||||
"AGENTS.md": "core\n",
|
||||
"tools/wikitool": "#!/bin/sh\n",
|
||||
preserved: "a fresh stub from the new release\n",
|
||||
},
|
||||
)
|
||||
dist_cmd.run_upgrade(release)
|
||||
if preserved == "CHANGES.md":
|
||||
assert (instance / preserved).read_text(encoding="utf-8") == "instance's own changelog\n"
|
||||
elif preserved == ".wikitool-kb.json":
|
||||
state = json.loads((instance / preserved).read_text())
|
||||
assert state["applied"] == [{"migration": "x"}]
|
||||
else:
|
||||
assert not (instance / preserved).exists()
|
||||
|
||||
|
||||
# --- migration chain: reported, never run -----------------------------------
|
||||
|
||||
|
||||
def test_migration_chain_is_reported_but_never_executed(instance, tmp_path):
|
||||
release = _release(
|
||||
tmp_path, "release", "1.1.0",
|
||||
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"},
|
||||
)
|
||||
write_migration(release / "instructions" / "migrations", "1.1.0", "some-change")
|
||||
|
||||
dist_cmd.run_upgrade(release)
|
||||
|
||||
# The chain is reported, not applied: kb_version has not moved.
|
||||
assert kb_state.read_kb_version().base == version_mod.Version(1, 0, 0)
|
||||
|
||||
|
||||
def test_outstanding_local_migration_blocks_before_touching_the_source(instance, tmp_path):
|
||||
"""A migration owed against the *installed* machinery must be finished
|
||||
first - the source is never even opened."""
|
||||
migrations = instance / "instructions" / "migrations"
|
||||
write_migration(migrations, "1.0.0", "not-yet-done")
|
||||
monkey_target = instance / "does-not-exist" # never read if this check fires first
|
||||
|
||||
with pytest.raises(typer.Exit):
|
||||
dist_cmd.run_upgrade(monkey_target)
|
||||
|
||||
|
||||
# --- preconditions -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_missing_local_stamp_blocks(instance, tmp_path):
|
||||
(instance / version_mod.RELEASE_STAMP_FILENAME).unlink()
|
||||
release = _release(tmp_path, "release", "1.1.0", {"AGENTS.md": "core\n"})
|
||||
with pytest.raises(typer.Exit):
|
||||
dist_cmd.run_upgrade(release)
|
||||
|
||||
|
||||
def test_missing_kb_version_blocks(instance, tmp_path):
|
||||
(instance / kb_state.KB_STATE_FILENAME).unlink()
|
||||
release = _release(tmp_path, "release", "1.1.0", {"AGENTS.md": "core\n"})
|
||||
with pytest.raises(typer.Exit):
|
||||
dist_cmd.run_upgrade(release)
|
||||
|
||||
|
||||
def test_prerelease_source_is_refused_without_pre_flag(instance, tmp_path):
|
||||
release = _release(tmp_path, "release", "1.1.0-beta.1", {"AGENTS.md": "core\n"})
|
||||
with pytest.raises(typer.Exit):
|
||||
dist_cmd.run_upgrade(release)
|
||||
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
|
||||
|
||||
|
||||
def test_pre_flag_allows_a_prerelease_source(instance, tmp_path):
|
||||
release = _release(
|
||||
tmp_path, "release", "1.1.0-beta.1",
|
||||
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"},
|
||||
)
|
||||
dist_cmd.run_upgrade(release, allow_pre=True)
|
||||
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.1.0-beta.1"
|
||||
|
||||
|
||||
def test_downgrade_is_refused(instance, tmp_path):
|
||||
release = _release(tmp_path, "release", "0.9.0", {"AGENTS.md": "core\n"})
|
||||
with pytest.raises(typer.Exit):
|
||||
dist_cmd.run_upgrade(release)
|
||||
|
||||
|
||||
def test_equal_version_is_a_noop(instance, tmp_path):
|
||||
release = _release(tmp_path, "release", "1.0.0", {"AGENTS.md": "core\n"})
|
||||
dist_cmd.run_upgrade(release) # must not raise
|
||||
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
|
||||
|
||||
|
||||
def test_dirty_working_tree_blocks(instance, tmp_path):
|
||||
subprocess.run(["git", "init", "-q", "-b", "main"], cwd=instance, check=True)
|
||||
subprocess.run(["git", "config", "user.name", "Fixture Author"], cwd=instance, check=True)
|
||||
subprocess.run(["git", "config", "user.email", "fixture@example.invalid"], cwd=instance, check=True)
|
||||
(instance / "untracked.txt").write_text("dirty\n", encoding="utf-8")
|
||||
|
||||
release = _release(tmp_path, "release", "1.1.0", {"AGENTS.md": "core\n"})
|
||||
with pytest.raises(typer.Exit):
|
||||
dist_cmd.run_upgrade(release)
|
||||
assert not (instance / "types").exists()
|
||||
|
||||
|
||||
def test_source_that_does_not_exist_is_refused(instance, tmp_path):
|
||||
with pytest.raises(typer.Exit):
|
||||
dist_cmd.run_upgrade(tmp_path / "nowhere")
|
||||
|
||||
|
||||
# --- prune -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prune_removes_only_removed_files_still_unchanged_since_install(instance, tmp_path):
|
||||
(instance / "extra.txt").write_text("shipped once, edited since\n", encoding="utf-8")
|
||||
old_stamp = json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())
|
||||
old_stamp["files"]["extra.txt"] = _digest("shipped once, unedited\n") # deliberately stale
|
||||
old_stamp["files"]["gone.txt"] = _digest("also shipped once\n")
|
||||
(instance / version_mod.RELEASE_STAMP_FILENAME).write_text(json.dumps(old_stamp), encoding="utf-8")
|
||||
(instance / "gone.txt").write_text("also shipped once\n", encoding="utf-8")
|
||||
|
||||
release = _release(
|
||||
tmp_path, "release", "1.1.0",
|
||||
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"},
|
||||
)
|
||||
dist_cmd.run_upgrade(release, prune=True)
|
||||
|
||||
# gone.txt matched its recorded digest -> pruned.
|
||||
assert not (instance / "gone.txt").exists()
|
||||
# extra.txt was locally edited relative to its recorded digest -> kept.
|
||||
assert (instance / "extra.txt").read_text(encoding="utf-8") == "shipped once, edited since\n"
|
||||
|
||||
|
||||
# --- tarball sources ----------------------------------------------------------
|
||||
|
||||
|
||||
def _pack(release_dir: Path, archive: Path) -> None:
|
||||
with tarfile.open(archive, "w:gz") as tf:
|
||||
tf.add(release_dir, arcname=release_dir.name)
|
||||
|
||||
|
||||
def test_tarball_with_more_than_one_top_level_entry_is_refused(instance, tmp_path):
|
||||
scratch = tmp_path / "scratch"
|
||||
(scratch / "a").mkdir(parents=True)
|
||||
(scratch / "b").mkdir(parents=True)
|
||||
(scratch / "a" / "x.txt").write_text("x\n", encoding="utf-8")
|
||||
(scratch / "b" / "y.txt").write_text("y\n", encoding="utf-8")
|
||||
archive = tmp_path / "bad.tar.gz"
|
||||
with tarfile.open(archive, "w:gz") as tf:
|
||||
tf.add(scratch / "a", arcname="a")
|
||||
tf.add(scratch / "b", arcname="b")
|
||||
|
||||
with pytest.raises(typer.Exit):
|
||||
dist_cmd.run_upgrade(archive)
|
||||
|
||||
|
||||
def test_tarball_source_is_extracted_and_applied(instance, tmp_path):
|
||||
release_dir = _release(
|
||||
tmp_path, "chemenu-stack-1.1.0", "1.1.0",
|
||||
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"},
|
||||
)
|
||||
archive = tmp_path / "chemenu-stack-1.1.0.tar.gz"
|
||||
_pack(release_dir, archive)
|
||||
|
||||
dist_cmd.run_upgrade(archive)
|
||||
|
||||
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "core\n"
|
||||
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.1.0"
|
||||
|
||||
|
||||
def test_tarball_sha256_sidecar_mismatch_is_refused(instance, tmp_path):
|
||||
release_dir = _release(
|
||||
tmp_path, "chemenu-stack-1.1.0", "1.1.0", {"AGENTS.md": "core\n"}
|
||||
)
|
||||
archive = tmp_path / "chemenu-stack-1.1.0.tar.gz"
|
||||
_pack(release_dir, archive)
|
||||
(archive.with_name(archive.name + ".sha256")).write_text(
|
||||
"0" * 64 + " chemenu-stack-1.1.0.tar.gz\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
with pytest.raises(typer.Exit):
|
||||
dist_cmd.run_upgrade(archive)
|
||||
@@ -190,6 +190,12 @@ def test_this_repos_boundary_is_accounted_for():
|
||||
|
||||
|
||||
def _boundary_tree(tmp_path, monkeypatch, current: str, previous: str, marker: str = ""):
|
||||
"""A changelog with `current` as the topmost entry and `previous` as the
|
||||
last release beneath it. `current` is normally an open candidate
|
||||
(`2.0.0-beta.1`) - the checks compare the newest entry against the **last
|
||||
release** (`version_mod.last_release`), which skips right past a topmost
|
||||
entry that is itself already a release (that one's crossing, if any, was
|
||||
already checked while it was still the open candidate)."""
|
||||
(tmp_path / "VERSION").write_text(f"{current}\n", encoding="utf-8")
|
||||
(tmp_path / "CHANGES.md").write_text(
|
||||
"# Changelog\n\n---\n\n"
|
||||
@@ -207,28 +213,41 @@ def _boundary_tree(tmp_path, monkeypatch, current: str, previous: str, marker: s
|
||||
def test_a_breaking_release_without_a_migration_is_reported(tmp_path, monkeypatch):
|
||||
"""`version check` tells an instance it must migrate; without this, that is
|
||||
where the trail ends."""
|
||||
_boundary_tree(tmp_path, monkeypatch, "2.0.0", "1.4.0")
|
||||
_boundary_tree(tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0")
|
||||
issues = docs_verify.check_migration_for_boundary()
|
||||
assert any("2.0.0" in issue and "must migrate" in issue for issue in issues)
|
||||
assert any("2.0.0-beta.1" in issue and "must migrate" in issue for issue in issues)
|
||||
|
||||
|
||||
def test_a_compatible_release_needs_no_migration(tmp_path, monkeypatch):
|
||||
_boundary_tree(tmp_path, monkeypatch, "1.5.0", "1.4.0")
|
||||
_boundary_tree(tmp_path, monkeypatch, "1.5.0-beta.1", "1.4.0")
|
||||
assert docs_verify.check_migration_for_boundary() == []
|
||||
|
||||
|
||||
def test_a_fixed_release_is_never_re_checked_against_its_own_crossing(tmp_path, monkeypatch):
|
||||
"""Regression for finding #2: comparing against the entry *beneath* the
|
||||
newest one (rather than the last release) would find no boundary between
|
||||
two betas of the same candidate - and would also, wrongly, re-flag an
|
||||
already-fixed release forever. Once `current` is itself a release,
|
||||
`last_release` returns it directly, so there is nothing left to compare."""
|
||||
_boundary_tree(tmp_path, monkeypatch, "2.0.0", "1.4.0")
|
||||
assert docs_verify.check_migration_for_boundary() == []
|
||||
assert docs_verify.check_breaking_change_for_boundary() == []
|
||||
|
||||
|
||||
def test_an_explicit_none_required_marker_satisfies_the_check(tmp_path, monkeypatch):
|
||||
from chemenu import version as version_mod
|
||||
|
||||
_boundary_tree(
|
||||
tmp_path, monkeypatch, "2.0.0", "1.4.0",
|
||||
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
|
||||
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
|
||||
)
|
||||
assert docs_verify.check_migration_for_boundary() == []
|
||||
|
||||
|
||||
def test_a_migration_document_satisfies_the_check(tmp_path, monkeypatch):
|
||||
root = _boundary_tree(tmp_path, monkeypatch, "2.0.0", "1.4.0")
|
||||
"""The document targets the candidate's *base* (`2.0.0`), not its full
|
||||
pre-release form - matching what `version bump` looks for."""
|
||||
root = _boundary_tree(tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0")
|
||||
(root / "instructions" / "migrations" / "2.0.0-retype.md").write_text(
|
||||
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
|
||||
"description: Retype.\nmanual: true\nmigrates_to: 2.0.0\n---\n",
|
||||
@@ -243,16 +262,16 @@ def test_a_breaking_release_without_a_breaking_note_is_reported(tmp_path, monkey
|
||||
from chemenu import version as version_mod
|
||||
|
||||
_boundary_tree(
|
||||
tmp_path, monkeypatch, "2.0.0", "1.4.0",
|
||||
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
|
||||
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
|
||||
)
|
||||
assert docs_verify.check_migration_for_boundary() == []
|
||||
issues = docs_verify.check_breaking_change_for_boundary()
|
||||
assert any("2.0.0" in issue and "drop-in" in issue for issue in issues)
|
||||
assert any("2.0.0-beta.1" in issue and "drop-in" in issue for issue in issues)
|
||||
|
||||
|
||||
def test_a_compatible_release_needs_no_breaking_note(tmp_path, monkeypatch):
|
||||
_boundary_tree(tmp_path, monkeypatch, "1.5.0", "1.4.0")
|
||||
_boundary_tree(tmp_path, monkeypatch, "1.5.0-beta.1", "1.4.0")
|
||||
assert docs_verify.check_breaking_change_for_boundary() == []
|
||||
|
||||
|
||||
@@ -260,7 +279,7 @@ def test_a_breaking_change_marker_satisfies_the_check(tmp_path, monkeypatch):
|
||||
from chemenu import version as version_mod
|
||||
|
||||
_boundary_tree(
|
||||
tmp_path, monkeypatch, "2.0.0", "1.4.0",
|
||||
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
|
||||
marker=f"{version_mod.BREAKING_CHANGE_MARKER} the feed moved.\n\n",
|
||||
)
|
||||
assert docs_verify.check_breaking_change_for_boundary() == []
|
||||
|
||||
@@ -162,6 +162,33 @@ def test_an_unreadable_kb_state_fails(instance):
|
||||
assert _status(doctor.run_doctor(), "kb-version") == "FAIL"
|
||||
|
||||
|
||||
def test_a_running_candidate_is_named_as_such(instance):
|
||||
(config.ROOT / "VERSION").write_text("0.2.0-beta.1\n", encoding="utf-8")
|
||||
detail = next(c.detail for c in doctor.run_doctor() if c.name == "stack-version")
|
||||
assert "0.2.0-beta.1" in detail
|
||||
assert "candidate" in detail
|
||||
|
||||
|
||||
def test_kb_version_chain_still_reaches_a_target_matching_a_running_candidate(instance):
|
||||
"""Regression for finding #3: comparing the chain against the raw
|
||||
candidate would sort `2.0.0` (the migration's target) *before*
|
||||
`2.0.0-beta.1` (what is installed), dropping it out of the owed range."""
|
||||
(config.ROOT / "VERSION").write_text("2.0.0-beta.1\n", encoding="utf-8")
|
||||
(config.ROOT / ".wikitool-kb.json").write_text(
|
||||
'{"schema": 1, "kb_version": "1.0.0", "applied": []}', encoding="utf-8"
|
||||
)
|
||||
migrations = config.INSTRUCTIONS_DIR / "migrations"
|
||||
migrations.mkdir(parents=True, exist_ok=True)
|
||||
(migrations / "2.0.0-retype.md").write_text(
|
||||
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
|
||||
"description: Retype.\nmanual: true\nmigrates_to: 2.0.0\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "kb-version") == "WARN"
|
||||
assert "outstanding" in next(c.detail for c in checks if c.name == "kb-version")
|
||||
|
||||
|
||||
def test_no_collections_at_all_fails_structure(instance):
|
||||
"""Removing one collection is a legitimate state - collections are
|
||||
discovered by COLLECTION.md presence, not a fixed list (kb/CONTRACT.md).
|
||||
|
||||
@@ -74,3 +74,23 @@ def test_wiki_author_overrides_the_git_identity(tmp_path: Path,
|
||||
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||
monkeypatch.setenv("WIKI_AUTHOR", "Env Override")
|
||||
assert config.default_author() == "Env Override"
|
||||
|
||||
|
||||
def test_kb_dir_repoints_the_configured_root_at_its_own_tree(kb_dir: Path, tmp_path: Path):
|
||||
"""The other half of the isolation, and the one `kb_dir` was missing until
|
||||
Gitea #44: a fixture that builds a corpus but leaves `config.ROOT` on the
|
||||
real checkout hands every `config.KB_DIR` lookup the developer's own wiki -
|
||||
which is how a test overwrote the repository's `.wikitool-kb.json`."""
|
||||
assert config.ROOT == tmp_path
|
||||
assert config.KB_DIR == kb_dir
|
||||
|
||||
|
||||
def test_raw_dir_repoints_the_configured_root_at_its_own_tree(raw_dir: Path, tmp_path: Path):
|
||||
assert config.ROOT == tmp_path
|
||||
assert config.RAW_DIR == raw_dir
|
||||
|
||||
|
||||
def test_both_corpus_fixtures_keep_the_shipped_type_specs_reachable(kb_dir: Path):
|
||||
"""Repointing `ROOT` moves `TYPES_DIR` with it, so the repoint has to be
|
||||
paired with `use_shipped_type_specs()` or no page type resolves at all."""
|
||||
assert (config.TYPES_DIR / "entity.md").is_file()
|
||||
|
||||
@@ -15,7 +15,6 @@ from chemenu.commands.lint import (
|
||||
)
|
||||
from chemenu.frontmatter_io import write_page
|
||||
from chemenu.provenance import cite_id, render_cite_block
|
||||
from chemenu.tests.conftest import use_shipped_type_specs
|
||||
from chemenu.version import Version
|
||||
|
||||
|
||||
@@ -482,26 +481,18 @@ def test_lint_does_not_count_a_shell_prompt_as_a_quote(kb_dir):
|
||||
|
||||
# --- the migration gate on `unlabelled_edges` / `unauthorised_labels` -------
|
||||
#
|
||||
# These four repoint `config.ROOT` at the fixture tree, which the `kb_dir`
|
||||
# fixture alone does not do. Two things need it: `write_kb_state()` writes
|
||||
# `.wikitool-kb.json` relative to `ROOT`, and `lint`'s collection lookup
|
||||
# resolves a page against `config.KB_DIR` rather than against the directory it
|
||||
# was handed - so without the repoint the gate would be read off the real
|
||||
# repository's state file.
|
||||
# These read and write `.wikitool-kb.json`, which `kb_state` resolves relative
|
||||
# to `config.ROOT`. The `kb_dir` fixture repoints `ROOT` at its own tmp_path
|
||||
# (Gitea #44), so the gate is read off the fixture tree; before it did, these
|
||||
# four ran against the real repository's state file and one of them overwrote
|
||||
# it.
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rooted_kb(kb_dir, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||
use_shipped_type_specs(monkeypatch)
|
||||
return kb_dir
|
||||
|
||||
|
||||
def _page_with_an_unlabelled_edge(rooted_kb):
|
||||
def _page_with_an_unlabelled_edge(kb_dir):
|
||||
"""A `related:` entry that is a bare title rather than a `label: title`
|
||||
mapping - the shape every page was in before the 4.0.0 migration."""
|
||||
write_page(
|
||||
rooted_kb / "entities/tools/bare-edge.md",
|
||||
kb_dir / "entities/tools/bare-edge.md",
|
||||
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
|
||||
"modified": "2026-09-03", "related": ["Modbus"], "sources": [], "confidence": 0.8,
|
||||
"provenance": "general", "summary": "One edge whose label was never declared."},
|
||||
@@ -509,14 +500,14 @@ def _page_with_an_unlabelled_edge(rooted_kb):
|
||||
)
|
||||
|
||||
|
||||
def test_unlabelled_edge_is_advisory_below_kb_version_4(rooted_kb):
|
||||
def test_unlabelled_edge_is_advisory_below_kb_version_4(kb_dir):
|
||||
"""The window the migration document describes: the machinery has landed,
|
||||
the corpus has not been converted yet, and `lint --fail-on-error` must not
|
||||
refuse the very tree the migration tells the instance to publish unit by
|
||||
unit."""
|
||||
_page_with_an_unlabelled_edge(rooted_kb)
|
||||
_page_with_an_unlabelled_edge(kb_dir)
|
||||
kb_state.write_kb_state(Version(3, 0, 0), [])
|
||||
report = run_lint(rooted_kb)
|
||||
report = run_lint(kb_dir)
|
||||
assert report["unlabelled_edges"] != []
|
||||
assert "unlabelled_edges" not in hard_error_keys()
|
||||
# Narrowed to the finding under test: the fixture corpus carries unrelated
|
||||
@@ -525,34 +516,64 @@ def test_unlabelled_edge_is_advisory_below_kb_version_4(rooted_kb):
|
||||
assert has_hard_errors({"unlabelled_edges": report["unlabelled_edges"]}) is False
|
||||
|
||||
|
||||
def test_unlabelled_edge_is_hard_at_kb_version_4(rooted_kb):
|
||||
def test_unlabelled_edge_is_hard_at_kb_version_4(kb_dir):
|
||||
"""Once the migration is recorded, a bare title is no longer a page waiting
|
||||
its turn - it is an edge whose author did not say what it asserts."""
|
||||
_page_with_an_unlabelled_edge(rooted_kb)
|
||||
_page_with_an_unlabelled_edge(kb_dir)
|
||||
kb_state.write_kb_state(Version(4, 0, 0), [])
|
||||
report = run_lint(rooted_kb)
|
||||
report = run_lint(kb_dir)
|
||||
assert report["unlabelled_edges"] != []
|
||||
assert "unlabelled_edges" in hard_error_keys()
|
||||
assert has_hard_errors({"unlabelled_edges": report["unlabelled_edges"]}) is True
|
||||
|
||||
|
||||
def test_unauthorised_label_is_hard_at_kb_version_4(rooted_kb):
|
||||
def test_unauthorised_label_is_hard_at_kb_version_4(kb_dir):
|
||||
"""The fixture contracts authorise `depends-on` but not `contradicts`."""
|
||||
write_page(
|
||||
rooted_kb / "entities/tools/off-menu.md",
|
||||
kb_dir / "entities/tools/off-menu.md",
|
||||
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
|
||||
"modified": "2026-09-03", "related": [{"contradicts": "Modbus"}], "sources": [],
|
||||
"confidence": 0.8, "provenance": "general", "summary": "A label off this menu."},
|
||||
"\n# off-menu\n\nA label the source collection never authorised.\n",
|
||||
)
|
||||
kb_state.write_kb_state(Version(4, 0, 0), [])
|
||||
report = run_lint(rooted_kb)
|
||||
report = run_lint(kb_dir)
|
||||
assert report["unauthorised_labels"] != []
|
||||
assert "unauthorised_labels" in hard_error_keys()
|
||||
assert has_hard_errors({"unauthorised_labels": report["unauthorised_labels"]}) is True
|
||||
|
||||
|
||||
def test_a_tree_that_never_declared_a_kb_version_keeps_every_key(rooted_kb):
|
||||
def test_unauthorised_label_is_judged_in_a_tree_that_is_not_the_configured_kb(
|
||||
kb_dir, tmp_path, monkeypatch
|
||||
):
|
||||
"""`run_lint()` judges the tree it was handed, not the configured corpus.
|
||||
|
||||
The collection lookup used to resolve a page against `config.KB_DIR`; a
|
||||
page anywhere else raised `ValueError`, read back as "no collection", and
|
||||
the label check skipped the edge without a word. That is why
|
||||
`unauthorised_labels` was untested in practice before Gitea #44 - every
|
||||
fixture tree was somewhere else. Here `ROOT` deliberately points away from
|
||||
the tree under lint, which is the case the old code got wrong.
|
||||
"""
|
||||
write_page(
|
||||
kb_dir / "entities/tools/off-menu.md",
|
||||
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
|
||||
"modified": "2026-09-03", "related": [{"contradicts": "Modbus"}], "sources": [],
|
||||
"confidence": 0.8, "provenance": "general", "summary": "A label off this menu."},
|
||||
"\n# off-menu\n\nA label the source collection never authorised.\n",
|
||||
)
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
monkeypatch.setattr(config, "ROOT", elsewhere)
|
||||
assert config.KB_DIR != kb_dir
|
||||
|
||||
report = run_lint(kb_dir)
|
||||
assert {
|
||||
"page": "off-menu", "target": "Modbus", "label": "contradicts", "destination": "concepts"
|
||||
} in report["unauthorised_labels"]
|
||||
|
||||
|
||||
def test_a_tree_that_never_declared_a_kb_version_keeps_every_key(kb_dir):
|
||||
"""No `.wikitool-kb.json` means a fresh instance, which starts at the
|
||||
current shape rather than migrating into it - so there is no outstanding
|
||||
migration for a gated finding to be the noise of."""
|
||||
|
||||
@@ -11,7 +11,7 @@ import typer
|
||||
|
||||
from chemenu import config, kb_state
|
||||
from chemenu.commands import migrate_cmd
|
||||
from chemenu.version import Version
|
||||
from chemenu.version import Version, VersionError
|
||||
|
||||
CHANGES = "# Changelog\n\n---\n\n## 1.0.0 - 2026-08-30 - First\n\nBody.\n"
|
||||
|
||||
@@ -108,6 +108,19 @@ def test_status_lists_the_chain_in_order(instance, capsys):
|
||||
assert [m["migrates_to"] for m in result["pending"]] == ["1.4.0", "1.7.0", "2.0.0"]
|
||||
|
||||
|
||||
def test_status_chain_still_reaches_a_target_matching_a_running_candidate(instance, capsys):
|
||||
"""Regression for finding #3: a migration targeting `2.0.0` must still be
|
||||
owed while `VERSION` is the running candidate `2.0.0-beta.1` - `2.0.0` sorts
|
||||
*above* its own candidate, so comparing against the raw pre-release would
|
||||
drop it out of the chain right when the machinery that owes it installs."""
|
||||
(instance / "VERSION").write_text("2.0.0-beta.1\n", encoding="utf-8")
|
||||
set_kb_version(instance, "1.7.0")
|
||||
migrate_cmd.status_command(json_out=True)
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["stack_version"] == "2.0.0-beta.1"
|
||||
assert [m["migrates_to"] for m in result["pending"]] == ["2.0.0"]
|
||||
|
||||
|
||||
def test_list_reports_every_document_sorted_by_target(instance, capsys):
|
||||
migrate_cmd.list_command(json_out=True)
|
||||
targets = [m["migrates_to"] for m in json.loads(capsys.readouterr().out)]
|
||||
@@ -155,6 +168,16 @@ def test_done_without_a_declared_kb_version_is_refused(instance):
|
||||
# --- baseline --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_read_kb_version_refuses_a_pre_release(instance):
|
||||
"""A content shape has no beta channel - only the stack version does."""
|
||||
set_kb_version(instance, "1.3.1")
|
||||
(instance / kb_state.KB_STATE_FILENAME).write_text(
|
||||
json.dumps({"schema": 1, "kb_version": "1.4.0-beta.1", "applied": []}), encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(VersionError):
|
||||
kb_state.read_kb_version()
|
||||
|
||||
|
||||
def test_baseline_declares_the_version_once(instance):
|
||||
migrate_cmd.baseline_command(version="1.3.1", force=False)
|
||||
assert kb_state.read_kb_version() == Version(1, 3, 1)
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
"""Tests for `wikitool upstream merge`/`upstream verify` - the code procedure
|
||||
that replaces private-instance.md's prose merge script (Gitea #30).
|
||||
|
||||
Two real git repos stand in for a private instance (`repo`, remote name
|
||||
`upstream`) and the public repo it takes updates from (`upstream`, a plain
|
||||
repo committed to directly - a fetch-only remote does not need to be bare for
|
||||
`git fetch` to work against it). Each scenario diverges the two by committing
|
||||
independently on each side, exactly like a real fetch-only upstream would.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
import typer
|
||||
|
||||
from chemenu import config, ownership
|
||||
from chemenu.commands import git_publish, upstream_cmd
|
||||
|
||||
|
||||
def _git(root, *args):
|
||||
result = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return result
|
||||
|
||||
|
||||
def _write(root, relative, content):
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def _commit(root, message):
|
||||
_git(root, "add", "-A")
|
||||
_git(root, "commit", "-m", message)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_repos(tmp_path, monkeypatch):
|
||||
"""`repo`, a private instance, with a fetch-only `upstream` remote pointing
|
||||
at a second, independent repo. Both start from the same seed commit -
|
||||
kb/CONTRACT.md, kb/CONVENTIONS.md(.template), kb/entities/COLLECTION.md,
|
||||
raw/CONTRACT.md, work/CONTRACT.md, reports/CONTRACT.md, and one tools/
|
||||
file - which is what a private instance looks like right after the
|
||||
private-instance.md setup: the tracked machinery, plus its own filled
|
||||
instance files layered on top.
|
||||
"""
|
||||
seed = tmp_path / "seed"
|
||||
seed.mkdir()
|
||||
_git(seed, "init", "-b", "main")
|
||||
_git(seed, "config", "user.name", "Seed")
|
||||
_git(seed, "config", "user.email", "seed@example.com")
|
||||
# .wikitool-remotes.json is gitignored in the real repo (it is per-checkout,
|
||||
# see config.PUBLISH_REMOTES_FILENAME) - without this, dropping one into the
|
||||
# fixture during a test would show up as an untracked file and trip the
|
||||
# dirty-working-tree precondition for a reason that has nothing to do with
|
||||
# what that test is checking.
|
||||
# Mirrors the real .gitignore in the two ways that matter here:
|
||||
# `.wikitool-remotes.json` is per-checkout (dropping one in during a test
|
||||
# must not read as a dirty tree), and `reports/` is derived output that is
|
||||
# ignored except for its contract - which is what makes a content stage
|
||||
# able to hold local, non-recomputable data a merge must not touch.
|
||||
_write(
|
||||
seed,
|
||||
".gitignore",
|
||||
f"/{config.PUBLISH_REMOTES_FILENAME}\n/reports/*\n!/reports/CONTRACT.md\n",
|
||||
)
|
||||
_write(seed, "kb/CONTRACT.md", "stack kb contract v1\n")
|
||||
_write(seed, "kb/CONVENTIONS.md.template", "template v1\n")
|
||||
_write(seed, "kb/CONVENTIONS.md", "instance conventions v1\n")
|
||||
_write(seed, "kb/entities/COLLECTION.md", "instance collection contract v1\n")
|
||||
_write(seed, "kb/Both.md", "page both sides delete\n")
|
||||
_write(seed, "kb/ToDelete.md", "page the instance will delete\n")
|
||||
_write(seed, "kb/RegularPage.md", "an ordinary page neither side has touched yet\n")
|
||||
_write(seed, "raw/CONTRACT.md", "raw contract v1\n")
|
||||
_write(seed, "work/CONTRACT.md", "work contract v1\n")
|
||||
_write(seed, "reports/CONTRACT.md", "reports contract v1\n")
|
||||
_write(seed, "tools/wikitool.py", "line one\nline two\nline three\n")
|
||||
_commit(seed, "seed")
|
||||
|
||||
upstream = tmp_path / "upstream"
|
||||
subprocess.run(["git", "clone", str(seed), str(upstream)], check=True, capture_output=True)
|
||||
_git(upstream, "config", "user.name", "Upstream")
|
||||
_git(upstream, "config", "user.email", "upstream@example.com")
|
||||
|
||||
# Cloned from `upstream`, not from `seed` directly: the remote (renamed
|
||||
# below) must resolve to the path this fixture actually commits new
|
||||
# upstream state into, or a later `git fetch upstream main` silently
|
||||
# fetches from `seed` instead and never sees anything new.
|
||||
repo = tmp_path / "repo"
|
||||
subprocess.run(["git", "clone", str(upstream), str(repo)], check=True, capture_output=True)
|
||||
_git(repo, "config", "user.name", "Test")
|
||||
_git(repo, "config", "user.email", "test@example.com")
|
||||
_git(repo, "remote", "rename", "origin", "upstream")
|
||||
|
||||
monkeypatch.setattr(config, "ROOT", repo)
|
||||
monkeypatch.setenv("WIKITOOL_SESSION_ID", "test-session")
|
||||
return upstream, repo
|
||||
|
||||
|
||||
def _merge(**overrides):
|
||||
kwargs = dict(remote="upstream", branch="main", no_fetch=False)
|
||||
kwargs.update(overrides)
|
||||
upstream_cmd.merge_command(**kwargs)
|
||||
|
||||
|
||||
# --- the four restbefund regressions, plus the baseline table from the issue ---
|
||||
|
||||
|
||||
def test_upstream_edit_of_a_page_the_instance_deleted_does_not_land(two_repos):
|
||||
upstream, repo = two_repos
|
||||
_git(repo, "rm", "-q", "kb/ToDelete.md")
|
||||
_commit(repo, "instance deletes ToDelete")
|
||||
|
||||
_write(upstream, "kb/ToDelete.md", "upstream edited it after the instance deleted it\n")
|
||||
_commit(upstream, "upstream edits ToDelete")
|
||||
|
||||
_merge()
|
||||
|
||||
assert not (repo / "kb/ToDelete.md").exists()
|
||||
|
||||
|
||||
def test_upstream_new_page_does_not_land(two_repos):
|
||||
upstream, repo = two_repos
|
||||
_write(upstream, "kb/NewPage.md", "a demo page the upstream added\n")
|
||||
_commit(upstream, "upstream adds NewPage")
|
||||
|
||||
_merge()
|
||||
|
||||
assert not (repo / "kb/NewPage.md").exists()
|
||||
|
||||
|
||||
def test_page_deleted_on_both_sides_is_a_noop(two_repos):
|
||||
upstream, repo = two_repos
|
||||
_git(repo, "rm", "-q", "kb/Both.md")
|
||||
_commit(repo, "instance deletes Both")
|
||||
_git(upstream, "rm", "-q", "kb/Both.md")
|
||||
_commit(upstream, "upstream deletes Both")
|
||||
|
||||
_merge() # must not raise
|
||||
|
||||
assert not (repo / "kb/Both.md").exists()
|
||||
|
||||
|
||||
def test_kb_contract_change_lands(two_repos):
|
||||
upstream, repo = two_repos
|
||||
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
|
||||
_commit(upstream, "upstream changes kb/CONTRACT.md")
|
||||
|
||||
_merge()
|
||||
|
||||
assert (repo / "kb/CONTRACT.md").read_text(encoding="utf-8") == "stack kb contract v2\n"
|
||||
|
||||
|
||||
def test_conventions_template_change_lands_local_conventions_untouched(two_repos):
|
||||
upstream, repo = two_repos
|
||||
_write(upstream, "kb/CONVENTIONS.md.template", "template v2\n")
|
||||
_commit(upstream, "upstream changes the conventions template")
|
||||
|
||||
_merge()
|
||||
|
||||
assert (repo / "kb/CONVENTIONS.md.template").read_text(encoding="utf-8") == "template v2\n"
|
||||
assert (repo / "kb/CONVENTIONS.md").read_text(encoding="utf-8") == "instance conventions v1\n"
|
||||
|
||||
|
||||
def test_collection_contract_change_does_not_land(two_repos):
|
||||
"""A COLLECTION.md is instance-owned since #39 - one level deeper than
|
||||
`<stage>/CONTRACT.md`, so `is_stack_owned` must say no to it."""
|
||||
upstream, repo = two_repos
|
||||
_write(repo, "kb/entities/COLLECTION.md", "instance collection contract v2 (local)\n")
|
||||
_commit(repo, "instance rewrites its own collection contract")
|
||||
|
||||
_write(upstream, "kb/entities/COLLECTION.md", "upstream collection contract v2\n")
|
||||
_commit(upstream, "upstream changes the default collection contract")
|
||||
|
||||
_merge()
|
||||
|
||||
assert (repo / "kb/entities/COLLECTION.md").read_text(encoding="utf-8") == (
|
||||
"instance collection contract v2 (local)\n"
|
||||
)
|
||||
|
||||
|
||||
def test_upstream_deletion_of_a_contract_file_lands(two_repos):
|
||||
"""Restbefund 2: a machinery file the upstream deleted must not silently
|
||||
survive because `git checkout MERGE_HEAD -- <path>` has nothing to check
|
||||
out."""
|
||||
upstream, repo = two_repos
|
||||
_git(upstream, "rm", "-q", "raw/CONTRACT.md")
|
||||
_commit(upstream, "upstream drops raw/CONTRACT.md")
|
||||
|
||||
_merge()
|
||||
|
||||
assert not (repo / "raw/CONTRACT.md").exists()
|
||||
|
||||
|
||||
def test_new_stack_template_under_a_content_stage_lands(two_repos):
|
||||
"""Restbefund 4: a brand-new stack-owned path the local tree has never
|
||||
seen must still be recognised by the predicate, not by a literal list."""
|
||||
upstream, repo = two_repos
|
||||
_write(upstream, "kb/GLOSSARY.md.template", "a stack-owned template that never existed before\n")
|
||||
_commit(upstream, "upstream adds a new template")
|
||||
|
||||
_merge()
|
||||
|
||||
assert (repo / "kb/GLOSSARY.md.template").read_text(encoding="utf-8") == (
|
||||
"a stack-owned template that never existed before\n"
|
||||
)
|
||||
|
||||
|
||||
def test_open_workshop_run_files_do_not_land(two_repos):
|
||||
upstream, repo = two_repos
|
||||
_write(upstream, "work/some-run/README.md", "an in-progress workshop run\n")
|
||||
_commit(upstream, "upstream ships an open work/ run")
|
||||
|
||||
_merge()
|
||||
|
||||
assert not (repo / "work/some-run").exists()
|
||||
|
||||
|
||||
def test_merge_keeps_ignored_local_data_under_a_content_stage(two_repos):
|
||||
"""`reports/` is gitignored except its contract, so a content stage's
|
||||
working tree holds local data that is in no git tree and is not
|
||||
recomputable - the telemetry traces `eval score` reads, saved eval
|
||||
reports, past lint reports. Forcing the stage back to the local side must
|
||||
not take those out as collateral: this instance had 497 trace directories
|
||||
under reports/telemetry/ when the first version of this command wiped the
|
||||
stage wholesale."""
|
||||
upstream, repo = two_repos
|
||||
_write(repo, "reports/telemetry/session-a/trace.jsonl", '{"event": "local"}\n')
|
||||
_write(repo, "reports/Lint Report 2026-09-04.md", "a local lint report\n")
|
||||
assert _git(repo, "status", "--porcelain").stdout == "" # ignored, so the tree is clean
|
||||
|
||||
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
|
||||
_commit(upstream, "upstream changes kb/CONTRACT.md")
|
||||
|
||||
_merge()
|
||||
|
||||
assert (repo / "reports/telemetry/session-a/trace.jsonl").read_text(encoding="utf-8") == (
|
||||
'{"event": "local"}\n'
|
||||
)
|
||||
assert (repo / "reports/Lint Report 2026-09-04.md").exists()
|
||||
assert (repo / "reports/CONTRACT.md").read_text(encoding="utf-8") == "reports contract v1\n"
|
||||
|
||||
|
||||
def test_upstream_content_under_a_stage_absent_from_head_does_not_land(two_repos):
|
||||
"""The stage guard must not rest on the local side happening to track
|
||||
something under that stage: an instance whose `work/` holds no tracked
|
||||
file at all must still not receive the upstream's open run."""
|
||||
upstream, repo = two_repos
|
||||
_git(repo, "rm", "-q", "work/CONTRACT.md")
|
||||
_commit(repo, "instance has nothing tracked under work/")
|
||||
|
||||
_write(upstream, "work/some-run/README.md", "an in-progress workshop run\n")
|
||||
_commit(upstream, "upstream ships an open work/ run")
|
||||
|
||||
_merge()
|
||||
|
||||
assert not (repo / "work/some-run").exists()
|
||||
|
||||
|
||||
def test_one_upstream_commit_mixing_every_case_at_once(two_repos):
|
||||
"""The acceptance test from the issue: a single upstream commit that edits
|
||||
a page the instance deleted, adds a new page, deletes an untouched page,
|
||||
changes a stack contract, changes a template, and deletes a different
|
||||
stack contract - all at once, all restored or discarded correctly by one
|
||||
`upstream merge` call."""
|
||||
upstream, repo = two_repos
|
||||
_git(repo, "rm", "-q", "kb/ToDelete.md")
|
||||
_commit(repo, "instance deletes ToDelete")
|
||||
|
||||
_write(upstream, "kb/ToDelete.md", "upstream edited it after the instance deleted it\n")
|
||||
_write(upstream, "kb/BrandNewPage.md", "a demo page the upstream added\n")
|
||||
_git(upstream, "rm", "-q", "kb/RegularPage.md")
|
||||
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
|
||||
_write(upstream, "kb/CONVENTIONS.md.template", "template v2\n")
|
||||
_git(upstream, "rm", "-q", "raw/CONTRACT.md")
|
||||
_commit(upstream, "one upstream commit: edit + add + delete + contract + template + contract-delete")
|
||||
|
||||
_merge()
|
||||
|
||||
assert not (repo / "kb/ToDelete.md").exists()
|
||||
assert not (repo / "kb/BrandNewPage.md").exists()
|
||||
assert (repo / "kb/RegularPage.md").exists()
|
||||
assert (repo / "kb/CONTRACT.md").read_text(encoding="utf-8") == "stack kb contract v2\n"
|
||||
assert (repo / "kb/CONVENTIONS.md.template").read_text(encoding="utf-8") == "template v2\n"
|
||||
assert (repo / "kb/CONVENTIONS.md").read_text(encoding="utf-8") == "instance conventions v1\n"
|
||||
assert not (repo / "raw/CONTRACT.md").exists()
|
||||
|
||||
|
||||
def test_real_conflict_in_tools_leaves_the_merge_open(two_repos):
|
||||
upstream, repo = two_repos
|
||||
|
||||
_write(repo, "tools/wikitool.py", "line one\nLOCAL CHANGE\nline three\n")
|
||||
_commit(repo, "local edits tools/wikitool.py")
|
||||
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
_write(upstream, "tools/wikitool.py", "line one\nUPSTREAM CHANGE\nline three\n")
|
||||
_commit(upstream, "upstream edits the same line")
|
||||
|
||||
with pytest.raises(typer.Exit) as excinfo:
|
||||
_merge()
|
||||
assert excinfo.value.exit_code == 1
|
||||
|
||||
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
|
||||
assert (repo / ".git" / "MERGE_HEAD").exists()
|
||||
|
||||
|
||||
def test_a_merge_git_refuses_to_open_deletes_nothing(two_repos, tmp_path):
|
||||
"""The failure mode with the worst blast radius if it is not guarded:
|
||||
without MERGE_HEAD, every stack-owned path in HEAD reads as "the upstream
|
||||
deleted it", and the restore loop would remove kb/CONTRACT.md,
|
||||
raw/CONTRACT.md and every template. A merge git refuses to start must stop
|
||||
before that, with the tree untouched."""
|
||||
upstream, repo = two_repos
|
||||
unrelated = tmp_path / "unrelated"
|
||||
unrelated.mkdir()
|
||||
_git(unrelated, "init", "-b", "main")
|
||||
_git(unrelated, "config", "user.name", "Unrelated")
|
||||
_git(unrelated, "config", "user.email", "unrelated@example.com")
|
||||
_write(unrelated, "somefile.md", "no shared history with the instance\n")
|
||||
_commit(unrelated, "unrelated root commit")
|
||||
|
||||
_git(repo, "remote", "set-url", "upstream", str(unrelated))
|
||||
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
with pytest.raises(typer.Exit) as excinfo:
|
||||
_merge()
|
||||
assert excinfo.value.exit_code == 1
|
||||
|
||||
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
|
||||
assert (repo / "kb/CONTRACT.md").read_text(encoding="utf-8") == "stack kb contract v1\n"
|
||||
assert (repo / "raw/CONTRACT.md").exists()
|
||||
assert (repo / "kb/CONVENTIONS.md.template").exists()
|
||||
assert _git(repo, "status", "--porcelain").stdout == ""
|
||||
|
||||
|
||||
def test_success_message_reports_what_changed_not_what_was_restored(two_repos, capsys):
|
||||
"""Restoring every stack-owned path from MERGE_HEAD touches all of them
|
||||
whether or not the upstream moved any, so the report has to ask git what
|
||||
changed - otherwise a one-file update is announced as five."""
|
||||
upstream, repo = two_repos
|
||||
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
|
||||
_commit(upstream, "upstream changes exactly one stack path")
|
||||
|
||||
_merge()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Stack paths changed (1)" in out
|
||||
assert "kb/CONTRACT.md" in out
|
||||
assert "kb/CONVENTIONS.md.template" not in out
|
||||
|
||||
|
||||
def test_dirty_working_tree_is_refused_untouched(two_repos):
|
||||
upstream, repo = two_repos
|
||||
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||
(repo / "kb/CONTRACT.md").write_text("uncommitted local edit\n", encoding="utf-8")
|
||||
|
||||
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
|
||||
_commit(upstream, "upstream changes kb/CONTRACT.md")
|
||||
|
||||
with pytest.raises(typer.Exit) as excinfo:
|
||||
_merge()
|
||||
assert excinfo.value.exit_code == 1
|
||||
|
||||
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
|
||||
assert (repo / "kb/CONTRACT.md").read_text(encoding="utf-8") == "uncommitted local edit\n"
|
||||
|
||||
|
||||
def test_already_up_to_date_is_a_noop(two_repos):
|
||||
upstream, repo = two_repos
|
||||
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
_merge() # nothing new upstream at all
|
||||
|
||||
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
|
||||
|
||||
|
||||
def test_merge_warns_when_the_publish_remote_gate_is_unarmed(two_repos, capsys):
|
||||
upstream, repo = two_repos
|
||||
assert git_publish.read_allowed_push_urls() is None # no .wikitool-remotes.json in this repo
|
||||
|
||||
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
|
||||
_commit(upstream, "upstream changes kb/CONTRACT.md")
|
||||
|
||||
_merge()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "WARN" in captured.out
|
||||
assert ".wikitool-remotes.json" in captured.out
|
||||
|
||||
|
||||
def test_merge_stays_silent_when_the_publish_remote_gate_is_armed(two_repos, capsys):
|
||||
upstream, repo = two_repos
|
||||
(repo / config.PUBLISH_REMOTES_FILENAME).write_text(
|
||||
'{"schema": 1, "allowed_push_urls": ["ssh://example/test.git"]}\n', encoding="utf-8"
|
||||
)
|
||||
|
||||
_write(upstream, "kb/CONTRACT.md", "stack kb contract v2\n")
|
||||
_commit(upstream, "upstream changes kb/CONTRACT.md")
|
||||
|
||||
_merge()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "WARN" not in captured.out
|
||||
|
||||
|
||||
def test_dist_cmd_contract_only_stages_agree_with_ownership(two_repos):
|
||||
"""Consistency guard for the ownership refactor: `dist_cmd`'s own list of
|
||||
stage-contract paths and `ownership.is_stack_owned` must not be able to
|
||||
name a different set of stages - both are sourced from
|
||||
`ownership.CONTENT_STAGES` now, so a stage added to one and not the other
|
||||
fails this rather than only surfacing in a real merge."""
|
||||
from chemenu.commands import dist_cmd
|
||||
|
||||
assert dist_cmd.CONTRACT_ONLY_STAGES # sanity: the derivation still yields entries
|
||||
for relative in dist_cmd.CONTRACT_ONLY_STAGES:
|
||||
assert ownership.is_stack_owned(relative)
|
||||
|
||||
|
||||
# --- upstream verify --------------------------------------------------------
|
||||
|
||||
|
||||
def test_verify_is_clean_on_a_stack_owned_only_change(two_repos):
|
||||
upstream, repo = two_repos
|
||||
since = _git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
_write(repo, "kb/CONTRACT.md", "stack kb contract v2\n")
|
||||
_commit(repo, "advance kb/CONTRACT.md")
|
||||
|
||||
upstream_cmd.verify_command(since=since, until="HEAD") # must not raise
|
||||
|
||||
|
||||
def test_verify_fails_on_a_hand_botched_merge(two_repos, capsys):
|
||||
upstream, repo = two_repos
|
||||
since = _git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
_write(repo, "kb/SneakedIn.md", "content that arrived outside a stack-owned path\n")
|
||||
_commit(repo, "a hand-resolved merge that let content through")
|
||||
|
||||
with pytest.raises(typer.Exit) as excinfo:
|
||||
upstream_cmd.verify_command(since=since, until="HEAD")
|
||||
assert excinfo.value.exit_code == 1
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "kb/SneakedIn.md" in captured.out
|
||||
|
||||
|
||||
# --- ownership predicate, exercised directly ---------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"relative,expected",
|
||||
[
|
||||
("kb/CONTRACT.md", True),
|
||||
("raw/CONTRACT.md", True),
|
||||
("work/CONTRACT.md", True),
|
||||
("reports/CONTRACT.md", True),
|
||||
("kb/CONVENTIONS.md.template", True),
|
||||
("kb/entities/COLLECTION.md.template", True),
|
||||
("kb/GLOSSARY.md.template", True),
|
||||
("kb/CONVENTIONS.md", False),
|
||||
("kb/entities/COLLECTION.md", False),
|
||||
("kb/concepts/Some Page.md", False),
|
||||
("raw/notes/x.md", False),
|
||||
("tools/CONTRACT.md", False), # not a content stage
|
||||
("kb/log.md", False), # export stub, not stack-owned
|
||||
],
|
||||
)
|
||||
def test_is_stack_owned(relative, expected):
|
||||
assert ownership.is_stack_owned(relative) == expected
|
||||
@@ -83,6 +83,86 @@ def test_compare_separates_a_compatible_update_from_a_migration(local, latest, s
|
||||
assert version_mod.compare(Version.parse(local), Version.parse(latest)) == state
|
||||
|
||||
|
||||
# --- candidates: parsing and ordering ---------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["4.4.0-beta.1", "4.4.0-beta.10", "v4.4.0-beta.2"])
|
||||
def test_parse_accepts_a_candidate_suffix(text):
|
||||
version = Version.parse(text)
|
||||
assert version.is_prerelease
|
||||
assert version.beta == int(text.rsplit(".", 1)[1])
|
||||
|
||||
|
||||
def test_a_release_has_no_beta():
|
||||
version = Version.parse("4.4.0")
|
||||
assert not version.is_prerelease
|
||||
assert version.beta is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"lesser,greater",
|
||||
[
|
||||
("4.4.0-beta.1", "4.4.0"),
|
||||
("4.4.0-beta.1", "4.4.0-beta.2"),
|
||||
("4.4.0-beta.9", "4.4.0-beta.10"), # numeric, not lexicographic
|
||||
("4.4.0-beta.9", "4.4.1"),
|
||||
],
|
||||
)
|
||||
def test_a_candidate_sorts_before_its_release_and_by_numeric_beta(lesser, greater):
|
||||
assert Version.parse(lesser) < Version.parse(greater)
|
||||
assert Version.parse(greater) > Version.parse(lesser)
|
||||
|
||||
|
||||
def test_base_strips_the_candidate_suffix():
|
||||
assert str(Version.parse("4.4.0-beta.3").base) == "4.4.0"
|
||||
assert Version.parse("4.4.0").base == Version.parse("4.4.0")
|
||||
|
||||
|
||||
def test_bumped_always_returns_a_release_even_from_a_candidate():
|
||||
"""`bumped()` answers "what would the next fixed version be" - it is
|
||||
`escalate()` that knows about running candidates."""
|
||||
assert not Version.parse("4.4.0-beta.3").bumped("patch").is_prerelease
|
||||
|
||||
|
||||
# --- candidates: escalation --------------------------------------------------
|
||||
|
||||
|
||||
def test_escalate_opens_the_first_candidate_at_beta_one():
|
||||
release = Version.parse("4.3.3")
|
||||
candidate = version_mod.escalate(release, release, "minor")
|
||||
assert str(candidate) == "4.4.0-beta.1"
|
||||
|
||||
|
||||
def test_escalate_on_the_same_stage_only_advances_the_bump_count():
|
||||
release = Version.parse("4.3.3")
|
||||
first = version_mod.escalate(release, release, "minor")
|
||||
second = version_mod.escalate(release, first, "patch")
|
||||
assert str(second) == "4.4.0-beta.2"
|
||||
|
||||
|
||||
def test_escalate_never_steps_back_down():
|
||||
release = Version.parse("1.4.0")
|
||||
major = version_mod.escalate(release, release, "major")
|
||||
still_major = version_mod.escalate(release, major, "patch")
|
||||
assert still_major.base == major.base
|
||||
assert still_major.beta == 2
|
||||
|
||||
|
||||
def test_escalate_raises_the_base_and_resets_the_bump_count():
|
||||
release = Version.parse("4.3.3")
|
||||
minor = version_mod.escalate(release, release, "minor")
|
||||
major = version_mod.escalate(release, minor, "major")
|
||||
assert str(major) == "5.0.0-beta.1"
|
||||
|
||||
|
||||
def test_escalate_with_no_last_release_bumps_the_current_version_directly():
|
||||
"""The fresh-distribution edge case: a changelog with no versioned entry at
|
||||
all opens a candidate straight from `current`, rather than failing."""
|
||||
fresh = Version.parse("0.1.0")
|
||||
candidate = version_mod.escalate(None, fresh, "patch")
|
||||
assert str(candidate) == "0.1.1-beta.1"
|
||||
|
||||
|
||||
def test_a_migration_headline_says_so_rather_than_just_being_louder():
|
||||
status = version_mod.UpdateStatus(Version(0, 1, 0), Version(0, 2, 0), "migration")
|
||||
assert "migration" in status.headline.lower()
|
||||
@@ -167,14 +247,75 @@ def test_changes_section_is_none_for_an_undocumented_version():
|
||||
assert version_mod.changes_section(CHANGES_HEADER, Version(9, 9, 9)) is None
|
||||
|
||||
|
||||
def test_insert_changes_entry_lands_above_the_newest_entry():
|
||||
def test_last_release_skips_an_open_candidate_above_it():
|
||||
text = (
|
||||
CHANGES_HEADER
|
||||
+ "## 0.2.0-beta.1 - 2026-09-04 - Candidate\n\nBody.\n\n---\n\n"
|
||||
+ "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
|
||||
)
|
||||
assert version_mod.last_release(text) == Version(0, 1, 0)
|
||||
|
||||
|
||||
def test_last_release_is_none_with_no_versioned_entry_at_all():
|
||||
text = CHANGES_HEADER + "## 2026-08-01 - Before versioning\n\nBody.\n"
|
||||
assert version_mod.last_release(text) is None
|
||||
|
||||
|
||||
def test_insert_changes_entry_opens_a_fresh_candidate_above_the_newest_entry():
|
||||
text = CHANGES_HEADER + "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
|
||||
result = version_mod.insert_changes_entry(
|
||||
text, Version(0, 2, 0), "2026-09-01", "Newer", "Someone"
|
||||
text, Version(0, 2, 0, beta=1), "2026-09-01", "Newer", "Someone"
|
||||
)
|
||||
assert result.index("## 0.2.0") < result.index("## 0.1.0")
|
||||
assert result.index("## 0.2.0-beta.1") < result.index("## 0.1.0")
|
||||
assert "Preamble." in result
|
||||
assert version_mod.top_changes_version(result) == Version(0, 2, 0)
|
||||
assert "- Newer" in result # the bump-title list seeds itself with this title
|
||||
assert version_mod.top_changes_version(result) == Version(0, 2, 0, beta=1)
|
||||
|
||||
|
||||
def test_insert_changes_entry_updates_an_open_candidate_in_place():
|
||||
"""The second bump of the same candidate must not open a second entry -
|
||||
one entry per running candidate, per instructions/dev/version-parts.md."""
|
||||
text = CHANGES_HEADER + "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
|
||||
first = version_mod.insert_changes_entry(
|
||||
text, Version(0, 2, 0, beta=1), "2026-09-01", "First title", "Someone"
|
||||
)
|
||||
second = version_mod.insert_changes_entry(
|
||||
first, Version(0, 2, 0, beta=2), "2026-09-02", "Second title", "Someone"
|
||||
)
|
||||
assert second.count("## 0.2.0") == 1
|
||||
assert "## 0.2.0-beta.2 - 2026-09-02 - Second title" in second
|
||||
assert "- First title" in second
|
||||
assert "- Second title" in second
|
||||
assert "## 0.1.0" in second # the older, already-released entry survives untouched
|
||||
|
||||
|
||||
def test_insert_changes_entry_keeps_the_breaking_line_across_a_later_bump():
|
||||
text = CHANGES_HEADER + "## 1.4.0 - 2026-08-29 - Older\n\nBody.\n"
|
||||
first = version_mod.insert_changes_entry(
|
||||
text, Version(2, 0, 0, beta=1), "2026-09-01", "Breaking bump", "Someone",
|
||||
breaking_reason="the feed moved", no_migration_reason="kb untouched",
|
||||
)
|
||||
second = version_mod.insert_changes_entry(
|
||||
first, Version(2, 0, 0, beta=2), "2026-09-02", "Follow-up", "Someone",
|
||||
)
|
||||
assert version_mod.BREAKING_CHANGE_MARKER in second
|
||||
assert "the feed moved" in second
|
||||
assert version_mod.MIGRATION_NONE_MARKER in second
|
||||
assert "kb untouched" in second
|
||||
|
||||
|
||||
def test_release_entry_fixes_the_heading_and_keeps_the_bump_titles():
|
||||
text = CHANGES_HEADER + "## 0.2.0-beta.2 - 2026-09-02 - Second title\n\n**Author:** Someone\n\n<!-- wikitool:bumps -->\n- First title\n- Second title\n<!-- /wikitool:bumps -->\n\nBody.\n"
|
||||
released = version_mod.release_entry(text, "2026-09-05")
|
||||
assert "## 0.2.0 - 2026-09-05 - Second title" in released
|
||||
assert "- First title" in released
|
||||
assert "- Second title" in released
|
||||
|
||||
|
||||
def test_release_entry_can_replace_the_title():
|
||||
text = CHANGES_HEADER + "## 0.2.0-beta.2 - 2026-09-02 - Second title\n\n**Author:** Someone\n\nBody.\n"
|
||||
released = version_mod.release_entry(text, "2026-09-05", title="Summarising title")
|
||||
assert "## 0.2.0 - 2026-09-05 - Summarising title" in released
|
||||
|
||||
|
||||
# --- version bump ----------------------------------------------------------
|
||||
@@ -185,13 +326,28 @@ def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
|
||||
major=False, minor=True, patch=False, title="Something happened",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
assert "## 1.1.0 - " in changes
|
||||
assert "## 1.1.0-beta.1 - " in changes
|
||||
assert "Something happened" in changes
|
||||
assert "**Author:** Test Author" in changes
|
||||
|
||||
|
||||
def test_a_second_bump_continues_the_same_candidate_instead_of_opening_another(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Second",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.2"
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
assert changes.count("## 1.1.0") == 1
|
||||
assert "First" in changes and "Second" in changes
|
||||
|
||||
|
||||
def test_bump_dry_run_writes_nothing(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Nope", breaking=None, no_migration=None, dry_run=True
|
||||
@@ -219,9 +375,10 @@ def test_bump_refuses_an_empty_title(tree):
|
||||
)
|
||||
|
||||
|
||||
def test_bump_refuses_when_the_changelog_is_already_ahead(tree):
|
||||
"""A changelog documenting a version the tree has not reached means
|
||||
someone edited one of the two by hand; bumping past it would hide that."""
|
||||
def test_bump_refuses_when_version_and_changelog_disagree(tree):
|
||||
"""A changelog whose newest entry names a different version than VERSION
|
||||
means someone edited one of the two by hand; bumping past it would hide
|
||||
that instead of surfacing it."""
|
||||
(tree / "CHANGES.md").write_text(
|
||||
CHANGES_HEADER + "## 1.5.0 - 2026-09-01 - Ahead\n\nBody.\n", encoding="utf-8"
|
||||
)
|
||||
@@ -258,7 +415,31 @@ def test_a_boundary_crossing_bump_passes_with_a_migration_document(tree):
|
||||
major=True, minor=False, patch=False, title="Breaking",
|
||||
breaking="every page is retyped", no_migration=None, dry_run=False,
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0"
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.1"
|
||||
|
||||
|
||||
def test_a_follow_up_bump_at_the_same_stage_need_not_repeat_breaking_or_migration(tree):
|
||||
"""Finding #4: the requirement fires once, at the bump that first escalates
|
||||
to the boundary; a later bump of the same candidate is not asked again."""
|
||||
migrations = tree / "instructions" / "migrations"
|
||||
(migrations / "2.0.0-retype.md").write_text(
|
||||
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
|
||||
"description: Retype every page.\nmanual: true\n"
|
||||
"migrates_to: 2.0.0\nmigration_kind: assisted\n---\n\n# M\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Breaking",
|
||||
breaking="every page is retyped", no_migration=None, dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Follow-up",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.2"
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
assert version_mod.BREAKING_CHANGE_MARKER in changes
|
||||
assert "every page is retyped" in changes
|
||||
|
||||
|
||||
def test_no_migration_records_the_reason_in_the_changelog(tree):
|
||||
@@ -321,6 +502,65 @@ def test_breaking_is_refused_on_a_compatible_bump(tree):
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||
|
||||
|
||||
# --- version release --------------------------------------------------------
|
||||
|
||||
|
||||
def test_release_fixes_version_and_the_changelog_heading(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
version_cmd.release_command(title=None, dry_run=False)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
assert "## 1.1.0 - " in changes
|
||||
assert "-beta." not in changes.split("## 1.1.0")[1].split("## ")[0]
|
||||
assert "First bump" in changes # kept, since --title was not given
|
||||
|
||||
|
||||
def test_release_can_replace_the_title(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Second bump",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
version_cmd.release_command(title="Summary of both bumps", dry_run=False)
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
assert "## 1.1.0 - " in changes
|
||||
assert "Summary of both bumps" in changes
|
||||
# the machine-managed bump list is left as the record of what happened
|
||||
assert "First bump" in changes
|
||||
assert "Second bump" in changes
|
||||
|
||||
|
||||
def test_release_dry_run_writes_nothing(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
version_cmd.release_command(title=None, dry_run=True)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
|
||||
|
||||
|
||||
def test_release_refuses_when_version_is_already_a_release(tree):
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.release_command(title=None, dry_run=False)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||
|
||||
|
||||
def test_release_refuses_when_version_and_changelog_disagree(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
(tree / "VERSION").write_text("9.9.9-beta.1\n", encoding="utf-8")
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.release_command(title=None, dry_run=False)
|
||||
|
||||
|
||||
# --- version notes ---------------------------------------------------------
|
||||
|
||||
|
||||
@@ -329,6 +569,21 @@ def test_notes_prints_the_entry_for_the_current_version(tree, capsys):
|
||||
assert "## 1.0.0" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_notes_prints_a_running_candidates_full_entry(tree, capsys):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Second bump",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
version_cmd.notes_command(version=None)
|
||||
out = capsys.readouterr().out
|
||||
assert "## 1.1.0-beta.2" in out
|
||||
assert "First bump" in out and "Second bump" in out
|
||||
|
||||
|
||||
def test_notes_fails_for_a_version_with_no_entry(tree):
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.notes_command(version="9.9.9")
|
||||
|
||||
+251
-18
@@ -33,6 +33,7 @@ because the tests (and `dist export`'s own fixtures) relocate the root.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -42,7 +43,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from chemenu import config
|
||||
from chemenu import blocks, config
|
||||
|
||||
VERSION_FILENAME = "VERSION"
|
||||
CHANGES_FILENAME = "CHANGES.md"
|
||||
@@ -65,12 +66,24 @@ UPDATE_URL_ENV = "WIKITOOL_UPDATE_URL"
|
||||
UPDATE_TOKEN_ENV = "WIKITOOL_UPDATE_TOKEN"
|
||||
|
||||
PARTS = ("major", "minor", "patch")
|
||||
_STAGE_RANK = {"patch": 0, "minor": 1, "major": 2}
|
||||
|
||||
# Plain `x.y.z` only: no `-rc1`, no `+build`. Pre-release channels would mean a
|
||||
# second ordering rule everywhere a version is compared - the release feed, the
|
||||
# migration chain, the compatibility check - to serve a workflow this stack does
|
||||
# not have.
|
||||
_SEMVER_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)\s*$")
|
||||
# `x.y.z`, optionally followed by exactly one pre-release channel: `-beta.<n>`.
|
||||
# Deliberately not a general SemVer pre-release alphabet - one channel keeps the
|
||||
# ordering numeric and total. See "Candidates and releases" below.
|
||||
_SEMVER_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?\s*$")
|
||||
|
||||
# The marker pair `bumps` inside a CHANGES.md entry: the machine-managed list of
|
||||
# every `--title` a candidate has collected across its bumps. Reuses
|
||||
# `blocks.open_marker`/`close_marker` (the same delimiter convention as a page
|
||||
# body's generated regions) but is **not** added to `blocks.BLOCKS` - that tuple
|
||||
# feeds `xref`, `cite` and the `unbalanced_markers` lint check, all of which are
|
||||
# about a page's body, and `CHANGES.md` is not a page. The region itself, and
|
||||
# its rendering, belong here instead.
|
||||
BUMPS_BLOCK_NAME = "bumps"
|
||||
_BUMPS_OPEN = blocks.open_marker(BUMPS_BLOCK_NAME)
|
||||
_BUMPS_CLOSE = blocks.close_marker(BUMPS_BLOCK_NAME)
|
||||
_BUMPS_RE = re.compile(re.escape(_BUMPS_OPEN) + r"(.*?)" + re.escape(_BUMPS_CLOSE), re.DOTALL)
|
||||
|
||||
# Written into a CHANGES.md entry whose version crosses a compatibility
|
||||
# boundary that needs no content migration. `docs verify` accepts it in place
|
||||
@@ -84,7 +97,7 @@ BREAKING_CHANGE_MARKER = "**Breaking Change:**"
|
||||
# A changelog entry that names a version. Entries predating versioning start
|
||||
# with a date instead and are deliberately not matched - they are history, not
|
||||
# a claim about which version the tree is.
|
||||
_CHANGES_ENTRY_RE = re.compile(r"^## (\d+\.\d+\.\d+)(?: - (.*))?$", re.MULTILINE)
|
||||
_CHANGES_ENTRY_RE = re.compile(r"^## (\d+\.\d+\.\d+(?:-beta\.\d+)?)(?: - (.*))?$", re.MULTILINE)
|
||||
|
||||
|
||||
class VersionError(ValueError):
|
||||
@@ -92,11 +105,34 @@ class VersionError(ValueError):
|
||||
written to be shown to the user verbatim."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
@functools.total_ordering
|
||||
@dataclass(frozen=True)
|
||||
class Version:
|
||||
"""A stack version: `MAJOR.MINOR.PATCH`, optionally a running candidate
|
||||
(`-beta.N`) between two releases.
|
||||
|
||||
**Candidates and releases.** Between two releases the stack carries at
|
||||
most one running candidate rather than a fresh number per `bump` - see
|
||||
`instructions/dev/version-parts.md`. `VERSION` holds either a release
|
||||
(`beta is None`) or a candidate (`beta` is the bump count since the
|
||||
candidate's base was last raised). `base` strips the suffix; `bumped()`
|
||||
always returns a release-shaped `Version`, because it answers "what would
|
||||
the *next fixed* version be", never "what candidate comes next" - that
|
||||
answer needs `escalate()`, which also knows the last release to escalate
|
||||
against.
|
||||
|
||||
**Ordering** is `(major, minor, patch, released, beta)`, `released` sorting
|
||||
a real release after every candidate that shares its base - `4.4.0-beta.1
|
||||
< 4.4.0`. `order=True` on the dataclass cannot express this: `None` and
|
||||
`int` do not compare, and the ordering is inverted relative to field
|
||||
declaration order anyway. `functools.total_ordering` plus an explicit
|
||||
`__lt__` is the direct way to say what the ordering actually is.
|
||||
"""
|
||||
|
||||
major: int
|
||||
minor: int
|
||||
patch: int
|
||||
beta: Optional[int] = None
|
||||
|
||||
@classmethod
|
||||
def parse(cls, text: str) -> "Version":
|
||||
@@ -104,11 +140,32 @@ class Version:
|
||||
if not match:
|
||||
raise VersionError(
|
||||
f"{text.strip()!r} is not a semantic version - expected MAJOR.MINOR.PATCH "
|
||||
"or MAJOR.MINOR.PATCH-beta.N"
|
||||
)
|
||||
return cls(int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
||||
beta = int(match.group(4)) if match.group(4) is not None else None
|
||||
return cls(int(match.group(1)), int(match.group(2)), int(match.group(3)), beta)
|
||||
|
||||
def __str__(self) -> str: # noqa: D105 - obvious
|
||||
return f"{self.major}.{self.minor}.{self.patch}"
|
||||
suffix = f"-beta.{self.beta}" if self.beta is not None else ""
|
||||
return f"{self.major}.{self.minor}.{self.patch}{suffix}"
|
||||
|
||||
def _sort_key(self) -> tuple[int, int, int, int, int]:
|
||||
return (self.major, self.minor, self.patch, 0 if self.is_prerelease else 1, self.beta or 0)
|
||||
|
||||
def __lt__(self, other: "Version") -> bool:
|
||||
if not isinstance(other, Version):
|
||||
return NotImplemented
|
||||
return self._sort_key() < other._sort_key()
|
||||
|
||||
@property
|
||||
def is_prerelease(self) -> bool:
|
||||
return self.beta is not None
|
||||
|
||||
@property
|
||||
def base(self) -> "Version":
|
||||
"""This version with any candidate suffix stripped - what it would be
|
||||
once fixed. A no-op on a version that is already a release."""
|
||||
return Version(self.major, self.minor, self.patch)
|
||||
|
||||
def bumped(self, part: str) -> "Version":
|
||||
if part == "major":
|
||||
@@ -127,6 +184,10 @@ class Version:
|
||||
`0.1.9` share `(0, 1)`; `0.2.0` does not. An all-zero version has no
|
||||
non-zero component, so it compares by all three - during `0.0.x`
|
||||
every release is a breaking one, which is what that range means.
|
||||
|
||||
Computed over major/minor/patch alone, i.e. over the **base**: a
|
||||
candidate's pre-release suffix carries no compatibility information of
|
||||
its own, it is the base that will be released that does.
|
||||
"""
|
||||
components = (self.major, self.minor, self.patch)
|
||||
for index, component in enumerate(components):
|
||||
@@ -135,6 +196,44 @@ class Version:
|
||||
return components
|
||||
|
||||
|
||||
def _stage_between(reference: Version, base: Version) -> Optional[str]:
|
||||
"""Which part `base` has escalated past `reference` on, or None if equal.
|
||||
|
||||
Both are release-shaped (no beta): `reference` is the last real release,
|
||||
`base` is a candidate's base. Exactly one of major/minor/patch differs,
|
||||
because `bumped()` always resets everything to the right of the part it
|
||||
raises - so the leftmost differing component *is* the stage.
|
||||
"""
|
||||
for part in PARTS:
|
||||
if getattr(reference, part) != getattr(base, part):
|
||||
return part
|
||||
return None
|
||||
|
||||
|
||||
def escalate(last_release: Optional[Version], current: Version, part: str) -> Version:
|
||||
"""The next candidate: `current` escalated by `part` against `last_release`,
|
||||
max-wins.
|
||||
|
||||
A running candidate never steps back down: bumping `--patch` on a MINOR
|
||||
candidate only advances its bump count (`beta`), it does not lower the
|
||||
base. `last_release=None` is the fresh-distribution edge case - a
|
||||
changelog with no versioned entry at all - where there is nothing to
|
||||
escalate against, so the candidate's base is simply `current` bumped by
|
||||
`part`; see instructions/dev/version-parts.md for why that is not an
|
||||
error.
|
||||
"""
|
||||
if part not in _STAGE_RANK:
|
||||
raise VersionError(f"unknown version part {part!r} - expected one of {', '.join(PARTS)}")
|
||||
reference = last_release if last_release is not None else (
|
||||
current.base if current.is_prerelease else current
|
||||
)
|
||||
old_stage = _stage_between(reference, current.base) if current.is_prerelease else None
|
||||
new_stage = part if old_stage is None else max(old_stage, part, key=_STAGE_RANK.get)
|
||||
new_base = reference.bumped(new_stage)
|
||||
new_beta = (current.beta + 1) if (current.is_prerelease and current.base == new_base) else 1
|
||||
return Version(new_base.major, new_base.minor, new_base.patch, new_beta)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpdateStatus:
|
||||
"""The answer `version check` reports. `state` is the actionable part:
|
||||
@@ -323,6 +422,23 @@ def top_changes_version(text: str) -> Optional[Version]:
|
||||
return Version.parse(match.group(1))
|
||||
|
||||
|
||||
def last_release(text: str) -> Optional[Version]:
|
||||
"""The newest entry that is a **release**, not a running candidate, or
|
||||
`None` if the changelog names no release at all yet.
|
||||
|
||||
Entries are inserted newest-first (see `insert_changes_entry`), so the
|
||||
first non-pre-release heading found scanning top-down is the last release
|
||||
- whether or not the very top entry is an open candidate sitting above it.
|
||||
A changelog with no versioned entry (a fresh distribution) answers `None`,
|
||||
which `escalate()` treats as its own edge case rather than an error.
|
||||
"""
|
||||
for match in _CHANGES_ENTRY_RE.finditer(text):
|
||||
version = Version.parse(match.group(1))
|
||||
if not version.is_prerelease:
|
||||
return version
|
||||
return None
|
||||
|
||||
|
||||
def changes_section(text: str, version: Version) -> Optional[str]:
|
||||
"""The body of one version's entry, heading included, ready to become
|
||||
release notes.
|
||||
@@ -342,6 +458,81 @@ def changes_section(text: str, version: Version) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _bumps_block(titles: list[str]) -> str:
|
||||
lines = "\n".join(f"- {title}" for title in titles)
|
||||
return f"{_BUMPS_OPEN}\n{lines}\n{_BUMPS_CLOSE}"
|
||||
|
||||
|
||||
def _bump_titles(section: str) -> list[str]:
|
||||
match = _BUMPS_RE.search(section)
|
||||
if not match:
|
||||
return []
|
||||
return [
|
||||
line[2:].strip()
|
||||
for line in match.group(1).strip("\n").splitlines()
|
||||
if line.strip().startswith("- ")
|
||||
]
|
||||
|
||||
|
||||
def _set_marker_line(section: str, marker: str, line: str) -> str:
|
||||
"""Add or replace the one-line `marker ...` paragraph in `section`.
|
||||
|
||||
Used for the breaking-change and no-migration lines, which - unlike the
|
||||
bumps list - are not accumulated: a later bump that repeats `--breaking`
|
||||
restates it rather than growing a list nobody would read as history.
|
||||
"""
|
||||
pattern = re.compile(rf"^{re.escape(marker)}.*$", re.MULTILINE)
|
||||
if pattern.search(section):
|
||||
return pattern.sub(line, section, count=1)
|
||||
anchor = section.find(_BUMPS_CLOSE)
|
||||
if anchor != -1:
|
||||
insert_at = section.find("\n", anchor)
|
||||
insert_at = insert_at + 1 if insert_at != -1 else len(section)
|
||||
else:
|
||||
insert_at = len(section)
|
||||
return section[:insert_at] + f"\n{line}\n" + section[insert_at:]
|
||||
|
||||
|
||||
def _entry_span(text: str) -> tuple[int, int]:
|
||||
"""Start/end offsets of the topmost entry, heading included."""
|
||||
match = re.search(r"^## ", text, re.MULTILINE)
|
||||
if not match:
|
||||
raise VersionError(f"{CHANGES_FILENAME} has no entry to update")
|
||||
start = match.start()
|
||||
following = re.search(r"^## ", text[start + 1:], re.MULTILINE)
|
||||
end = start + 1 + following.start() if following else len(text)
|
||||
return start, end
|
||||
|
||||
|
||||
def _update_open_candidate(
|
||||
text: str,
|
||||
version: Version,
|
||||
date: str,
|
||||
title: str,
|
||||
breaking_reason: Optional[str],
|
||||
no_migration_reason: Optional[str],
|
||||
) -> str:
|
||||
"""Move the topmost entry's heading to `version`/`date`/`title`, append
|
||||
`title` to its machine-managed bump list, and set the breaking/no-migration
|
||||
lines only where this call supplies them - see `insert_changes_entry`."""
|
||||
start, end = _entry_span(text)
|
||||
section = text[start:end]
|
||||
|
||||
heading_match = _CHANGES_ENTRY_RE.match(section)
|
||||
if not heading_match:
|
||||
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry has no parseable version heading")
|
||||
section = f"## {version} - {date} - {title}" + section[heading_match.end():]
|
||||
|
||||
section = _BUMPS_RE.sub(lambda _m: _bumps_block(_bump_titles(section) + [title]), section, count=1)
|
||||
|
||||
if breaking_reason:
|
||||
section = _set_marker_line(section, BREAKING_CHANGE_MARKER, f"{BREAKING_CHANGE_MARKER} {breaking_reason}")
|
||||
if no_migration_reason:
|
||||
section = _set_marker_line(section, MIGRATION_NONE_MARKER, f"{MIGRATION_NONE_MARKER} - {no_migration_reason}")
|
||||
|
||||
return text[:start] + section + text[end:]
|
||||
|
||||
|
||||
def insert_changes_entry(
|
||||
text: str,
|
||||
version: Version,
|
||||
@@ -351,18 +542,35 @@ def insert_changes_entry(
|
||||
no_migration_reason: Optional[str] = None,
|
||||
breaking_reason: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Add a heading for `version` above the newest existing entry.
|
||||
"""Open a new entry above the newest existing one, or - when the topmost
|
||||
entry is still an open candidate (a pre-release heading) - update that
|
||||
entry in place instead.
|
||||
|
||||
Only the skeleton: heading, date, author, and - when a compatibility
|
||||
boundary is crossed - the line saying what breaks, plus the line saying no
|
||||
content has to change where that applies. The entry's actual content is
|
||||
written afterwards by whoever made the change, which is also why `bump`
|
||||
refuses to invent a title.
|
||||
`version bump` always lands on a candidate (see `escalate`); only
|
||||
`version release` fixes one, and it edits the heading directly rather than
|
||||
through this path (`version_cmd.release_command`), which is what makes "is
|
||||
the topmost heading still a pre-release" the right test for "is a
|
||||
candidate still open" here.
|
||||
|
||||
The break comes first: it is what an operator reading the release notes has
|
||||
to act on, and the migration line only qualifies it.
|
||||
A fresh entry gets the skeleton only: heading, date, author, the
|
||||
machine-managed bump-title list (started with this one title, for a
|
||||
candidate), and - when a compatibility boundary is crossed - the line
|
||||
saying what breaks, plus the line saying no content has to change where
|
||||
that applies. The break comes first: it is what an operator reading the
|
||||
release notes has to act on, and the migration line only qualifies it. The
|
||||
entry's actual prose is written afterwards by whoever made the change,
|
||||
which is also why `bump` refuses to invent a title.
|
||||
"""
|
||||
top = top_changes_version(text)
|
||||
if top is not None and top.is_prerelease:
|
||||
return _update_open_candidate(
|
||||
text, version, date, title,
|
||||
breaking_reason=breaking_reason, no_migration_reason=no_migration_reason,
|
||||
)
|
||||
|
||||
lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""]
|
||||
if version.is_prerelease:
|
||||
lines += [_bumps_block([title]), ""]
|
||||
if breaking_reason:
|
||||
lines += [f"{BREAKING_CHANGE_MARKER} {breaking_reason}", ""]
|
||||
if no_migration_reason:
|
||||
@@ -372,3 +580,28 @@ def insert_changes_entry(
|
||||
if anchor:
|
||||
return text[: anchor.start()] + entry + text[anchor.start():]
|
||||
return text.rstrip() + "\n\n---\n\n" + entry
|
||||
|
||||
|
||||
def release_entry(text: str, date: str, title: Optional[str] = None) -> str:
|
||||
"""Fix the topmost entry: strip its version's `-beta.N` suffix and write
|
||||
today's heading, keeping the previous title unless `title` overrides it.
|
||||
|
||||
Leaves the rest of the entry - the bump-title list included - untouched:
|
||||
it is the record of what happened across the candidate's life, and a
|
||||
release call has no reason to discard it. `version_cmd.release_command`
|
||||
is the only caller; it has already checked the topmost entry names a
|
||||
pre-release, so a non-pre-release version reaching here is a caller bug.
|
||||
"""
|
||||
start, end = _entry_span(text)
|
||||
section = text[start:end]
|
||||
heading_match = _CHANGES_ENTRY_RE.match(section)
|
||||
if not heading_match:
|
||||
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry has no parseable version heading")
|
||||
|
||||
current = Version.parse(heading_match.group(1))
|
||||
rest = heading_match.group(2) or ""
|
||||
_, _, existing_title = rest.partition(" - ")
|
||||
new_title = title if title is not None else existing_title
|
||||
|
||||
section = f"## {current.base} - {date} - {new_title}" + section[heading_match.end():]
|
||||
return text[:start] + section + text[end:]
|
||||
|
||||
Reference in New Issue
Block a user