Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0dc2129bb | |||
| f140e26a4c | |||
| 0fb8fd6122 | |||
| dc688e5726 | |||
| 1b0158fc8d |
+147
-7
@@ -25,13 +25,153 @@ 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.
|
||||
the entry's `**Author:**`/`**Breaking Change:**`/`**Migration:**` lines -
|
||||
written and read by `wikitool version bump`, never by hand.
|
||||
|
||||
That list is graded, not a flat chronological dump: each bump carries an
|
||||
impact (`--impact high|medium|low`, default `medium`), and the list renders
|
||||
grouped under `**High/Medium/Low impact**` headings - except when every bump
|
||||
so far is `medium`, where it stays flat with no headings at all, exactly as
|
||||
it always did before grading existed. `wikitool version regrade` corrects a
|
||||
grade after the fact, against a single read of the whole list. Below the
|
||||
list comes a short summary paragraph, written once at release time, and below
|
||||
that one `### <bump title>` changeset per bump, in chronological order -
|
||||
`wikitool version release` refuses to close a candidate that collected two or
|
||||
more bumps and has no summary there (a one-bump candidate is exempt, since its
|
||||
single changeset already reads as one). This layering exists because a
|
||||
long-running candidate's bump list, left flat and ungraded, grows unreadable
|
||||
as a release announcement - the concrete case that forced it was `5.0.0`, one
|
||||
entry across roughly 1440 lines.
|
||||
|
||||
`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 list,
|
||||
summary and changesets 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 the suffix and everything below the heading are a dev-checkout
|
||||
concern - readable here, never shipped as something to parse.
|
||||
|
||||
---
|
||||
|
||||
## 6.0.0-beta.2 - 2026-09-13 - docs verify: ein nur als .template ausgeliefertes Linkziel gilt als aufgeloest
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
**Breaking Change:** docs verify loest ab dieser Version jeden relativen Markdown-Link in den Referenzdateien auf und meldet ein totes Ziel als Fehler - auch in kb/CONVENTIONS.md und kb/<collection>/COLLECTION.md, die eine Instanz selbst besitzt und die ein Drop-in-Copy der Maschinerie nicht ersetzt. Eine Instanz, deren eigene Konventions- oder Collection-Datei einen relativen Link mit falscher ../-Tiefe oder auf eine inzwischen geloeschte Datei traegt, sieht docs verify nach dem Update fehlschlagen, wo es vorher durchlief. Reparatur: den in der Meldung genannten Datei:Zeile-Link korrigieren - kein Werkzeuglauf, keine Inhaltsmigration.
|
||||
|
||||
**Migration:** none required - Keine kb/-Seite aendert ihre Form. Der Grenzuebertritt ist ein strengerer Check auf instanz-eigener Prosa, keine Schema- oder Frontmatteraenderung.
|
||||
|
||||
<!-- wikitool:bumps -->
|
||||
**High impact**
|
||||
- SKILL.md: relative Links durch repo-root-relative Pfade ersetzt, docs verify/instructions verify pruefen Linkziele
|
||||
- docs verify: der Linkziel-Check erreicht auch die instanz-eigenen kb/CONVENTIONS.md und COLLECTION.md - daher Grenzuebertritt
|
||||
|
||||
**Medium impact**
|
||||
- docs verify: ein nur als .template ausgeliefertes Linkziel gilt als aufgeloest
|
||||
|
||||
**Low impact**
|
||||
- gates.md/session-setup.md: die Budget-Ausnahme von version regrade haengt an der Aufrufform
|
||||
<!-- /wikitool:bumps -->
|
||||
|
||||
### gates.md/session-setup.md: die Budget-Ausnahme von version regrade haengt an der Aufrufform
|
||||
|
||||
Doku-Nachzug zu `5.1.0`. Beide Dateien beschrieben die Budget-Ausnahme als feste Liste pro
|
||||
Kommandoname ("fixed allowlist"); `version regrade` ist die erste Ausnahme, die nur in *einer*
|
||||
Aufrufform liest - bar listet sie, mit Positionen schreibt sie `CHANGES.md`. Die Liste selbst
|
||||
bleibt an ihrem einen Ort (`tools/CONTRACT.md`), beide Stellen benennen jetzt aber, dass dort ein
|
||||
Eintrag pro Aufruf statt pro Namen gilt. Aufgefallen in der Schlussphase derselben Arbeit, deshalb
|
||||
ein eigener Patch-Bump: der Pfad `instructions/` liegt im Version-Gate der CI.
|
||||
|
||||
### SKILL.md: relative Links durch repo-root-relative Pfade ersetzt, docs verify/instructions verify pruefen Linkziele
|
||||
|
||||
`instructions sync` kopiert jede `SKILL.md` byteidentisch in `.agents/skills/` und
|
||||
`.claude/skills/` - eine andere Verzeichnistiefe als die Quelle, ohne deren Nachbardateien. 52 von
|
||||
58 relativen Markdown-Links in den sieben Skills zeigten deshalb in der publizierten Kopie ins
|
||||
Leere, unbemerkt, weil kein Check je ein Linkziel gelesen hat (Gitea-Meldung: ein `session-setup.md`-Read
|
||||
schlug in einer ausgelieferten Instanz fehl). Alle 58 Links sind jetzt repo-root-relative
|
||||
Klartextpfade (`instructions/session-setup.md` statt `[session-setup.md](../session-setup.md)`) -
|
||||
sie ueberleben die Kopie unveraendert, weil sie nicht von der Position der lesenden Datei abhaengen.
|
||||
`instructions/CONTRACT.md` § "A skill's outbound reference is a plain path, not a link" traegt die
|
||||
Regel.
|
||||
|
||||
Zwei neue mechanische Checks verhindern das Wiederauftreten: `instructions verify` verbietet jeden
|
||||
relativen Markdown-Link in einer `SKILL.md` (`check_skill_reference_paths`), `docs verify` loest
|
||||
jeden relativen Link in den flachen Instructions und Contracts gegen den Arbeitsbaum auf
|
||||
(`check_reference_targets`, ueber denselben Dateisatz wie `docs toc`). Nebenbei behoben:
|
||||
`instructions/dev/doc-pull-through.md` hatte zwei Links mit falscher `../`-Tiefe, unabhaengig vom
|
||||
Skill-Copy-Problem.
|
||||
|
||||
### docs verify: der Linkziel-Check erreicht auch die instanz-eigenen kb/CONVENTIONS.md und COLLECTION.md - daher Grenzuebertritt
|
||||
|
||||
Nachtraegliche Neueinstufung des Bumps darueber, kein zusaetzlicher Code. `check_reference_targets`
|
||||
laeuft ueber den Dateisatz von `docs toc`, und fuenf Dateien darin gehoeren der Instanz statt dem
|
||||
Stack: `kb/CONVENTIONS.md` und die vier `kb/<collection>/COLLECTION.md`. Ein Drop-in-Copy von
|
||||
`tools/`, `types/`, `instructions/` und `AGENTS.md` ersetzt sie nicht - ein toter relativer Link
|
||||
darin laesst `docs verify` nach dem Update fehlschlagen, wo es vorher durchlief. Genau die Form,
|
||||
die `tools/README.md` § Adding a command Schritt 5 als MAJOR-Zeile benennt ("a stricter check that
|
||||
newly fails on shipped content an instance already had"), und `instructions/dev/version-parts.md`
|
||||
entscheidet den Zweifelsfall zugunsten des Grenzuebertritts.
|
||||
|
||||
Gemessen bricht heute nichts: die zweite bekannte Instanz traegt 25 relative Links in diesen fuenf
|
||||
Dateien, davon null tote; dieses Repo ebenso. Die Einstufung folgt der Reichweite des Checks, nicht
|
||||
einem beobachteten Schaden - der Preis einer unnoetigen MAJOR ist eine Release-Notiz, der Preis
|
||||
einer MINOR, die doch bricht, ist eine Instanz mit fehlschlagendem Update-Pfad unter einer
|
||||
Versionsnummer, die Drop-in versprochen hat. Aufgefallen ist es in der Schlussphase beim Lesen der
|
||||
eigenen Regel in `tools/README.md`, nicht durch einen Check - was `docs/version-model.md` ueber
|
||||
genau diese Stelle sagt ("a person looking at the diff ... not a validator"), hat sich hier
|
||||
wiederholt.
|
||||
|
||||
### docs verify: ein nur als .template ausgeliefertes Linkziel gilt als aufgeloest
|
||||
|
||||
Defektbehebung am Check aus den beiden Bumps darueber, gefunden unmittelbar nach deren Publish.
|
||||
`check_reference_targets` meldete auf einem frisch exportierten Baum 13 tote Links - `kb/CONTRACT.md`
|
||||
neunmal, dazu `german-terminology.md`, `kb-profiles.md` und `link-taxonomy.md` - und zwar dafuer,
|
||||
dass der Export tut, was er soll. `kb/CONVENTIONS.md` und die vier `kb/<name>/COLLECTION.md` sind
|
||||
instanz-eigen: die Distribution traegt `<name>.template`, und die Instanz uebernimmt sie erst im
|
||||
Personalisierungsschritt von `instructions/setup-instance.md` durch Umbenennen. Zwischen
|
||||
`dist export` und diesem Schritt existiert die fertige Datei berechtigterweise nicht, waehrend die
|
||||
stack-eigenen Dateien sie unter ihrem kuenftigen Namen verlinken - korrekt, denn so wird sie heissen.
|
||||
|
||||
Ein Linkziel gilt jetzt auch dann als aufgeloest, wenn daneben `<ziel>.template` liegt. Die
|
||||
Ausnahme ist eng: fehlt beides, bleibt es ein Befund. Damit beschreibt der Check nicht laenger
|
||||
"noch nicht personalisiert" als "kaputter Link" - diesen Zustand meldet `doctor` unter
|
||||
`conventions` praezise und zustaendig.
|
||||
|
||||
CI war davon nie rot: der Replay in `.gitea/workflows/ci.yml` uebernimmt die Templates, bevor er
|
||||
`docs verify` aufruft, und der dokumentierte Weg in `setup-instance.md` stellt die Personalisierung
|
||||
(Schritt 5/6) ebenfalls vor die Verifikation (Schritt 13). Getroffen haette es jeden, der nach dem
|
||||
Export einmal zur Kontrolle `docs verify` aufruft. Aufgefallen ist es, weil die Verifikation des
|
||||
vorherigen Publishes den Arbeitsbaum geprueft hatte und nicht den exportierten - ausgerechnet bei
|
||||
einer Aenderung, deren ganzer Gegenstand Kopien in anderer Verzeichnistiefe sind.
|
||||
|
||||
---
|
||||
|
||||
## 5.1.0 - 2026-09-12 - changelog: Kandidaten-Eintrag nach Impact gruppiert, version regrade zur Nachkorrektur, version release verlangt eine Zusammenfassung
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
<!-- wikitool:bumps -->
|
||||
**High impact**
|
||||
- changelog: Kandidaten-Eintrag nach Impact gruppiert, version regrade zur Nachkorrektur, version release verlangt eine Zusammenfassung
|
||||
<!-- /wikitool:bumps -->
|
||||
|
||||
Seit `4.4.0` sammelt ein laufender Kandidat alle Bumps in einem Eintrag; bei `5.0.0` wurde das
|
||||
mit 20 Bumps und ~1440 Zeilen unlesbar, weil die Liste chronologisch und ungewichtet war und die
|
||||
Release-Seite genau diesen Eintrag 1:1 uebernimmt (`version notes`, `release.yml`). Der Eintrag
|
||||
ist jetzt geschichtet statt einer einzigen Wand Text: `version bump --impact high|medium|low`
|
||||
(Default `medium`) graduiert jeden Bump, die Liste rendert nach High/Medium/Low gruppiert - ausser
|
||||
wenn alles `medium` ist, dann bleibt sie flach wie bisher, damit jeder alte Eintrag und jeder
|
||||
einfache Patch unveraendert bleibt. `version regrade` korrigiert eine Note im Nachhinein, gegen
|
||||
einen einzelnen Lesevorgang der ganzen Liste, bevor der Kandidat geschlossen wird. Direkt unter
|
||||
der Liste steht jetzt eine kurze Zusammenfassung, darunter je Bump ein eigener
|
||||
`### <Bump-Titel>`-Changeset-Absatz; `version release` verweigert das Schliessen eines Kandidaten
|
||||
mit zwei oder mehr Bumps, solange diese Zusammenfassung fehlt (ein Kandidat mit genau einem Bump
|
||||
ist ausgenommen - sein Changeset ist bereits die Zusammenfassung, wie hier). Geschlossene
|
||||
Eintraege wie der zu `5.0.0` bleiben in der alten Form stehen: die Release-Seiten sind laut
|
||||
eigenem Footer unveraenderliche Snapshots, und `instructions/dev/version-parts.md` sowie
|
||||
`docs/version-model.md` zitieren den `2.0.0`-Eintrag mit Abschnittsnamen.
|
||||
|
||||
`--breaking`/`**Migration:**` sitzen jetzt oberhalb der Bump-Liste statt darunter, damit die fuer
|
||||
einen Operator wichtigste Zeile nicht unter einer moeglicherweise langen Liste verschwindet.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+15
-8
@@ -22,19 +22,24 @@ ein Release verbraucht wird - steht in
|
||||
[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.**
|
||||
1. **Bump eröffnet oder eskaliert den Kandidaten, gewichtet mit `--impact`.**
|
||||
|
||||
```bash
|
||||
tools/wikitool version bump --minor --title "Was sich geändert hat"
|
||||
tools/wikitool version bump --minor --title "Was sich geändert hat" --impact medium
|
||||
```
|
||||
|
||||
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.
|
||||
denselben Eintrag, statt einen neuen zu eröffnen. `--impact high|medium|low` (Default
|
||||
`medium`) gruppiert den Eintrag; `tools/wikitool version regrade` korrigiert eine Note später,
|
||||
wenn der Gesamteindruck des Kandidaten den Blick auf einen früheren Bump ändert.
|
||||
|
||||
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.
|
||||
2. **Der Eintrag bekommt seine Prosa - zweigeteilt.** `bump` schreibt nur das Skelett (Heading,
|
||||
Datum, Autor, die maschinenverwaltete, gruppierte Bump-Titel-Liste, ggf.
|
||||
Breaking-/Migration-Zeile). Darunter kommen zwei Autorenanteile: eine kurze Zusammenfassung
|
||||
(ein paar Sätze, worum es in diesem Release geht) direkt unter der Liste, und darunter je Bump
|
||||
ein eigener `### <Bump-Titel>`-Changeset-Absatz. Details dazu in
|
||||
[instructions/dev/version-parts.md](instructions/dev/version-parts.md) § The candidate model.
|
||||
|
||||
3. **Verify laufen lassen, bevor irgendetwas gepublished wird:**
|
||||
|
||||
@@ -52,8 +57,10 @@ eine Sitzung ihn tatsächlich durchläuft:
|
||||
|
||||
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)).
|
||||
der mehrere Bump-Titel gesammelt hat, eine zusammenfassende Überschrift. Verweigert, wenn der
|
||||
Kandidat zwei oder mehr Bumps gesammelt hat und die Zusammenfassung aus Schritt 2 noch fehlt -
|
||||
ein Kandidat mit genau einem Bump ist davon ausgenommen. Committet und pusht nichts
|
||||
(Invariante 5 in [AGENTS.md](AGENTS.md)).
|
||||
|
||||
5. **Publish bewegt `VERSION` auf `main`.**
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ alongside [AGENTS.md](../AGENTS.md).
|
||||
- [Publishing](#publishing)
|
||||
- [Writing an instruction](#writing-an-instruction)
|
||||
- [A skill's H1 is a name, not an imperative](#a-skills-h1-is-a-name-not-an-imperative)
|
||||
- [A skill's outbound reference is a plain path, not a link](#a-skills-outbound-reference-is-a-plain-path-not-a-link)
|
||||
- [Reference depth: bundled files, not repo-wide contracts](#reference-depth-bundled-files-not-repo-wide-contracts)
|
||||
- [When a skill carries a copy-in checklist](#when-a-skill-carries-a-copy-in-checklist)
|
||||
- [How much reasoning a step may carry](#how-much-reasoning-a-step-may-carry)
|
||||
@@ -190,6 +191,35 @@ exception in the same breath - "for promoted skills, the skill name is the title
|
||||
That is the whole exception. Everything else in this section binds a `SKILL.md` exactly as it
|
||||
binds an instruction.
|
||||
|
||||
### A skill's outbound reference is a plain path, not a link
|
||||
|
||||
`tools/wikitool instructions sync` copies each `SKILL.md` byte for byte into
|
||||
`.agents/skills/<name>/` and `.claude/skills/<name>/` (§ Publishing, above) - a different depth
|
||||
than the source, and without the sibling files a relative link might expect. A markdown link
|
||||
correct at `instructions/<name>/SKILL.md` (`../session-setup.md`, `../../kb/CONTRACT.md`)
|
||||
resolves to a different, usually nonexistent, file once copied: the number of `../` segments
|
||||
that reaches a target from `instructions/` does not reach the same target from
|
||||
`.claude/skills/`. Fifty-two of the fifty-eight relative links across this repo's seven skills
|
||||
broke exactly this way before this rule existed, silently - nothing rendered the copy to notice,
|
||||
and no check read a link target.
|
||||
|
||||
So a `SKILL.md` never writes an outbound reference as a relative markdown link, correct depth or
|
||||
not. It names the target as a repo-root-relative **plain path** instead - `` `instructions/session-setup.md` ``, not `[session-setup.md](../session-setup.md)`; `` `kb/CONTRACT.md` `` for a
|
||||
whole file, `` `kb/CONVENTIONS.md` § Tone `` for a section rather than an anchored link. The path
|
||||
survives the copy unchanged because it does not depend on where the reading file sits: an
|
||||
agent's working directory is the instance root regardless of which published copy it opened, so
|
||||
the same plain path resolves in the source and in both published copies alike. The cost is that
|
||||
the reference is no longer clickable from the source file - accepted deliberately, because the
|
||||
source is not where an agent reads it from; the harness reads the published copy.
|
||||
`tools/wikitool instructions verify` enforces the ban mechanically
|
||||
(`check_skill_reference_paths`).
|
||||
|
||||
This binds only `SKILL.md`. The flat `instructions/<name>.md` form - this file included - is
|
||||
never copied anywhere, so its relative links stay exactly as correct as their `../` count says,
|
||||
and stay ordinary links; `tools/wikitool docs verify` (`check_reference_targets`) resolves those
|
||||
against the working tree instead of banning the syntax, over the same reference-file scope
|
||||
`tools/wikitool docs toc` uses.
|
||||
|
||||
### Reference depth: bundled files, not repo-wide contracts
|
||||
|
||||
Anthropic's skill-authoring guidance asks that reference files stay **one level deep from
|
||||
@@ -201,9 +231,11 @@ That rule governs **skill-bundled** material: files sitting in `instructions/<na
|
||||
`OOXML.md`), and it says nothing about files outside the skill directory. No skill in this repo
|
||||
has a bundled file today, so as written the rule currently binds nothing here.
|
||||
|
||||
A link from a skill to a repo-wide contract - [kb/CONTRACT.md](../kb/CONTRACT.md),
|
||||
[tools/CONTRACT.md](../tools/CONTRACT.md), [gates.md](gates.md) - is a different category, and
|
||||
the two halves of the question have different answers:
|
||||
A skill's reference to a repo-wide contract - `kb/CONTRACT.md`, `tools/CONTRACT.md`,
|
||||
`instructions/gates.md` (written as a plain path per § "A skill's outbound reference is a plain
|
||||
path, not a link" above; this file is a flat instruction rather than a `SKILL.md`, so its own
|
||||
references to the same three files, a few sections up and below, stay ordinary links) - is a
|
||||
different category, and the two halves of the question have different answers:
|
||||
|
||||
- **The mechanic is real and directory-independent.** A contract reached at the second hop can
|
||||
be read partially exactly as a bundled file would be. Nothing about the path makes it safe.
|
||||
|
||||
@@ -33,7 +33,7 @@ touched; a row that does not apply needs no action.
|
||||
|
||||
| Touched surface | Document(s) that make a claim about it |
|
||||
|---|---|
|
||||
| A `wikitool` command's behaviour, flags, or interface | Both tables in [tools/CONTRACT.md](../tools/CONTRACT.md): the command reference row, and its per-command error contract (exit codes, atomicity, retry-safety) |
|
||||
| A `wikitool` command's behaviour, flags, or interface | Both tables in [tools/CONTRACT.md](../../tools/CONTRACT.md): the command reference row, and its per-command error contract (exit codes, atomicity, retry-safety) |
|
||||
| A stage's authoring rules (`raw/`, `kb/`, `types/`, `reports/`, `work/`, `tools/`, `instructions/`) | The touched `<stage>/CONTRACT.md` |
|
||||
| A rule, gate, or invariant `AGENTS.md` itself states | The relevant `AGENTS.md` section (Invariants, Gates, File naming, Routing, ...) |
|
||||
| A workflow, stage, or command a human operates by hand | Whichever of `README.md`, `EVALS.md`, `tools/README.md`, `INSTALL.md`, `DEVELOPMENT.md` names it - AGENTS.md § File naming says which document is for which reader |
|
||||
@@ -42,7 +42,7 @@ touched; a row that does not apply needs no action.
|
||||
|
||||
3. **Do not re-derive what `docs verify` already checks mechanically** - existence, table-row
|
||||
membership, ignore-canary state. That enumeration lives once, in
|
||||
[tools/CONTRACT.md](../tools/CONTRACT.md)'s own `docs verify` row; copying it here would be a
|
||||
[tools/CONTRACT.md](../../tools/CONTRACT.md)'s own `docs verify` row; copying it here would be a
|
||||
second copy that drifts, the exact failure this instruction exists to describe (Gitea #90).
|
||||
This instruction is only about the prose no check reads.
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ there and hands off here rather than continuing into this phase in the same brea
|
||||
printed its stack-machinery note ("this publish touched stack machinery...") and nothing has
|
||||
closed the work package it belongs to yet; or a package was published in an earlier session and
|
||||
never went through this skill (the gap this split exists to make impossible to skip past
|
||||
silently - see [issue-tracking.md](../issue-tracking.md)'s note that a closed body is the version
|
||||
silently - see `instructions/dev/issue-tracking.md`'s note that a closed body is the version
|
||||
everyone reads afterwards and nobody revisits).
|
||||
|
||||
**This directory is dev-only.** Same boundary as `stack-dev`
|
||||
([its own note](../stack-dev/SKILL.md) has the full reasoning) - `dist export` prunes
|
||||
(its own `instructions/dev/stack-dev/SKILL.md` has the full reasoning) - `dist export` prunes
|
||||
`instructions/dev/` wholesale, so this skill never reaches a distributed instance.
|
||||
|
||||
## Why this is a separate skill, not `stack-dev`'s step 6
|
||||
@@ -25,13 +25,13 @@ everyone reads afterwards and nobody revisits).
|
||||
The two phases around the mechanical middle of a stack-dev session have no mechanical guard at
|
||||
all - `pytest`, `docs verify` and `instructions verify` cover the code and tests in between, and
|
||||
nothing covers a changelog entry's accuracy, a `docs/` page's staleness, or an issue body's final
|
||||
state (see [docs/model-and-effort-selection.md](../../../docs/model-and-effort-selection.md)). Asking the
|
||||
state (see `docs/model-and-effort-selection.md`). Asking the
|
||||
same session to notice it has crossed into that second unchecked stretch - as a prose break inside
|
||||
`stack-dev`'s own step 6 - failed twice in a row on this stack (Gitea #42, then #30): both times
|
||||
the session knew the rule and skipped past it anyway, because nothing in the moment forced the
|
||||
question. Splitting the phase into its own skill does not add a check either - `wikitool` still
|
||||
does not know this tracker exists and must not learn (see
|
||||
[issue-tracking.md](../issue-tracking.md) § What no tool checks) - but it removes the thing that
|
||||
`instructions/dev/issue-tracking.md` § What no tool checks) - but it removes the thing that
|
||||
was actually failing: the closing *procedure* is no longer sitting in the session's context as a
|
||||
next step to run past - it exists only inside a skill someone has to invoke.
|
||||
|
||||
@@ -66,7 +66,7 @@ and a fresh subagent starts without the session's context).
|
||||
- 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 -
|
||||
[issue-tracking.md](../issue-tracking.md) steps 2-3 and 7 have the full shape; this is that
|
||||
`instructions/dev/issue-tracking.md` steps 2-3 and 7 have the full shape; this is that
|
||||
procedure, run at the point this skill exists to guarantee it actually gets run.
|
||||
|
||||
**A closing report in a comment does not satisfy this**, however thorough: it reads as
|
||||
@@ -80,7 +80,7 @@ and a fresh subagent starts without the session's context).
|
||||
naming) - the same is true of `tools/CONTRACT.md`'s two tables and any touched
|
||||
`<stage>/CONTRACT.md`, whose prose `docs verify` checks only for presence and table-row
|
||||
membership, never for what a cell or a section actually says
|
||||
([doc-pull-through.md](../doc-pull-through.md)); of `README.md`/`INSTALL.md`/`DEVELOPMENT.md`
|
||||
(`instructions/dev/doc-pull-through.md`); of `README.md`/`INSTALL.md`/`DEVELOPMENT.md`
|
||||
prose; and of a new instruction's own wording, which `instructions verify` checks structurally
|
||||
but never for what it claims. If the change this package shipped moved the reasoning or the
|
||||
behaviour one of these documents describes, update it now; if none did, say so rather than
|
||||
@@ -110,7 +110,7 @@ and a fresh subagent starts without the session's context).
|
||||
- **The work package spans several sessions?** Run this skill once, at the point the package is
|
||||
actually finished and its last publish has landed - not after every individual publish. A
|
||||
package still open across sessions keeps its body current per
|
||||
[issue-tracking.md](../issue-tracking.md) step 2 in the meantime; that is maintenance, not
|
||||
`instructions/dev/issue-tracking.md` step 2 in the meantime; that is maintenance, not
|
||||
closing.
|
||||
- **Resuming a package whose publish landed in an earlier, already-ended session?** Run this
|
||||
skill now, on whatever model the current session is - do not reopen the earlier session to run
|
||||
|
||||
@@ -39,30 +39,30 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
||||
1. **Confirm the mode.** If the task is ambiguous between "extend the tool" and "operate the
|
||||
wiki", ask rather than guess - the two have different rules for the same directories.
|
||||
2. **Consult `instructions/dev/` for the concrete procedure.** Currently:
|
||||
[commonplace-kb.md](../commonplace-kb.md) - vendored knowledge base on agent context
|
||||
`instructions/dev/commonplace-kb.md` - vendored knowledge base on agent context
|
||||
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
|
||||
`instructions/dev/issue-tracking.md` - open work lives in Gitea issues, one per work
|
||||
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 what
|
||||
[`stack-close`](../stack-close/SKILL.md) carries out once this skill's own work is published -
|
||||
`stack-close` (`instructions/dev/stack-close/SKILL.md`) carries out once this skill's own work is published -
|
||||
see step 6 below. An issue labelled `status/incoming` is the exception to all of that: it is a
|
||||
human's stub, not a spec, and it is **never implemented as it stands** - it gets worked out and
|
||||
triaged first. Read this file before filing something for later, before editing or closing an
|
||||
issue, before picking up an incoming stub, or before deciding what to pick up next.
|
||||
[testing-conventions.md](../testing-conventions.md) - the suite runs against a deliberately
|
||||
`instructions/dev/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
|
||||
`instructions/dev/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 4.
|
||||
[corpus-policy.md](../corpus-policy.md) - what "curated enough" means for the shared
|
||||
`instructions/dev/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.
|
||||
[doc-pull-through.md](../doc-pull-through.md) - which document makes a claim about a touched
|
||||
`instructions/dev/doc-pull-through.md` - which document makes a claim about a touched
|
||||
surface (a `wikitool` command, a stage's rules, an `AGENTS.md` rule/gate/invariant, a
|
||||
README-shaped human doc, a `docs/` page's reasoning) and therefore needs updating alongside
|
||||
the code, since `docs verify` never reads a cell's prose. Read it before step 6.
|
||||
@@ -100,16 +100,21 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
||||
|
||||
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:
|
||||
[docs/model-and-effort-selection.md](../../../docs/model-and-effort-selection.md).
|
||||
`docs/model-and-effort-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:
|
||||
|
||||
```bash
|
||||
tools/wikitool version bump --patch --title "<what changed>"
|
||||
tools/wikitool version bump --patch --title "<what changed>" --impact medium
|
||||
```
|
||||
|
||||
`--impact high|medium|low` (default `medium`) grades this bump in the changelog entry's own
|
||||
list - `tools/wikitool version regrade` corrects it later if the candidate's overall shape
|
||||
changes the read on an earlier one; see
|
||||
`instructions/dev/version-parts.md` § The candidate model.
|
||||
|
||||
Never edit `VERSION` or the entry's heading by hand - `bump` writes both, and `docs verify`
|
||||
fails a tree where they disagree. Pick the part by whether the new version is a **drop-in
|
||||
replacement** for the old one - not by whether content has to be migrated:
|
||||
@@ -123,12 +128,12 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
||||
Content migration is one way to land in the last row, not the definition of it: a rename of
|
||||
the update path, the artefact, an import name, a flag or an envvar breaks a swap with `kb/`
|
||||
entirely untouched. The full test, the catalogue of such breaks, and what to put in front of
|
||||
the user first are in [version-parts.md](../version-parts.md) - **read it before choosing
|
||||
the user first are in `instructions/dev/version-parts.md` - **read it before choosing
|
||||
`--major`.**
|
||||
|
||||
A `--major` bump therefore needs two things recorded. `--breaking "<what stops working>"`
|
||||
is required on every boundary-crossing bump; on top of it, a migration document for the new
|
||||
version - written per [migrate-corpus.md](../../migrate-corpus.md) - or
|
||||
version - written per `instructions/migrate-corpus.md` - or
|
||||
`--no-migration "<reason>"` when no content actually has to change. `bump` refuses without
|
||||
either, and so does `docs verify`: an instance learning that it must migrate, with nothing
|
||||
telling it how, is a dead end.
|
||||
@@ -140,7 +145,7 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
||||
do not need a bump - CI's version gate is scoped to what changes behaviour.
|
||||
|
||||
5. **Pull through every document that makes a claim about the surface you touched - `docs verify`
|
||||
checks a cell's presence, never its prose.** [doc-pull-through.md](../doc-pull-through.md) has
|
||||
checks a cell's presence, never its prose.** `instructions/dev/doc-pull-through.md` has
|
||||
the table of which document that is, per surface.
|
||||
|
||||
6. **Verify, then publish.** `tools/wikitool docs verify`, `tools/wikitool instructions verify`,
|
||||
@@ -157,7 +162,7 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
||||
|
||||
**This skill stops here.** The closing phase - rewriting the issue body to its final state,
|
||||
checking for `docs/` staleness, and naming which model ran which phase of the session - lives
|
||||
in [`stack-close`](../stack-close/SKILL.md), not in a further step of this one. Invoke it now;
|
||||
in `stack-close` (`instructions/dev/stack-close/SKILL.md`), not in a further step of this one. Invoke it now;
|
||||
do not fold its work into this session under this skill's rules, and do not treat "the change
|
||||
is published" as this work package being done.
|
||||
|
||||
@@ -171,7 +176,7 @@ 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. A surfacing boundary crossing
|
||||
`instructions/dev/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.
|
||||
@@ -181,4 +186,4 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
||||
Not for wiki content work - use `wiki-ingest`/`wiki-query`/`wiki-manage`/`wiki-lint`/
|
||||
`wiki-status` for that. Not for setting up a new instance (`instructions/setup-instance.md`) or
|
||||
a fresh clone of this repo (`instructions/bootstrap.md`). Not for closing a work package after
|
||||
its publish has landed - that is [`stack-close`](../stack-close/SKILL.md).
|
||||
its publish has landed - that is `stack-close` (`instructions/dev/stack-close/SKILL.md`).
|
||||
|
||||
@@ -55,10 +55,32 @@ a new one, and only `version release` turns it into something the release workfl
|
||||
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.
|
||||
bump's `--title`, graded by `--impact`); 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 is left
|
||||
alone. `version notes` therefore still prints exactly one entry per release, whatever a
|
||||
candidate's history of bumps looked like.
|
||||
- **The entry is layered, not one undifferentiated block.** A long-running candidate can collect
|
||||
dozens of bumps, chronological and equally weighted, which is unreadable as a release
|
||||
announcement - `5.0.0` did this at ~1440 lines for one entry. So the entry reads, top to bottom,
|
||||
as four layers with different authors and different lifetimes:
|
||||
|
||||
1. **Heading, author, breaking/migration lines** - written by `version bump`, anchored right
|
||||
above the bump list so the line an operator most needs to act on never sits beneath a list
|
||||
that can run long.
|
||||
2. **The bump list**, grouped `**High/Medium/Low impact**` (empty groups omitted) - rendered by
|
||||
`version bump`'s `--impact` (default `medium`), corrected after the fact by `version regrade`.
|
||||
Flat and ungrouped, exactly as before this layering existed, when every bump is `medium` - the
|
||||
common case, and the shape every pre-existing region still is.
|
||||
3. **The release summary** - a short paragraph, written once, by hand, when the candidate is
|
||||
ready to ship. `version release` refuses to close an entry with two or more bumps and no
|
||||
summary here; a one-bump entry is exempt, since there the bump's own changeset already reads
|
||||
as the summary.
|
||||
4. **The changesets**, one `### <bump title>` heading per bump, in chronological order - the
|
||||
detail a reader follows into from the graded list above. A changeset is a few sentences,
|
||||
not the full rationale; what needs more than that belongs in the issue tracker, not here.
|
||||
|
||||
The list is the index into the changesets, which is why the bump list's title text and a
|
||||
changeset's `###` heading are the same string.
|
||||
- **`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.
|
||||
@@ -126,9 +148,12 @@ 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 escalation bump itself.** The bump that first crosses the boundary
|
||||
requires `--breaking "<what breaks>"`, which writes a `**Breaking Change:**` line into the
|
||||
entry:
|
||||
5. **Grade the bump while you are making it, with `--impact high|medium|low`** (default
|
||||
`medium`) - the judgment is easiest right after you did the work, not weeks later staring at a
|
||||
chronological list. It is not final: `version regrade` corrects it before release if the
|
||||
candidate's overall shape changes the read on an earlier bump. Then 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 \
|
||||
@@ -168,16 +193,27 @@ the three-line test below is usually enough.
|
||||
never reported by anything. The 5.0.0 candidate is the case: it declared `--no-migration` for
|
||||
a TOC-verification change, then absorbed a schema removal that migrates 152 pages.
|
||||
|
||||
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.
|
||||
7. **Review the graded list before fixing the candidate, and regrade what reads wrong.** Run
|
||||
`tools/wikitool version regrade` with no arguments - it lists every bump at its current grade,
|
||||
numbered in rendered order. A candidate that grew over several sessions often has a bump graded
|
||||
in isolation that reads differently once the whole shape is visible; `version regrade 3 7
|
||||
--impact high` corrects one or several positions against a single read of that list, put the
|
||||
result in front of the user, and re-list to confirm. Only then run
|
||||
`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.
|
||||
8. **Write the entry's prose - the summary, and each bump's own changeset.** `bump` leaves both
|
||||
empty on purpose. The **summary** is a short paragraph (a few sentences) written once, at
|
||||
release time, right below the graded bump list: what this release is about, and why, for a
|
||||
reader who will not read the changesets underneath. `version release` refuses to close an
|
||||
entry that collected two or more bumps and has no summary - a one-bump entry is exempt, since
|
||||
there the bump's changeset already reads as one. Each **changeset**, under its own
|
||||
`### <bump title>` heading, is a few sentences on what changed and why - it is the one thing a
|
||||
future reader cannot reconstruct from the diff, but it is not the place for the full rationale
|
||||
of a decision; that belongs in the issue tracker or the commit history, and a changeset that
|
||||
is growing past a paragraph or two is a sign it belongs there instead.
|
||||
|
||||
## Decision points
|
||||
|
||||
|
||||
@@ -193,10 +193,17 @@ command you actually need to run, and only with the user's approval.
|
||||
|
||||
A dozen commands are exempt from this budget entirely - `search` and `doctor` because retrieval
|
||||
and diagnosis are reading, not iterating, plus the read-only forms of `links`, `cite`, `budget`,
|
||||
`eval`, `version`, `migrate` and `upstream verify`. The exemption is that fixed allowlist in
|
||||
`eval`, `version`, `migrate` and `upstream verify`. The exemption is that allowlist in
|
||||
[tools/CONTRACT.md](../tools/CONTRACT.md), not a "does not change the wiki" rule of thumb: `lint`
|
||||
only writes to gitignored `reports/` and still counts, because it is not on the list.
|
||||
|
||||
**One entry is read-only only in one of its two forms.** `version regrade` lists the running
|
||||
candidate's graded bump titles when called bare, and writes `CHANGES.md` when called with
|
||||
positions to regrade - so the exemption is per *invocation* there, not per command name. It is
|
||||
the only such case; every other row on the list is exempt however it is called. Its
|
||||
`tools/CONTRACT.md` row says which form is which, which is still the single place that list
|
||||
lives.
|
||||
|
||||
### Taking a new session id
|
||||
|
||||
The budget is scoped by `WIKITOOL_SESSION_ID` ([session-setup.md](session-setup.md)), so a new
|
||||
|
||||
@@ -57,11 +57,12 @@ refusal. See [gates.md](gates.md).
|
||||
|
||||
## Scope
|
||||
|
||||
**The exemption is a fixed allowlist, not "read-only" or "does not change the wiki."** A command
|
||||
**The exemption is an allowlist, not "read-only" or "does not change the wiki."** A command
|
||||
needs this setup unless it is one of the dozen `tools/CONTRACT.md` marks exempt in its command
|
||||
table (`search`, `doctor`, `links show`, `cite id`, `budget status`, the read-only forms of
|
||||
`eval`, `version`, `migrate` and `upstream verify`) - that table, not a rule of thumb here, is
|
||||
the single list.
|
||||
the single list. One entry on it, `version regrade`, is exempt only in its bare listing form and
|
||||
counted when it is given positions to regrade; every other entry is exempt however it is called.
|
||||
|
||||
`lint` is the case that breaks the "changes the wiki" reading: it only writes to `reports/`,
|
||||
which is gitignored, so it looks side-effect-free - but it is not on the allowlist and is counted
|
||||
|
||||
@@ -10,7 +10,7 @@ description: Process a new source file into the LLM wiki - extract entities and
|
||||
**Trigger:** User drops a file into `incoming/` (the normal path - see step 1) or directly into
|
||||
`raw/`, or explicitly requests ingestion.
|
||||
|
||||
**Before the first `wikitool` call:** [session-setup.md](../session-setup.md).
|
||||
**Before the first `wikitool` call:** `instructions/session-setup.md`.
|
||||
|
||||
Contracts are read **when the step needs them**, not upfront: a source that produces no concept
|
||||
pages should never have cost the concept contract. Field-level requirements always come from
|
||||
@@ -40,7 +40,7 @@ validator complains - and the ticked list is the only record that they happened.
|
||||
## Steps
|
||||
|
||||
1. **Promote from `incoming/` if that is where the file sits.** Read
|
||||
[raw/CONTRACT.md](../../raw/CONTRACT.md) "Getting a file in" and "Capture fields" if you have
|
||||
`raw/CONTRACT.md` "Getting a file in" and "Capture fields" if you have
|
||||
not this session - the directory and any bundling are computed, never chosen by hand, but the
|
||||
two capture flags are not: `raw accept` refuses without them.
|
||||
|
||||
@@ -63,7 +63,7 @@ validator complains - and the ticked list is the only record that they happened.
|
||||
**A file that arrived through the MCP `submit` tool is not yet in `incoming/`** - it sits in
|
||||
`mcp-upload/<id>/`, a quarantine no command in this step reads. A reviewer promotes it first
|
||||
with `wikitool upload accept <id> --confirm <token>`, per
|
||||
[instructions/ingest-queue.md](../ingest-queue.md); once accepted it is an ordinary file in
|
||||
`instructions/ingest-queue.md`; once accepted it is an ordinary file in
|
||||
`incoming/` and this step applies to it exactly as to anything dropped there by hand.
|
||||
|
||||
**If this refuses because the name is already claimed** (a file stem or a bundle directory
|
||||
@@ -79,7 +79,7 @@ validator complains - and the ticked list is the only record that they happened.
|
||||
|
||||
**Check the size first, on both axes.** *Volume* - how many raw files this ingest covers -
|
||||
and *breadth* - how many entities and concepts this one source would produce or update.
|
||||
Either one past the thresholds in [ingest-large-tree.md](../ingest-large-tree.md) § When to
|
||||
Either one past the thresholds in `instructions/ingest-large-tree.md` § When to
|
||||
run is that procedure, not this one: stop and follow it. There, volume is cut into units;
|
||||
breadth cannot be cut at all (`raw/` keeps a file whole, and one raw file has one owning
|
||||
source page) and buys an extract pass instead, before any page is written. Skipping either
|
||||
@@ -107,7 +107,7 @@ validator complains - and the ticked list is the only record that they happened.
|
||||
which entities/concepts to create or update, any specific emphasis.
|
||||
|
||||
6. **Create the source page.** Read
|
||||
[kb/sources/COLLECTION.md](../../kb/sources/COLLECTION.md) first - it holds what this
|
||||
`kb/sources/COLLECTION.md` first - it holds what this
|
||||
instance expects of a source page's sections and how it names one.
|
||||
|
||||
```bash
|
||||
@@ -140,18 +140,18 @@ validator complains - and the ticked list is the only record that they happened.
|
||||
article also pass `--set source_url=<upstream URL>`; `raw_files:` must still point at the
|
||||
local copy. Then write the Summary / Key Takeaways / Action Items prose from step 5 - in the
|
||||
KB language, whatever the source's own language is, quoting verbatim passages in the
|
||||
original. Which language that is: [kb/CONVENTIONS.md](../../kb/CONVENTIONS.md#language).
|
||||
original. Which language that is: `kb/CONVENTIONS.md` § Language.
|
||||
What is exempt from it, in any language:
|
||||
[kb/CONTRACT.md](../../kb/CONTRACT.md#language-and-identifiers).
|
||||
`kb/CONTRACT.md` § Language and identifiers.
|
||||
|
||||
Fill `## Not Extracted` in the same pass: what you read and deliberately did not promote,
|
||||
with the reason. Nothing in the repository can re-derive that judgment, and without it the
|
||||
same source gets re-litigated on the next pass.
|
||||
|
||||
7. **Create or update entity pages.** Read
|
||||
[kb/entities/COLLECTION.md](../../kb/entities/COLLECTION.md) and
|
||||
[kb/CONTRACT.md](../../kb/CONTRACT.md) plus
|
||||
[kb/CONVENTIONS.md](../../kb/CONVENTIONS.md) first - the second is where provenance and
|
||||
`kb/entities/COLLECTION.md` and
|
||||
`kb/CONTRACT.md` plus
|
||||
`kb/CONVENTIONS.md` first - the second is where provenance and
|
||||
citation are defined, the third where this instance's tone and naming forms are.
|
||||
|
||||
**A subject earns a page when the source carries material for one.** A name the source
|
||||
@@ -186,7 +186,7 @@ validator complains - and the ticked list is the only record that they happened.
|
||||
|
||||
8. **Create or update concept pages** - only if the source produced any. Same pattern, including
|
||||
step 7's rule about which subjects earn a page at all, reading
|
||||
[kb/concepts/COLLECTION.md](../../kb/concepts/COLLECTION.md) first:
|
||||
`kb/concepts/COLLECTION.md` first:
|
||||
|
||||
```bash
|
||||
tools/wikitool new concept --name "<Name>" \
|
||||
@@ -211,7 +211,7 @@ validator complains - and the ticked list is the only record that they happened.
|
||||
The new raw file(s) must no longer be listed as uncovered, and no `raw_files:` entry may be
|
||||
broken.
|
||||
|
||||
11. **Close out.** Follow [publish-cycle.md](../publish-cycle.md) with `--op ingest` and a
|
||||
11. **Close out.** Follow `instructions/publish-cycle.md` with `--op ingest` and a
|
||||
message of the form `ingest: <raw path>`.
|
||||
|
||||
12. **Check the lint cadence.**
|
||||
@@ -230,16 +230,16 @@ validator complains - and the ticked list is the only record that they happened.
|
||||
Two pages on one subject is the failure this step exists to prevent.
|
||||
- **One source names far more subjects than usual?** That is breadth, not volume. It is not
|
||||
split into several sources - it cannot be - and it does not get a page per name either:
|
||||
[ingest-large-tree.md](../ingest-large-tree.md) § A broad source is not cut.
|
||||
`instructions/ingest-large-tree.md` § A broad source is not cut.
|
||||
- **No raw file backs a claim you want to write?** Leave it out, or mark the page
|
||||
`provenance: mixed` and put it under `## General Guidance (unsourced)`.
|
||||
- **`publish` exited 42?** A single ingest is normally well under the Mass-Update Gate
|
||||
threshold. If it trips - a source touching many entities - show the user the output and stop;
|
||||
see [gates.md](../gates.md).
|
||||
- **A gate or the loop-breaker refuses anything?** Stop and follow [gates.md](../gates.md).
|
||||
see `instructions/gates.md`.
|
||||
- **A gate or the loop-breaker refuses anything?** Stop and follow `instructions/gates.md`.
|
||||
A multi-tool ingest should land in roughly 20-35 `wikitool` calls; needing far more is a sign
|
||||
the source should be split into several ingests - which is
|
||||
[ingest-large-tree.md](../ingest-large-tree.md), not a bigger budget.
|
||||
`instructions/ingest-large-tree.md`, not a bigger budget.
|
||||
|
||||
## wikitool commands used
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ description: Health-check the LLM wiki - broken links, orphan pages, uncovered r
|
||||
threshold reached - `wiki-ingest`'s last step checks it after every publish, so the count is
|
||||
never something an agent has to remember.
|
||||
|
||||
**Before the first `wikitool` call:** [session-setup.md](../session-setup.md).
|
||||
**Before the first `wikitool` call:** `instructions/session-setup.md`.
|
||||
|
||||
## Run checklist
|
||||
|
||||
@@ -51,7 +51,7 @@ mechanical half looks exactly like a complete one.
|
||||
The *Redundant see-also* section is the one that looks mechanical and is not - do **not**
|
||||
clear it under step 7. It names a `see-also` edge standing beside a specific label on the
|
||||
reverse direction, and the obvious repair destroys the thing worth keeping: `xref remove`
|
||||
clears the reference in *both* directions (see [tools/CONTRACT.md](../../tools/CONTRACT.md)),
|
||||
clears the reference in *both* directions (see `tools/CONTRACT.md`),
|
||||
so removing the weak edge takes the labelled one with it and the pair ends up saying nothing
|
||||
at all. Either relabel the weak edge to something true with `xref add`, which only ever
|
||||
touches the source page, or leave it and report it at step 9. Clearing a batch of these is a
|
||||
@@ -90,7 +90,7 @@ mechanical half looks exactly like a complete one.
|
||||
7. **Repair what is mechanical.** A dangling frontmatter reference is either a page that should
|
||||
exist (`tools/wikitool new ...`) or a reference that should not
|
||||
(`tools/wikitool xref remove --a "<Page>" --b "<Missing>"`). A title that changed is
|
||||
`tools/wikitool rename` - see [page-lifecycle.md](../page-lifecycle.md). Never hand-edit a
|
||||
`tools/wikitool rename` - see `instructions/page-lifecycle.md`. Never hand-edit a
|
||||
frontmatter array to clear one.
|
||||
|
||||
8. **Verify the stack.**
|
||||
@@ -129,7 +129,7 @@ mechanical half looks exactly like a complete one.
|
||||
|
||||
- **Publish?** Lint does not auto-publish. Run `tools/wikitool publish` only if asked.
|
||||
- **Bulk fixes touched 10+ files?** Expected for a lint pass: `publish` exits 42. Show the
|
||||
user its output and stop; see [gates.md](../gates.md). Consider `--path` batches instead.
|
||||
user its output and stop; see `instructions/gates.md`. Consider `--path` batches instead.
|
||||
- **The gate or loop-breaker keeps tripping?** That is a signal to stop and re-plan with the
|
||||
user, not to pass `--override-budget`. A full pass should land in roughly 20-35 calls.
|
||||
|
||||
@@ -140,7 +140,7 @@ mechanical half looks exactly like a complete one.
|
||||
`publish` (only if asked)
|
||||
|
||||
**Deliberately absent:** `rm` - a lint pass never deletes a page, and
|
||||
[page-lifecycle.md](../page-lifecycle.md) is where a deletion belongs. `log status` - it decides
|
||||
`instructions/page-lifecycle.md` is where a deletion belongs. `log status` - it decides
|
||||
this skill's *trigger*, but `wiki-ingest`'s last step is what runs it.
|
||||
|
||||
## Output
|
||||
|
||||
@@ -11,11 +11,11 @@ catalog and the audit log in sync.
|
||||
**Trigger:** User requests a new entity/concept/comparison page, or new information needs
|
||||
integrating into an existing one.
|
||||
|
||||
**Before the first `wikitool` call:** [session-setup.md](../session-setup.md).
|
||||
**Before the first `wikitool` call:** `instructions/session-setup.md`.
|
||||
|
||||
**Read before drafting:** [kb/CONTRACT.md](../../kb/CONTRACT.md) - linking and provenance,
|
||||
**Read before drafting:** `kb/CONTRACT.md` - linking and provenance,
|
||||
both of which the tool enforces - and
|
||||
[kb/CONVENTIONS.md](../../kb/CONVENTIONS.md), which is where this instance's language, naming
|
||||
`kb/CONVENTIONS.md`, which is where this instance's language, naming
|
||||
forms, tone and relationship labels are, together with the target collection's own
|
||||
`COLLECTION.md`, which carries its quality goal and what is local to that subtree. Field-level
|
||||
requirements come from `tools/wikitool types describe <type>`.
|
||||
@@ -48,7 +48,7 @@ requirements come from `tools/wikitool types describe <type>`.
|
||||
subjects - so the prose connects to existing pages instead of restating them.
|
||||
|
||||
5. **Draft.** Fill in the generated skeleton's TODO sections, following the tone rules in
|
||||
[kb/CONVENTIONS.md](../../kb/CONVENTIONS.md#tone). If `provenance:` is `sourced` or `mixed`, cite
|
||||
`kb/CONVENTIONS.md` § Tone. If `provenance:` is `sourced` or `mixed`, cite
|
||||
hard facts as you write them with `tools/wikitool cite add --page "<Title>" --source
|
||||
"Source - X"`, which also adds `X` to `sources:` - paste the `[^cite-id]` marker it prints.
|
||||
|
||||
@@ -60,7 +60,7 @@ requirements come from `tools/wikitool types describe <type>`.
|
||||
|
||||
One per relationship. Never hand-edit `related:`.
|
||||
|
||||
7. **Close out.** [publish-cycle.md](../publish-cycle.md), `--op create`.
|
||||
7. **Close out.** `instructions/publish-cycle.md`, `--op create`.
|
||||
|
||||
## Updating a page
|
||||
|
||||
@@ -84,11 +84,11 @@ requirements come from `tools/wikitool types describe <type>`.
|
||||
|
||||
Never hand-edit `modified:`, `summary:` or `provenance:`.
|
||||
|
||||
7. **Close out.** [publish-cycle.md](../publish-cycle.md), `--op update`.
|
||||
7. **Close out.** `instructions/publish-cycle.md`, `--op update`.
|
||||
|
||||
## Renaming, deleting, or unlinking
|
||||
|
||||
That is [page-lifecycle.md](../page-lifecycle.md). A title is the wiki's only identifier for a
|
||||
That is `instructions/page-lifecycle.md`. A title is the wiki's only identifier for a
|
||||
page, so none of it is a file operation.
|
||||
|
||||
## Decision points
|
||||
@@ -98,7 +98,7 @@ page, so none of it is a file operation.
|
||||
- **Entity or concept?** A thing you can point at is an entity; a *why* or *how* is a concept.
|
||||
The collection contracts draw the line.
|
||||
- **`publish` refused?** A single page is normally well under the threshold. If it trips,
|
||||
[gates.md](../gates.md).
|
||||
`instructions/gates.md`.
|
||||
|
||||
## wikitool commands used
|
||||
|
||||
@@ -106,7 +106,7 @@ page, so none of it is a file operation.
|
||||
`sources rebuild-index`, `index rebuild`, `log append`, `publish`
|
||||
|
||||
`xref remove` belongs to the unlinking case, which this skill delegates whole to
|
||||
[page-lifecycle.md](../page-lifecycle.md) rather than describing in a step of its own.
|
||||
`instructions/page-lifecycle.md` rather than describing in a step of its own.
|
||||
|
||||
## Output
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ description: Answer a question using the LLM wiki's compiled knowledge - read-on
|
||||
|
||||
**Trigger:** User asks a question.
|
||||
|
||||
**Before the first `wikitool` call:** [session-setup.md](../session-setup.md).
|
||||
**Before the first `wikitool` call:** `instructions/session-setup.md`.
|
||||
|
||||
**Hard rule:** read-only with respect to wiki *content*. Never modify, hand-edit, or scaffold a
|
||||
page while answering. Two exceptions, both mechanical: step 6 (filing a valuable answer through
|
||||
@@ -46,7 +46,7 @@ invariant - rather than synthesizing a plausible-sounding answer from general kn
|
||||
|
||||
4. **Answer and cite.** Name the wiki pages the answer came from, and the sources behind them.
|
||||
Hedge to what those sources carry, not to a number - see
|
||||
[kb/CONVENTIONS.md § Hedging](../../kb/CONVENTIONS.md#hedging).
|
||||
`kb/CONVENTIONS.md` § Hedging.
|
||||
|
||||
5. **Decide what earns a page - before the first `new`.** Name every page you are considering,
|
||||
then hold each one on its own against all three criteria: the answer required synthesis
|
||||
@@ -75,9 +75,9 @@ invariant - rather than synthesizing a plausible-sounding answer from general kn
|
||||
exist under different words. Then say the wiki has no confident source, and offer to ingest
|
||||
one.
|
||||
- **Filed a page?** Query does **not** auto-publish. Run `tools/wikitool publish` only if asked;
|
||||
the sequence is in [publish-cycle.md](../publish-cycle.md).
|
||||
the sequence is in `instructions/publish-cycle.md`.
|
||||
- **Several answers filed at once?** That can trip the Mass-Update Gate - see
|
||||
[gates.md](../gates.md). The gate is a brake, not the check: it counts files and knows nothing
|
||||
`instructions/gates.md`. The gate is a brake, not the check: it counts files and knows nothing
|
||||
about whether any of them earned a page. Step 5 is what decides that, and a batch small enough
|
||||
to pass the gate has not been cleared by it.
|
||||
|
||||
|
||||
@@ -10,14 +10,14 @@ semantic review a lint pass does.
|
||||
|
||||
**Trigger:** User asks for wiki statistics, "what's new", or a quick health snapshot.
|
||||
|
||||
**Before the first `wikitool` call:** [session-setup.md](../session-setup.md) - step 2's `lint` is
|
||||
**Before the first `wikitool` call:** `instructions/session-setup.md` - step 2's `lint` is
|
||||
not on the budget's exemption allowlist and is counted like any other call, gitignored report or
|
||||
not (§ Scope there).
|
||||
|
||||
**Hard rule:** read-only with respect to wiki *content*. Never create, modify, or scaffold a
|
||||
page, never repair a finding, never publish. One file does get written: the report `lint`
|
||||
produces in step 2. That is not an exception being stretched - `reports/` is gitignored and holds
|
||||
no wiki page ([reports/CONTRACT.md](../../reports/CONTRACT.md)), so the write leaves nothing
|
||||
no wiki page (`reports/CONTRACT.md`), so the write leaves nothing
|
||||
behind that the wiki ships. If something looks wrong, point the user at `wiki-lint` or
|
||||
`wiki-manage` instead of fixing it here.
|
||||
|
||||
|
||||
+10
-8
@@ -168,9 +168,9 @@ tools/wikitool <command> --help
|
||||
| `types list [--json]` | List every type-spec under `types/` (name, schema path, subtype field, description) - discover what page types exist without reading `types/*.md` directly |
|
||||
| `types describe <name> [--json]` | Print one type's full contract: required/optional frontmatter fields with enums, its subtype field (if any), and its authoring body |
|
||||
| `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 verify` | Check the instruction layer: flat instructions validate against `types/instruction.schema.yaml`, each `SKILL.md` carries the frontmatter its harness reads, no `SKILL.md` carries a relative markdown link (`sync` copies it to a different depth than the source, so a `SKILL.md` references a target as a repo-root-relative plain path instead - see [instructions/CONTRACT.md](../instructions/CONTRACT.md) § "A skill's outbound reference is a plain path, not a link"), 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 in this file's own § Commands table and, separately, in its § Error contracts table (both directions, checked per table, so a row dropped from one is not hidden by the same name surviving in the other, and only a name's presence in a row is checked, never the rest of that row's text), 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, the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories), and no `.md`/`.template` file `dist export` would ship citing an issue number - the tracker exists only in the origin repo, so such a number in a distributed instance is a reference its reader can neither resolve nor recognise as unresolvable (a `<!-- dist:strip-start/end -->` region is exempt: it is already gone from the text the check reads, which is the export plan's, not the working tree's), and every reference file `docs toc` covers carrying the current table-of-contents region for its own headings - missing and stale are one check, because the generator is idempotent. 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 |
|
||||
| `docs verify` | Check the docs that mirror the code: every CLI command documented in this file's own § Commands table and, separately, in its § Error contracts table (both directions, checked per table, so a row dropped from one is not hidden by the same name surviving in the other, and only a name's presence in a row is checked, never the rest of that row's text), 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, the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories), and no `.md`/`.template` file `dist export` would ship citing an issue number - the tracker exists only in the origin repo, so such a number in a distributed instance is a reference its reader can neither resolve nor recognise as unresolvable (a `<!-- dist:strip-start/end -->` region is exempt: it is already gone from the text the check reads, which is the export plan's, not the working tree's), every reference file `docs toc` covers carrying the current table-of-contents region for its own headings - missing and stale are one check, because the generator is idempotent - and every relative markdown link in one of those same reference files resolving to a file that actually exists (a target's `#anchor` suffix is stripped first; code fences and inline code spans are masked before scanning, so a passage showing link syntax as an example is not mistaken for a real reference). 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 |
|
||||
| `docs toc [--apply]` | Create, refresh or remove the generated table-of-contents region (`<!-- wikitool:toc -->` ... `<!-- /wikitool:toc -->`, placed after the title and before the first `##`) on every reference file over 100 lines that `AGENTS.md`, a stage/collection contract, or the flat `instructions/**.md` form covers - the scope Anthropic's skill-authoring guidance names for a file previewed rather than read in full. Dry-run by default (prints which files would change); `--apply` writes. `docs verify` checks the result stays current the same way it checks every other generated-from-code copy |
|
||||
|
||||
### Telemetry
|
||||
@@ -189,8 +189,9 @@ tools/wikitool <command> --help
|
||||
| `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>"] [--migration-required] [--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. A later bump of the same candidate that finds out `--no-migration` was wrong after all retracts that line with `--migration-required` instead of restating `--no-migration` - refused without a migration document already targeting the new base, and without an existing `--no-migration` line to retract. 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` |
|
||||
| `version bump --major\|--minor\|--patch --title "<...>" [--impact high\|medium\|low] [--breaking "<what breaks>"] [--no-migration "<reason>"] [--migration-required] [--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, graded by `--impact`, default `medium`) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. The list renders grouped under `**High/Medium/Low impact**` headings (empty groups omitted), except when every bump so far is `medium`, where it stays the flat, ungrouped list the region always had - `version regrade` corrects a grade after the fact. Refuses more or fewer than one part, an empty title, an unknown `--impact`, 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, anchored just above the bump list, and persist over later bumps of the same candidate without being repeated, and both are refused on a bump that crosses nothing at all. A later bump of the same candidate that finds out `--no-migration` was wrong after all retracts that line with `--migration-required` instead of restating `--no-migration` - refused without a migration document already targeting the new base, and without an existing `--no-migration` line to retract. 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 regrade [INDICES...] [--impact high\|medium\|low]` | List the running candidate's bump titles with their impact grade and 1-based rendered position (no arguments - the correction path for a `--impact` judgement made at bump time), or change one or more of them in a single call: `version regrade 3 7 --impact high` grades both against a single read of today's list, not position 3 first and then position 7 against whatever that produced. Touches only the topmost entry's bump list - never `VERSION`, never any other part of `CHANGES.md`. The bare listing is read-only and exempt from the Iteration Budget Gate, like `version notes`; a call with indices writes `CHANGES.md` and is counted like `version bump`. Refuses an index outside the rendered list's range, an unknown `--impact`, indices given without `--impact`, a missing `VERSION`/`CHANGES.md`, a `VERSION`/newest-changelog-entry mismatch, or a topmost entry with no bump list at all |
|
||||
| `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. Refuses when the candidate collected two or more bumps and the entry still carries no summary paragraph (at least 200 non-whitespace characters) between the bump list and the first `### <bump title>` changeset heading; a candidate with exactly one bump is exempt, since there its own changeset already is the summary. `--dry-run` runs this check too and reports the same refusal. 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` |
|
||||
|
||||
### Content migrations
|
||||
|
||||
@@ -378,9 +379,9 @@ is atomic, and whether a retry is safe.
|
||||
| `types list` | Never fails | Read-only | Safe to retry freely |
|
||||
| `types describe` | Unknown type name | Read-only | Fix the name and retry |
|
||||
| `instructions sync` | No skills found under `instructions/`, or a target directory is not a published skill (no `SKILL.md`) and `--force` was not passed | No - one directory copy per skill per target (`.agents/skills/`, `.claude/skills/`); each copy is idempotent, so a re-run converges even after a partial failure | Check whether the flagged target holds anything worth keeping, then re-run with `--force` if not; otherwise fix the named cause and retry |
|
||||
| `instructions verify` | Nothing found under `instructions/` at all, 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, CLAUDE.md, or a skill and so risks running implicitly), or something under `instructions/dev/` referenced from outside it and outside a `dist:strip` block | Read-only | Fix the flagged file, then re-run. For drift, re-run `sync` instead of hand-editing the published copy - the source under `instructions/` always wins |
|
||||
| `instructions verify` | Nothing found under `instructions/` at all, a malformed instruction or `SKILL.md`, a `SKILL.md` carrying a relative markdown link, a published copy that drifted from its source, an instruction nothing references (or, for `manual: true`, one that IS linked from AGENTS.md, CLAUDE.md, or a skill and so risks running implicitly), or something under `instructions/dev/` referenced from outside it and outside a `dist:strip` block | Read-only | Fix the flagged file, then re-run. For a relative link in a `SKILL.md`, rewrite it as a repo-root-relative plain path instead. For drift, re-run `sync` instead of hand-editing the published copy - the source under `instructions/` always wins |
|
||||
| `instructions list` | Never fails - an empty `instructions/` prints "No instructions found." | Read-only | Safe to retry freely |
|
||||
| `docs verify` | A command, contract, or type-form mismatch was found, a shipped `.md`/`.template` cites an issue number, or a reference file's table-of-contents region is missing or stale | Read-only | Fix the documentation it names, then re-run. For an issue reference: say what was decided instead of pointing at where, or move the pointer behind a `<!-- dist:strip-start/end -->` block. For a table of contents: run `docs toc --apply` - never hand-write the region |
|
||||
| `docs verify` | A command, contract, or type-form mismatch was found, a shipped `.md`/`.template` cites an issue number, a reference file's table-of-contents region is missing or stale, or a reference file's relative markdown link does not resolve to an existing file | Read-only | Fix the documentation it names, then re-run. For an issue reference: say what was decided instead of pointing at where, or move the pointer behind a `<!-- dist:strip-start/end -->` block. For a table of contents: run `docs toc --apply` - never hand-write the region. For a dead link: fix the `../` count or the target's name |
|
||||
| `docs toc` | Never fails on content: a file with no `##` heading, or one at or under the threshold, is simply left without a region | `--apply` rewrites each named file in place, one at a time and idempotently, so a re-run after an interruption converges rather than doubling a region; the dry-run form is read-only | Nothing to fix - re-run with `--apply` to write what the dry run listed. If `docs verify` still reports a stale region afterwards, the file's `##` headings changed in between; run it again |
|
||||
|
||||
### Telemetry
|
||||
@@ -399,8 +400,9 @@ is atomic, and whether a retry is safe.
|
||||
| `version show` | `VERSION` is missing or unparseable | Read-only | Fix `VERSION` and 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 notes` | An unparseable `--version`, an unreadable `VERSION` when `--version` is omitted, a missing `CHANGES.md`, or no entry naming the requested version | Read-only | Fix the named argument or file, then retry. Safe to retry |
|
||||
| `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`, `--breaking`/`--no-migration` on a bump that crosses nothing, or `--migration-required` combined with `--no-migration`, on a bump with no running candidate, with no `--no-migration` line to retract, or without a migration document already targeting the new base | 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 |
|
||||
| `version bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, an unknown `--impact`, 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`, `--breaking`/`--no-migration` on a bump that crosses nothing, or `--migration-required` combined with `--no-migration`, on a bump with no running candidate, with no `--no-migration` line to retract, or without a migration document already targeting the new base | 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 regrade` | A missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry naming different versions, a topmost entry with no bump list, an index outside the rendered list's range, indices given without `--impact`, or an unknown `--impact` | No - `CHANGES.md` only, and only when indices are given | The bare listing never writes anything. A write is **not idempotent** against a changed list: re-running the same indices after a first success regrades whatever is at those positions *now*, which may no longer be the same bumps - list again before retrying |
|
||||
| `version release` | A missing `VERSION`/`CHANGES.md`, `VERSION` already a release (no running candidate), `VERSION` and the changelog's newest entry naming different versions, or (from two bumps on) an entry with no summary paragraph above the changesets | 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 |
|
||||
|
||||
### Content migrations
|
||||
|
||||
|
||||
+10
-5
@@ -91,11 +91,16 @@ bound at import time - `KB_DIR` and friends follow whatever `ROOT` currently is.
|
||||
5. Raise the version: `wikitool version bump --minor --title "..."` for a new
|
||||
command (`--patch` for a fix, `--major` when the new version is **not a
|
||||
drop-in replacement** - a renamed flag or artefact, a stricter check that
|
||||
newly fails on content an instance already had, anything needing hand-work
|
||||
after the copy). Content migration is one way to land there, not the
|
||||
definition of it: a `--major` may well ship `--no-migration`, and one that
|
||||
does migrate also needs a document under `instructions/migrations/`. The
|
||||
full test is `instructions/dev/version-parts.md` - read it before choosing
|
||||
newly fails on **shipped content an instance already had** (a `kb/` page,
|
||||
an `instructions/*.md` file), anything needing hand-work after the copy).
|
||||
A new command that is merely pickier about its *own* fresh input - a flag
|
||||
it did not previously accept, a write it now refuses without more from the
|
||||
caller - is the ordinary MINOR case: nothing an instance already has stops
|
||||
validating, there is simply more to say when the command is next invoked.
|
||||
Content migration is one way to land in the MAJOR row, not the definition
|
||||
of it: a `--major` may well ship `--no-migration`, and one that does
|
||||
migrate also needs a document under `instructions/migrations/`. The full
|
||||
test is `instructions/dev/version-parts.md` - read it before choosing
|
||||
`--major`.
|
||||
A new command reaches every future instance, and CI's version gate refuses a
|
||||
stack change that moved no version.
|
||||
|
||||
@@ -32,6 +32,18 @@ A sixth checks a *reference* rather than a copy: no document `dist export`
|
||||
ships may cite an issue number, because the board those numbers live on
|
||||
exists only in the origin repo.
|
||||
|
||||
A seventh checks the other half of the same reference problem: every relative
|
||||
markdown link in a reference file - `toc.target_files()`'s scope, the same one
|
||||
the table-of-contents check uses - must resolve to a file that actually
|
||||
exists. A link with the wrong `../` count is invisible to every check above:
|
||||
it is present, it names an existing command or contract by title, and nothing
|
||||
renders it to notice the target is unreachable. The complementary half - that
|
||||
`instructions/<name>/SKILL.md` never carries a relative markdown link at all,
|
||||
because `instructions sync` copies it to a different depth than its links
|
||||
assume - is `instructions verify`'s job, not this one, since that module
|
||||
already owns the Skill/Instruction split (`skill_dirs()` vs
|
||||
`instruction_files()`).
|
||||
|
||||
Everything here is a hard oracle: a set comparison or a regex, no judgment.
|
||||
Content quality of the contracts themselves stays with the LLM.
|
||||
"""
|
||||
@@ -44,7 +56,7 @@ from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import config, conventions, kb_collections, toc, version as version_mod
|
||||
from chemenu import config, conventions, kb_collections, markdown_code, toc, version as version_mod
|
||||
from chemenu.commands import dist_cmd
|
||||
from chemenu.commands._util import fail, rel_path, success
|
||||
|
||||
@@ -452,6 +464,83 @@ def check_toc_regions() -> list[str]:
|
||||
return issues
|
||||
|
||||
|
||||
# A markdown link, `[text](target)`. The target excludes `)` and whitespace -
|
||||
# the same restriction every link in this repo's own instructions already
|
||||
# follows; a target needing either would need CommonMark's <angle-bracket>
|
||||
# escaping, which nothing here uses.
|
||||
MARKDOWN_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
|
||||
|
||||
# The suffix `dist export` re-keys an instance-owned file to, and the one
|
||||
# `setup-instance.md` renames away again. Spelled here rather than imported
|
||||
# from `ownership`, whose own `.template` handling answers a different
|
||||
# question (which side an upstream merge keeps) over a narrower scope
|
||||
# (paths under a content stage).
|
||||
TEMPLATE_SUFFIX = ".template"
|
||||
|
||||
|
||||
def is_external_or_anchor(target: str) -> bool:
|
||||
"""A link this check does not resolve as a filesystem path: an absolute
|
||||
URL, a `mailto:`, or a pure in-page `#anchor`.
|
||||
|
||||
Public (not `_`-prefixed): `instructions_cmd.check_skill_reference_paths`
|
||||
imports this alongside `MARKDOWN_LINK_RE` rather than keeping a second
|
||||
copy - the two checks classify the same link shape, just over different
|
||||
file sets (AGENTS.md invariant 8)."""
|
||||
return target.startswith(("http://", "https://", "mailto:", "#"))
|
||||
|
||||
|
||||
def check_reference_targets() -> list[str]:
|
||||
"""Every relative markdown link in a reference file resolves to a real file.
|
||||
|
||||
Scoped to `toc.target_files()` - AGENTS.md, the stage and collection
|
||||
contracts, and every flat `instructions/**.md` file - the same scope the
|
||||
table-of-contents check uses. That scope already excludes `SKILL.md`
|
||||
(banned from carrying a markdown link at all - `instructions verify`'s
|
||||
`check_skill_reference_paths`), `commonplace/` (vendored, not stack
|
||||
material) and `raw/`/`kb/` page content (data, not documentation) beyond
|
||||
the two files that are themselves reference material.
|
||||
|
||||
A target's `#anchor` suffix is stripped before resolving - CommonMark
|
||||
anchors are not filesystem paths, and nothing here renders one to notice
|
||||
a stale one anyway. Code fences and inline code spans are masked first
|
||||
(`markdown_code.strip_code_spans`), so a passage that shows link syntax
|
||||
as an example is not mistaken for a real reference.
|
||||
|
||||
**A target the stack ships only as a `.template` counts as resolving.**
|
||||
`kb/CONVENTIONS.md` and every `kb/<name>/COLLECTION.md` are instance-owned:
|
||||
a distribution carries `<name>.template` and the instance adopts it by
|
||||
renaming, during `instructions/setup-instance.md`'s personalization step.
|
||||
Between `dist export` and that step the real file legitimately does not
|
||||
exist yet - while `kb/CONTRACT.md` and three flat instructions link to it
|
||||
by its adopted name, correctly, because that is the name it will have.
|
||||
Reporting those as dead links would fail a fresh export for doing exactly
|
||||
what it is supposed to do, and would describe "not personalized yet" as a
|
||||
broken link when `doctor`'s `conventions` check already says it precisely.
|
||||
"""
|
||||
issues = []
|
||||
for path in toc.target_files():
|
||||
text = path.read_text(encoding="utf-8")
|
||||
masked = markdown_code.strip_code_spans(text)
|
||||
for line_number, masked_line in enumerate(masked.splitlines(), start=1):
|
||||
for match in MARKDOWN_LINK_RE.finditer(masked_line):
|
||||
target = match.group(1)
|
||||
if is_external_or_anchor(target):
|
||||
continue
|
||||
target_path = target.split("#", 1)[0]
|
||||
if not target_path:
|
||||
continue
|
||||
resolved = (path.parent / target_path).resolve()
|
||||
if resolved.exists():
|
||||
continue
|
||||
if resolved.with_name(resolved.name + TEMPLATE_SUFFIX).exists():
|
||||
continue
|
||||
issues.append(
|
||||
f"{rel_path(path)}:{line_number} links to `{target}`, which does not "
|
||||
"resolve to an existing file"
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def command_table_free_readmes() -> list[Path]:
|
||||
"""Every README that must not carry a copy of the command table.
|
||||
|
||||
@@ -768,7 +857,7 @@ def check_breaking_change_for_boundary() -> list[str]:
|
||||
|
||||
@app.command("verify")
|
||||
def verify():
|
||||
"""Check the CLI/README command tables, contract presence, type-form drift, ignore rules, version/changelog agreement, and issue references in shipped documents."""
|
||||
"""Check the CLI/README command tables, contract presence, type-form drift, ignore rules, version/changelog agreement, issue references, and link targets in shipped documents."""
|
||||
issues = (
|
||||
check_cli_readme()
|
||||
+ check_readmes_have_no_command_table()
|
||||
@@ -780,6 +869,7 @@ def verify():
|
||||
+ check_breaking_change_for_boundary()
|
||||
+ check_no_issue_references()
|
||||
+ check_toc_regions()
|
||||
+ check_reference_targets()
|
||||
)
|
||||
|
||||
if issues:
|
||||
@@ -791,7 +881,8 @@ def verify():
|
||||
f"{len(STAGE_CONTRACTS)} stage contract(s) present, no legacy type blocks, "
|
||||
f"{len(IGNORE_CANARIES)} ignore canaries clear, "
|
||||
f"no issue references in {len(shipped_prose())} shipped document(s), "
|
||||
f"tables of contents current on {len(toc.target_files())} reference file(s), "
|
||||
f"tables of contents current and every link resolving on "
|
||||
f"{len(toc.target_files())} reference file(s), "
|
||||
f"{version_mod.CHANGES_FILENAME} documents version "
|
||||
f"{(config.ROOT / version_mod.VERSION_FILENAME).read_text(encoding='utf-8').strip()}."
|
||||
)
|
||||
|
||||
@@ -20,6 +20,19 @@ Both target directories are gitignored. A fresh clone has no skills until `sync`
|
||||
runs; `instructions/bootstrap.md` is the procedure, and `verify` says so rather
|
||||
than reporting an error when *every* copy is missing, because that is the
|
||||
expected state of a clean checkout rather than a fault.
|
||||
|
||||
The copy is also a different depth than the source, and without the sibling
|
||||
files a relative link might expect - a plain `shutil.copytree` per skill
|
||||
directory, not a mirror of the whole `instructions/` tree. A relative markdown
|
||||
link correct at `instructions/<name>/SKILL.md` therefore resolves to a
|
||||
different, usually nonexistent, file in the published copy the harness
|
||||
actually reads. `verify` forbids the shape outright
|
||||
(`check_skill_reference_paths`) rather than checking depth arithmetic, and a
|
||||
`SKILL.md` writes an outbound reference as a repo-root-relative plain path
|
||||
instead - see instructions/CONTRACT.md § "A skill's outbound reference is a
|
||||
plain path, not a link". `docs_verify.check_reference_targets` is the
|
||||
complementary check, over the flat instructions and contracts that are still
|
||||
allowed to link normally because nothing ever copies them elsewhere.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -31,8 +44,8 @@ from pathlib import Path
|
||||
import typer
|
||||
import yaml
|
||||
|
||||
from chemenu import config
|
||||
from chemenu.commands import dist_cmd
|
||||
from chemenu import config, markdown_code
|
||||
from chemenu.commands import dist_cmd, docs_verify
|
||||
from chemenu.commands._util import fail, rel_path, success
|
||||
from chemenu.type_resolver import resolver
|
||||
|
||||
@@ -313,6 +326,45 @@ def dev_only_forbidden_references(instructions_dir: Path | None = None) -> set[s
|
||||
return referenced
|
||||
|
||||
|
||||
def check_skill_reference_paths() -> list[str]:
|
||||
"""No `SKILL.md` may carry a relative markdown link.
|
||||
|
||||
`sync` copies each skill directory verbatim into `.agents/skills/<name>/`
|
||||
and `.claude/skills/<name>/` - a different depth than
|
||||
`instructions/<name>/SKILL.md` itself, and without the sibling files a
|
||||
relative link might expect. A markdown link that resolves correctly at
|
||||
the source (`../session-setup.md`, `../../kb/CONTRACT.md`) resolves to a
|
||||
different, usually nonexistent, file once copied: the number of `../`
|
||||
segments that reaches a target from `instructions/<name>/` does not reach
|
||||
the same target from `.claude/skills/<name>/`.
|
||||
|
||||
So a `SKILL.md` never writes an outbound reference as a relative markdown
|
||||
link - it names the target as a repo-root-relative plain path instead
|
||||
(`` `instructions/session-setup.md` ``, not
|
||||
`[session-setup.md](../session-setup.md)`). See instructions/CONTRACT.md
|
||||
§ "A skill's outbound reference is a plain path, not a link" for why that
|
||||
form survives the copy unchanged.
|
||||
`docs_verify.check_reference_targets` is the complementary check, over the
|
||||
flat instructions and contracts that are still allowed to link normally
|
||||
because nothing ever copies them elsewhere."""
|
||||
issues: list[str] = []
|
||||
for source in skill_dirs():
|
||||
path = source / SKILL_FILE
|
||||
text = path.read_text(encoding="utf-8")
|
||||
masked = markdown_code.strip_code_spans(text)
|
||||
for line_number, masked_line in enumerate(masked.splitlines(), start=1):
|
||||
for match in docs_verify.MARKDOWN_LINK_RE.finditer(masked_line):
|
||||
target = match.group(1)
|
||||
if docs_verify.is_external_or_anchor(target):
|
||||
continue
|
||||
issues.append(
|
||||
f"{rel_path(path)}:{line_number} carries a relative markdown link to "
|
||||
f"`{target}` - `instructions sync` copies this file to a different depth, "
|
||||
"so write the target as a plain repo-root-relative path instead"
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
@app.command("sync")
|
||||
def sync(
|
||||
force: bool = typer.Option(
|
||||
@@ -351,7 +403,7 @@ def sync(
|
||||
|
||||
@app.command("verify")
|
||||
def verify():
|
||||
"""Check instructions/ against its type, and every published copy against its source."""
|
||||
"""Check instructions/ against its type, that no skill carries a relative markdown link, and every published copy against its source."""
|
||||
sources = skill_dirs()
|
||||
instructions = instruction_files()
|
||||
if not sources and not instructions:
|
||||
@@ -398,7 +450,11 @@ def verify():
|
||||
if not frontmatter.get("description"):
|
||||
issues.append(f"{source.name}: SKILL.md is missing (or has an empty) `description`")
|
||||
|
||||
# 3. Published copies match their sources. Missing *everywhere* is a clean
|
||||
# 3. No skill carries a relative markdown link - see
|
||||
# check_skill_reference_paths's own docstring for why the copy breaks it.
|
||||
issues.extend(check_skill_reference_paths())
|
||||
|
||||
# 4. Published copies match their sources. Missing *everywhere* is a clean
|
||||
# checkout, not a fault - say what to run instead of reporting drift.
|
||||
expected = len(sources) * len(target_dirs())
|
||||
missing = 0
|
||||
@@ -419,7 +475,7 @@ def verify():
|
||||
if missing and not bootstrap_needed:
|
||||
issues.append(f"{missing} published copy/copies missing - run `wikitool instructions sync`")
|
||||
|
||||
# 4. An instruction nothing loads is inert. Nothing else would report it -
|
||||
# 5. An instruction nothing loads is inert. Nothing else would report it -
|
||||
# unless it is `manual: true`, which inverts the rule over a narrower
|
||||
# haystack: that instruction must not be linked from AGENTS.md or a
|
||||
# skill (automatic pickup), though a CONTRACT.md mentioning it by name
|
||||
@@ -441,7 +497,7 @@ def verify():
|
||||
"Link it from a skill, a contract, AGENTS.md, or CLAUDE.md, or delete it."
|
||||
)
|
||||
|
||||
# 5. instructions/dev/ is a hard boundary: `dist export` prunes it whole,
|
||||
# 6. instructions/dev/ is a hard boundary: `dist export` prunes it whole,
|
||||
# so nothing outside it may depend on something inside it staying
|
||||
# around in a distributed instance. See dev_only_forbidden_references's
|
||||
# docstring for the dist:strip exemption.
|
||||
|
||||
@@ -125,7 +125,15 @@ def is_exempt(command: str, args: list[str]) -> bool:
|
||||
if command in SKIP_COMMANDS:
|
||||
return True
|
||||
subcommand = args[0] if args and not args[0].startswith("-") else ""
|
||||
return (command, subcommand) in SKIP_COMMAND_PATHS
|
||||
if (command, subcommand) in SKIP_COMMAND_PATHS:
|
||||
return True
|
||||
# `version regrade` only reads when called with no further arguments at
|
||||
# all - the bare listing. Any index (with `--impact`) writes CHANGES.md
|
||||
# and stays counted like `version bump`, so this cannot join
|
||||
# SKIP_COMMAND_PATHS, which only ever looks at the subcommand slot.
|
||||
if command == "version" and subcommand == "regrade":
|
||||
return len(args) == 1
|
||||
return False
|
||||
|
||||
|
||||
def _session_id() -> str:
|
||||
|
||||
@@ -11,7 +11,14 @@ number means, and `instructions/dev/version-parts.md` for the candidate model):
|
||||
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.
|
||||
a number a release actually consumes. Refuses if the candidate collected
|
||||
more than one bump and its entry still carries no summary above the
|
||||
changesets - see `version_mod.summary_prose`.
|
||||
- `version regrade` lists or changes the impact grade (high/medium/low) of
|
||||
the running candidate's bump titles, addressed by their position in the
|
||||
rendered list - the correction path for the judgment `version bump
|
||||
--impact` made at the time, per Gitea #95's fix for an unreadably long,
|
||||
ungraded bump list.
|
||||
- `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
|
||||
@@ -20,6 +27,7 @@ number means, and `instructions/dev/version-parts.md` for the candidate model):
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
@@ -210,6 +218,13 @@ def bump_command(
|
||||
"Requires a migration document already targeting the new base, and refuses when the entry "
|
||||
"carries no --no-migration line to retract.",
|
||||
),
|
||||
impact: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--impact",
|
||||
help="high|medium|low - how much this bump matters to a reader of the release notes "
|
||||
"(default: medium). Grouped into the entry's bump list; `version regrade` corrects it "
|
||||
"later if the running candidate's own judgment changes.",
|
||||
),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Report the change without writing"),
|
||||
):
|
||||
"""Raise or continue the running candidate, and open or update its
|
||||
@@ -242,6 +257,10 @@ def bump_command(
|
||||
if not title.strip():
|
||||
fail("--title must not be empty - it becomes the changelog entry's heading")
|
||||
return
|
||||
if impact is not None and impact not in version_mod.IMPACT_LEVELS:
|
||||
fail(f"--impact must be one of {', '.join(version_mod.IMPACT_LEVELS)}, not {impact!r}")
|
||||
return
|
||||
chosen_impact = impact or version_mod.DEFAULT_IMPACT
|
||||
|
||||
try:
|
||||
current = version_mod.read_version()
|
||||
@@ -351,13 +370,15 @@ def bump_command(
|
||||
no_migration_reason=no_migration.strip() if no_migration else None,
|
||||
breaking_reason=breaking.strip() if breaking else None,
|
||||
migration_required=migration_required,
|
||||
impact=chosen_impact,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
impact_note = "" if impact is not None else f" (impact not given - assumed {chosen_impact})"
|
||||
success(
|
||||
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."
|
||||
f"the {version_mod.CHANGES_FILENAME} entry{impact_note} - write its prose before "
|
||||
f"publishing, and `version release` once the candidate is ready to ship."
|
||||
)
|
||||
|
||||
|
||||
@@ -382,7 +403,12 @@ def release_command(
|
||||
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."""
|
||||
to fix. Also refuses - Gitea #95 - when the candidate collected two or
|
||||
more bumps and its entry still has no summary paragraph above the
|
||||
individual changesets: a release note that is only a chronological bump
|
||||
list is exactly the thing this refusal exists to stop shipping. A
|
||||
candidate with exactly one bump is exempt - there, the bump's own
|
||||
changeset already is the summary."""
|
||||
try:
|
||||
current = version_mod.read_version()
|
||||
except VersionError as exc:
|
||||
@@ -412,6 +438,18 @@ def release_command(
|
||||
)
|
||||
return
|
||||
|
||||
section = version_mod.changes_section(text, current) or ""
|
||||
bump_count = len(version_mod.bump_entries(section))
|
||||
summary_chars = len(re.sub(r"\s+", "", version_mod.summary_prose(section)))
|
||||
if bump_count >= 2 and summary_chars < version_mod.SUMMARY_MIN_CHARS:
|
||||
fail(
|
||||
f"This candidate collected {bump_count} bumps, but its {version_mod.CHANGES_FILENAME} "
|
||||
"entry carries no summary above the individual changesets - write a short paragraph "
|
||||
"(a few sentences on what this release is about) right below the bump list before "
|
||||
"releasing. `version regrade` (no arguments) shows the bump list first, if that helps."
|
||||
)
|
||||
return
|
||||
|
||||
new_version = current.base
|
||||
|
||||
if dry_run:
|
||||
@@ -428,3 +466,77 @@ def release_command(
|
||||
f"{version_mod.CHANGES_FILENAME} entry - `publish` next, which moves VERSION onto main and "
|
||||
"is what release.yml reacts to."
|
||||
)
|
||||
|
||||
|
||||
@app.command("regrade")
|
||||
def regrade_command(
|
||||
indices: Optional[list[int]] = typer.Argument(
|
||||
None,
|
||||
help="1-based positions in the rendered bump list to regrade (see the bare listing). "
|
||||
"Omit to just list.",
|
||||
),
|
||||
impact: Optional[str] = typer.Option(
|
||||
None, "--impact", help="high|medium|low - required together with indices"
|
||||
),
|
||||
):
|
||||
"""List the running candidate's bump titles with their impact grade, or
|
||||
change one or more of them in a single call.
|
||||
|
||||
Positions are `version_mod.bump_entries`'s own rendered order - grouped
|
||||
High before Medium before Low, chronological within a grade - as it
|
||||
stands *before* this call: `wikitool version regrade 3 7 --impact high`
|
||||
regrades both against today's list in one read, not #3 first and then #7
|
||||
against whatever regrading #3 produced. Run the bare command again
|
||||
afterwards to see the result and its new numbering.
|
||||
|
||||
The bare listing is read-only and, like `version notes`, exempt from the
|
||||
Iteration Budget Gate; passing indices writes `CHANGES.md` and is counted
|
||||
like `version bump`, because that is what it does."""
|
||||
try:
|
||||
current = version_mod.read_version()
|
||||
except VersionError as exc:
|
||||
fail(str(exc))
|
||||
return
|
||||
|
||||
changes = version_mod.changes_file()
|
||||
if not changes.is_file():
|
||||
fail(f"{version_mod.CHANGES_FILENAME} is missing - there is nothing to regrade")
|
||||
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 regrade. "
|
||||
"Fix whichever is wrong."
|
||||
)
|
||||
return
|
||||
|
||||
section = version_mod.changes_section(text, current) or ""
|
||||
entries = version_mod.bump_entries(section)
|
||||
if not entries:
|
||||
fail(f"{current}'s {version_mod.CHANGES_FILENAME} entry has no bump list to regrade.")
|
||||
return
|
||||
|
||||
if not indices:
|
||||
for position, (level, bump_title) in enumerate(entries, start=1):
|
||||
typer.echo(f"{position}. [{level}] {bump_title}")
|
||||
return
|
||||
|
||||
if impact is None:
|
||||
fail("--impact is required when regrading - pass one of high/medium/low.")
|
||||
return
|
||||
if impact not in version_mod.IMPACT_LEVELS:
|
||||
fail(f"--impact must be one of {', '.join(version_mod.IMPACT_LEVELS)}, not {impact!r}")
|
||||
return
|
||||
|
||||
updates = {index: impact for index in indices}
|
||||
try:
|
||||
new_text = version_mod.regrade(text, current, updates)
|
||||
except VersionError as exc:
|
||||
fail(str(exc))
|
||||
return
|
||||
|
||||
changes.write_text(new_text, encoding="utf-8")
|
||||
success(f"Regraded {len(indices)} bump title(s) to {impact} impact.")
|
||||
|
||||
@@ -533,6 +533,100 @@ def test_no_shipped_document_cites_an_issue():
|
||||
assert docs_verify.check_no_issue_references() == []
|
||||
|
||||
|
||||
def test_a_dead_relative_link_is_reported(tmp_path, monkeypatch):
|
||||
"""Regression guard for the bug this check exists to catch: a `../` count
|
||||
wrong for the file's own depth is invisible to every other check - the
|
||||
name it links to is real, the text renders, and nothing resolves the
|
||||
target to notice it lands nowhere."""
|
||||
fake = tmp_path / "example.md"
|
||||
fake.write_text("See [tools/CONTRACT.md](../tools/CONTRACT.md) for the command table.\n", encoding="utf-8")
|
||||
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||
issues = docs_verify.check_reference_targets()
|
||||
assert len(issues) == 1
|
||||
assert "example.md:1" in issues[0]
|
||||
assert "../tools/CONTRACT.md" in issues[0]
|
||||
|
||||
|
||||
def test_a_resolving_relative_link_is_not_reported(tmp_path, monkeypatch):
|
||||
(tmp_path / "tools").mkdir()
|
||||
(tmp_path / "tools" / "CONTRACT.md").write_text("# Contract\n", encoding="utf-8")
|
||||
fake = tmp_path / "example.md"
|
||||
fake.write_text("See [tools/CONTRACT.md](tools/CONTRACT.md) for the command table.\n", encoding="utf-8")
|
||||
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||
assert docs_verify.check_reference_targets() == []
|
||||
|
||||
|
||||
def test_an_absolute_url_is_not_resolved_as_a_path(tmp_path, monkeypatch):
|
||||
fake = tmp_path / "example.md"
|
||||
fake.write_text("See [Anthropic](https://www.anthropic.com).\n", encoding="utf-8")
|
||||
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||
assert docs_verify.check_reference_targets() == []
|
||||
|
||||
|
||||
def test_a_section_anchor_is_stripped_before_resolving(tmp_path, monkeypatch):
|
||||
"""CommonMark anchors are not filesystem paths - only the path part of
|
||||
`target#anchor` is checked for existence."""
|
||||
(tmp_path / "kb").mkdir()
|
||||
(tmp_path / "kb" / "CONVENTIONS.md").write_text("## Tone\n", encoding="utf-8")
|
||||
fake = tmp_path / "example.md"
|
||||
fake.write_text("See [kb/CONVENTIONS.md § Tone](kb/CONVENTIONS.md#tone).\n", encoding="utf-8")
|
||||
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||
assert docs_verify.check_reference_targets() == []
|
||||
|
||||
|
||||
def test_link_syntax_shown_as_an_example_in_a_fence_is_not_flagged(tmp_path, monkeypatch):
|
||||
"""A passage documenting bad link syntax must not be mistaken for a real
|
||||
reference - code fences are masked before scanning, mirroring `toc.py`."""
|
||||
fake = tmp_path / "example.md"
|
||||
fake.write_text(
|
||||
"Do not write it like this:\n\n```markdown\n[gates.md](../nonexistent.md)\n```\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||
assert docs_verify.check_reference_targets() == []
|
||||
|
||||
|
||||
def test_a_target_shipped_only_as_a_template_is_not_dead(tmp_path, monkeypatch):
|
||||
"""Regression guard for a defect this check shipped with. A fresh
|
||||
`dist export` carries `kb/CONVENTIONS.md.template`, not
|
||||
`kb/CONVENTIONS.md` - the instance adopts it by renaming, during
|
||||
`setup-instance.md`'s personalization step. `kb/CONTRACT.md` and three
|
||||
flat instructions link to the adopted name, correctly. Before this
|
||||
exemption the check reported 13 dead links on a just-exported tree, for
|
||||
doing exactly what a fresh export is supposed to do."""
|
||||
(tmp_path / "kb").mkdir()
|
||||
(tmp_path / "kb" / "CONVENTIONS.md.template").write_text("# Conventions\n", encoding="utf-8")
|
||||
fake = tmp_path / "kb" / "CONTRACT.md"
|
||||
fake.write_text("What this instance decided: [kb/CONVENTIONS.md](CONVENTIONS.md).\n", encoding="utf-8")
|
||||
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||
assert docs_verify.check_reference_targets() == []
|
||||
|
||||
|
||||
def test_a_target_with_neither_the_file_nor_a_template_is_still_dead(tmp_path, monkeypatch):
|
||||
"""The exemption is narrow: it covers a file the stack ships as a
|
||||
template, not any missing target."""
|
||||
(tmp_path / "kb").mkdir()
|
||||
fake = tmp_path / "kb" / "CONTRACT.md"
|
||||
fake.write_text("See [kb/CONVENTIONS.md](CONVENTIONS.md).\n", encoding="utf-8")
|
||||
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||
issues = docs_verify.check_reference_targets()
|
||||
assert len(issues) == 1
|
||||
assert "CONVENTIONS.md" in issues[0]
|
||||
|
||||
|
||||
def test_every_reference_files_link_targets_resolve():
|
||||
"""Forward direction, against the real tree: every relative link in a file
|
||||
`toc.target_files()` covers must resolve - this is what a `../` count
|
||||
wrong for the file's own depth is caught by."""
|
||||
assert docs_verify.check_reference_targets() == []
|
||||
|
||||
|
||||
def test_verify_raises_when_a_reference_target_is_dead(monkeypatch):
|
||||
monkeypatch.setattr(docs_verify, "check_reference_targets", lambda: ["dangling"])
|
||||
with pytest.raises(typer.Exit):
|
||||
docs_verify.verify()
|
||||
|
||||
|
||||
def test_a_cited_issue_number_is_reported(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
docs_verify,
|
||||
|
||||
@@ -341,6 +341,123 @@ def test_a_self_mention_does_not_count_as_a_reference(layer):
|
||||
assert "gates.md" not in instructions_cmd.referenced_names()
|
||||
|
||||
|
||||
# --- skill reference paths ---------------------------------------------------
|
||||
|
||||
|
||||
def test_the_fixture_skill_carries_no_relative_link(layer):
|
||||
"""Forward direction: the fixture's own `wiki-demo/SKILL.md` mentions
|
||||
`gates.md` as a bare word (`referenced_names()` relies on exactly that
|
||||
substring match), never as a markdown link - so it must not trip the ban."""
|
||||
assert instructions_cmd.check_skill_reference_paths() == []
|
||||
|
||||
|
||||
def test_a_relative_markdown_link_in_a_skill_is_reported(layer):
|
||||
"""`sync` copies `wiki-demo/SKILL.md` to `.claude/skills/wiki-demo/SKILL.md`
|
||||
and `.agents/skills/wiki-demo/SKILL.md` - a different depth than the
|
||||
source - so a link written `../gates.md` here would resolve to a
|
||||
different, usually nonexistent, file once published."""
|
||||
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
||||
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
||||
"# Demo\n\nSee [gates.md](../gates.md).\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
issues = instructions_cmd.check_skill_reference_paths()
|
||||
assert len(issues) == 1
|
||||
assert "wiki-demo" in issues[0] and "../gates.md" in issues[0]
|
||||
|
||||
|
||||
def test_a_plain_root_relative_path_is_not_a_reported_link(layer):
|
||||
"""The decided fix: a `SKILL.md` names its target as a repo-root-relative
|
||||
plain path, not a link - even one written correctly with brackets and
|
||||
parens. `instructions/gates.md` on its own, with no `[...]`, must pass."""
|
||||
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
||||
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
||||
"# Demo\n\nSee `instructions/gates.md`.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert instructions_cmd.check_skill_reference_paths() == []
|
||||
|
||||
|
||||
def test_an_absolute_url_in_a_skill_is_not_reported(layer):
|
||||
"""The ban is about relative paths breaking under the copy - an external
|
||||
link is not affected by where the file sits, so it stays a normal link."""
|
||||
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
||||
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
||||
"# Demo\n\nSee [the spec](https://example.com/spec).\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert instructions_cmd.check_skill_reference_paths() == []
|
||||
|
||||
|
||||
def test_a_pure_anchor_link_in_a_skill_is_not_reported(layer):
|
||||
"""A same-page `#anchor` link is not a filesystem reference and does not
|
||||
move when the file is copied."""
|
||||
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
||||
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
||||
"# Demo\n\n## Steps\n\nSee [Steps](#steps) above.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert instructions_cmd.check_skill_reference_paths() == []
|
||||
|
||||
|
||||
def test_link_syntax_shown_as_an_example_in_a_fence_is_not_flagged(layer):
|
||||
"""A skill documenting the banned syntax as an example (rather than using
|
||||
it) must not trip its own ban - code fences are masked before scanning,
|
||||
the same way `toc.py` masks them before hunting for headings."""
|
||||
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
||||
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
||||
"# Demo\n\nDo not write it like this:\n\n"
|
||||
"```markdown\n[gates.md](../gates.md)\n```\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert instructions_cmd.check_skill_reference_paths() == []
|
||||
|
||||
|
||||
def test_verify_rejects_a_skill_with_a_relative_markdown_link(layer):
|
||||
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
||||
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
||||
"# Demo\n\nSee [gates.md](../gates.md).\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
instructions_cmd.sync(force=False)
|
||||
with pytest.raises(typer.Exit):
|
||||
instructions_cmd.verify()
|
||||
|
||||
|
||||
def test_a_plain_path_resolves_the_same_regardless_of_which_copy_reads_it(layer):
|
||||
"""The property the whole fix rests on. A repo-root-relative plain path
|
||||
(`instructions/gates.md`) names the same file whether it is read from the
|
||||
source (`instructions/wiki-demo/SKILL.md`) or from either published copy
|
||||
(`.claude/skills/wiki-demo/SKILL.md`, `.agents/skills/wiki-demo/SKILL.md`),
|
||||
because it is resolved against the instance root, never against the
|
||||
reading file's own directory.
|
||||
|
||||
A relative link has no such property, which is the defect this whole
|
||||
check exists to prevent: the same `../gates.md` means
|
||||
`instructions/gates.md` from the source but a nonexistent
|
||||
`.claude/skills/gates.md` / `.agents/skills/gates.md` from either
|
||||
published copy - one directory short of the real file, because `sync`
|
||||
copies the skill one level shallower than `instructions/<name>/` sits."""
|
||||
instructions_cmd.sync(force=False)
|
||||
target = layer / "instructions" / "gates.md"
|
||||
assert target.is_file()
|
||||
|
||||
source_dir = layer / "instructions" / "wiki-demo"
|
||||
claude_copy_dir = _skill_copy(layer, ".claude")
|
||||
agents_copy_dir = _skill_copy(layer, ".agents")
|
||||
|
||||
# The plain path resolves against the instance root, regardless of which
|
||||
# of the three directories above is doing the reading.
|
||||
for _ in (source_dir, claude_copy_dir, agents_copy_dir):
|
||||
assert (layer / "instructions" / "gates.md").is_file()
|
||||
|
||||
# The equivalent relative link would not: correct from the source, wrong
|
||||
# from both copies.
|
||||
assert (source_dir / ".." / "gates.md").resolve() == target.resolve()
|
||||
assert not (claude_copy_dir / ".." / "gates.md").resolve().is_file()
|
||||
assert not (agents_copy_dir / ".." / "gates.md").resolve().is_file()
|
||||
|
||||
|
||||
# --- instructions/dev/ boundary ----------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -189,6 +189,19 @@ def test_search_exemption_survives_a_query_that_looks_like_a_subcommand():
|
||||
assert not run_budget.is_exempt("publish", ["--message", "x"])
|
||||
|
||||
|
||||
def test_version_regrade_bare_listing_is_exempt():
|
||||
"""Gitea #95: `version regrade` only reads when called with no further
|
||||
arguments at all - the listing form. It cannot join SKIP_COMMAND_PATHS
|
||||
(that dict only ever looks at the subcommand slot), so it is its own
|
||||
branch in `is_exempt`."""
|
||||
assert run_budget.is_exempt("version", ["regrade"])
|
||||
|
||||
|
||||
def test_version_regrade_with_indices_is_counted():
|
||||
assert not run_budget.is_exempt("version", ["regrade", "1", "--impact", "high"])
|
||||
assert not run_budget.is_exempt("version", ["regrade", "3"])
|
||||
|
||||
|
||||
def test_reset_command_requires_yes():
|
||||
run_budget.record_and_check("new", ["entity", "--name", "X"], override=False)
|
||||
with pytest.raises(typer.Exit):
|
||||
|
||||
@@ -34,6 +34,17 @@ def tree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _write_summary(tree: Path, words: str = "Summary paragraph. ") -> None:
|
||||
"""Append enough non-whitespace prose above the changesets for `version
|
||||
release`'s summary requirement to pass - the way an author's own edit
|
||||
would, between the bumps region and any `### <bump title>` heading."""
|
||||
path = tree / "CHANGES.md"
|
||||
text = path.read_text(encoding="utf-8")
|
||||
marker = version_mod._BUMPS_CLOSE + "\n"
|
||||
assert marker in text
|
||||
path.write_text(text.replace(marker, marker + "\n" + words * 15 + "\n", 1), encoding="utf-8")
|
||||
|
||||
|
||||
# --- the version itself ----------------------------------------------------
|
||||
|
||||
|
||||
@@ -318,13 +329,237 @@ def test_release_entry_can_replace_the_title():
|
||||
assert "## 0.2.0 - 2026-09-05 - Summarising title" in released
|
||||
|
||||
|
||||
# --- the changelog: impact grading (Gitea #95) ------------------------------
|
||||
|
||||
|
||||
def test_bumps_block_with_all_medium_renders_flat_like_before():
|
||||
section = version_mod._bumps_block([("medium", "First"), ("medium", "Second")])
|
||||
assert version_mod._IMPACT_GROUP_RE.search(section) is None
|
||||
assert "- First" in section and "- Second" in section
|
||||
|
||||
|
||||
def test_bumps_block_groups_by_impact_in_high_medium_low_order():
|
||||
section = version_mod._bumps_block(
|
||||
[("low", "Low one"), ("high", "High one"), ("medium", "Medium one")]
|
||||
)
|
||||
assert section.index("**High impact**") < section.index("**Medium impact**")
|
||||
assert section.index("**Medium impact**") < section.index("**Low impact**")
|
||||
assert "- High one" in section and "- Medium one" in section and "- Low one" in section
|
||||
|
||||
|
||||
def test_bumps_block_omits_empty_groups():
|
||||
section = version_mod._bumps_block([("high", "Only one"), ("low", "Another")])
|
||||
assert "**Medium impact**" not in section
|
||||
|
||||
|
||||
def test_bump_entries_round_trips_through_bumps_block():
|
||||
"""Round-trips the *grouping*, not the original chronological order: the
|
||||
region is grouped by grade, so a reparse reads back grouped too."""
|
||||
entries = [("high", "First"), ("low", "Second"), ("high", "Third")]
|
||||
section = version_mod._bumps_block(entries)
|
||||
assert version_mod.bump_entries(section) == [
|
||||
("high", "First"), ("high", "Third"), ("low", "Second"),
|
||||
]
|
||||
|
||||
|
||||
def test_bump_entries_reads_a_pre_impact_flat_region_as_all_medium():
|
||||
section = f"{version_mod._BUMPS_OPEN}\n- Old style\n- Another\n{version_mod._BUMPS_CLOSE}"
|
||||
assert version_mod.bump_entries(section) == [("medium", "Old style"), ("medium", "Another")]
|
||||
|
||||
|
||||
def test_summary_prose_is_the_text_between_the_bumps_region_and_the_first_changeset():
|
||||
section = (
|
||||
f"## 1.1.0 - 2026-09-12 - Title\n\n**Author:** Someone\n\n"
|
||||
f"{version_mod._bumps_block([('medium', 'A')])}\n\n"
|
||||
"This is the summary.\n\n### A\n\nDetail.\n"
|
||||
)
|
||||
assert version_mod.summary_prose(section).strip() == "This is the summary."
|
||||
|
||||
|
||||
def test_regrade_changes_several_indices_against_one_read():
|
||||
text = CHANGES_HEADER + version_mod.insert_changes_entry(
|
||||
"", Version(0, 2, 0, beta=1), "2026-09-01", "First", "Someone",
|
||||
)
|
||||
text = version_mod.insert_changes_entry(text, Version(0, 2, 0, beta=2), "2026-09-02", "Second", "Someone")
|
||||
text = version_mod.insert_changes_entry(text, Version(0, 2, 0, beta=3), "2026-09-03", "Third", "Someone")
|
||||
regraded = version_mod.regrade(text, Version(0, 2, 0, beta=3), {1: "high", 3: "low"})
|
||||
section = version_mod.changes_section(regraded, Version(0, 2, 0, beta=3))
|
||||
assert version_mod.bump_entries(section) == [
|
||||
("high", "First"), ("medium", "Second"), ("low", "Third"),
|
||||
]
|
||||
|
||||
|
||||
def test_regrade_refuses_an_out_of_range_index():
|
||||
text = CHANGES_HEADER + version_mod.insert_changes_entry(
|
||||
"", Version(0, 2, 0, beta=1), "2026-09-01", "Only", "Someone",
|
||||
)
|
||||
with pytest.raises(VersionError):
|
||||
version_mod.regrade(text, Version(0, 2, 0, beta=1), {5: "high"})
|
||||
|
||||
|
||||
# --- version bump: --impact -------------------------------------------------
|
||||
|
||||
|
||||
def test_bump_without_impact_defaults_to_medium(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
section = version_mod.changes_section(changes, version_mod.read_version())
|
||||
assert version_mod.bump_entries(section) == [("medium", "First bump")]
|
||||
|
||||
|
||||
def test_bump_with_impact_grades_the_bump_and_a_follow_up_regroups_the_list(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, migration_required=False, impact="high", dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Second bump",
|
||||
breaking=None, no_migration=None, migration_required=False, impact="low", dry_run=False,
|
||||
)
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
section = version_mod.changes_section(changes, version_mod.read_version())
|
||||
assert version_mod.bump_entries(section) == [("high", "First bump"), ("low", "Second bump")]
|
||||
assert "**High impact**" in section and "**Low impact**" in section
|
||||
|
||||
|
||||
def test_bump_refuses_an_unknown_impact(tree):
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, migration_required=False, impact="urgent", dry_run=False,
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||
|
||||
|
||||
def test_breaking_line_sits_above_the_bumps_region(tree):
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Breaking",
|
||||
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
|
||||
impact=None, dry_run=False,
|
||||
)
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
assert changes.index(version_mod.BREAKING_CHANGE_MARKER) < changes.index(version_mod._BUMPS_OPEN)
|
||||
|
||||
|
||||
# --- version regrade --------------------------------------------------------
|
||||
|
||||
|
||||
def test_regrade_lists_the_running_candidates_bumps(tree, capsys):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, migration_required=False, impact="high", dry_run=False,
|
||||
)
|
||||
version_cmd.regrade_command(indices=None, impact=None)
|
||||
out = capsys.readouterr().out
|
||||
assert "1. [high] First bump" in out
|
||||
|
||||
|
||||
def test_regrade_writes_the_new_impact_and_leaves_version_untouched(tree, capsys):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Second bump",
|
||||
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
version_before = (tree / "VERSION").read_text(encoding="utf-8")
|
||||
version_cmd.regrade_command(indices=[1, 2], impact="high")
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8") == version_before
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
section = version_mod.changes_section(changes, version_mod.read_version())
|
||||
assert version_mod.bump_entries(section) == [("high", "First bump"), ("high", "Second bump")]
|
||||
|
||||
|
||||
def test_regrade_refuses_without_impact(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.regrade_command(indices=[1], impact=None)
|
||||
|
||||
|
||||
def test_regrade_refuses_an_out_of_range_index_via_the_cli(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.regrade_command(indices=[9], impact="high")
|
||||
|
||||
|
||||
def test_regrade_fails_without_a_running_candidate(tree):
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.regrade_command(indices=None, impact=None)
|
||||
|
||||
|
||||
# --- version release: the summary requirement (Gitea #95) -------------------
|
||||
|
||||
|
||||
def test_release_refuses_two_bumps_with_no_summary(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Second bump",
|
||||
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.release_command(title=None, dry_run=False)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.2"
|
||||
|
||||
|
||||
def test_release_dry_run_also_refuses_without_a_summary(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Second bump",
|
||||
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.release_command(title=None, dry_run=True)
|
||||
|
||||
|
||||
def test_release_passes_with_two_bumps_once_a_summary_is_written(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Second bump",
|
||||
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
_write_summary(tree)
|
||||
version_cmd.release_command(title=None, dry_run=False)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
|
||||
|
||||
|
||||
def test_release_passes_with_a_single_bump_and_no_summary(tree):
|
||||
"""The one-bump exemption: there, the bump's own changeset already is the
|
||||
summary. `test_release_fixes_version_and_the_changelog_heading` already
|
||||
covers this shape; this test names the exemption explicitly."""
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="Only bump",
|
||||
breaking=None, no_migration=None, migration_required=False, impact=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"
|
||||
|
||||
|
||||
# --- version bump ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="Something happened",
|
||||
breaking=None, no_migration=None, migration_required=False, dry_run=False,
|
||||
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
@@ -336,11 +571,11 @@ def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
|
||||
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, migration_required=False, dry_run=False,
|
||||
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Second",
|
||||
breaking=None, no_migration=None, migration_required=False, dry_run=False,
|
||||
breaking=None, no_migration=None, migration_required=False, impact=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")
|
||||
@@ -350,7 +585,7 @@ def test_a_second_bump_continues_the_same_candidate_instead_of_opening_another(t
|
||||
|
||||
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, migration_required=False, dry_run=True
|
||||
major=False, minor=False, patch=True, title="Nope", breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=True
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||
assert "1.0.1" not in (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
@@ -363,7 +598,7 @@ def test_bump_demands_exactly_one_part(tree, flags):
|
||||
major, minor, patch = flags
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.bump_command(
|
||||
major=major, minor=minor, patch=patch, title="x", breaking=None, no_migration=None, migration_required=False, dry_run=False
|
||||
major=major, minor=minor, patch=patch, title="x", breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||
|
||||
@@ -371,7 +606,7 @@ def test_bump_demands_exactly_one_part(tree, flags):
|
||||
def test_bump_refuses_an_empty_title(tree):
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title=" ", breaking=None, no_migration=None, migration_required=False, dry_run=False
|
||||
major=False, minor=False, patch=True, title=" ", breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False
|
||||
)
|
||||
|
||||
|
||||
@@ -384,7 +619,7 @@ def test_bump_refuses_when_version_and_changelog_disagree(tree):
|
||||
)
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="x", breaking=None, no_migration=None, migration_required=False, dry_run=False
|
||||
major=False, minor=False, patch=True, title="x", breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||
|
||||
@@ -398,7 +633,7 @@ def test_a_boundary_crossing_bump_without_a_migration_is_refused(tree):
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Breaking",
|
||||
breaking="the feed moved", no_migration=None, migration_required=False, dry_run=False,
|
||||
breaking="the feed moved", no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||
|
||||
@@ -413,7 +648,7 @@ def test_a_boundary_crossing_bump_passes_with_a_migration_document(tree):
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Breaking",
|
||||
breaking="every page is retyped", no_migration=None, migration_required=False, dry_run=False,
|
||||
breaking="every page is retyped", no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.1"
|
||||
|
||||
@@ -430,11 +665,11 @@ def test_a_follow_up_bump_at_the_same_stage_need_not_repeat_breaking_or_migratio
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Breaking",
|
||||
breaking="every page is retyped", no_migration=None, migration_required=False, dry_run=False,
|
||||
breaking="every page is retyped", no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Follow-up",
|
||||
breaking=None, no_migration=None, migration_required=False, dry_run=False,
|
||||
breaking=None, no_migration=None, migration_required=False, impact=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")
|
||||
@@ -446,7 +681,7 @@ def test_no_migration_records_the_reason_in_the_changelog(tree):
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Breaking",
|
||||
breaking="the release feed moved",
|
||||
no_migration="no distributed instance exists yet", migration_required=False, dry_run=False,
|
||||
no_migration="no distributed instance exists yet", migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
assert version_mod.MIGRATION_NONE_MARKER in changes
|
||||
@@ -458,7 +693,7 @@ def test_no_migration_is_refused_on_a_compatible_bump(tree):
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Fix",
|
||||
breaking=None, no_migration="not needed", migration_required=False, dry_run=False,
|
||||
breaking=None, no_migration="not needed", migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -479,7 +714,7 @@ def test_migration_required_retracts_the_no_migration_line(tree):
|
||||
one after all has no other way to take that statement back."""
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Breaking",
|
||||
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False, dry_run=False,
|
||||
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
assert version_mod.MIGRATION_NONE_MARKER in changes
|
||||
@@ -487,7 +722,7 @@ def test_migration_required_retracts_the_no_migration_line(tree):
|
||||
_migration_document(tree, "2.0.0")
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Turns out it migrates",
|
||||
breaking=None, no_migration=None, migration_required=True, dry_run=False,
|
||||
breaking=None, no_migration=None, migration_required=True, impact=None, dry_run=False,
|
||||
)
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
assert version_mod.MIGRATION_NONE_MARKER not in changes
|
||||
@@ -498,12 +733,12 @@ def test_migration_required_retracts_the_no_migration_line(tree):
|
||||
def test_migration_required_is_refused_without_a_migration_document(tree):
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Breaking",
|
||||
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False, dry_run=False,
|
||||
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Turns out it migrates",
|
||||
breaking=None, no_migration=None, migration_required=True, dry_run=False,
|
||||
breaking=None, no_migration=None, migration_required=True, impact=None, dry_run=False,
|
||||
)
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
assert version_mod.MIGRATION_NONE_MARKER in changes # unchanged
|
||||
@@ -513,12 +748,12 @@ def test_migration_required_is_refused_with_no_no_migration_line_to_retract(tree
|
||||
_migration_document(tree, "2.0.0")
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Breaking",
|
||||
breaking="the feed moved", no_migration=None, migration_required=False, dry_run=False,
|
||||
breaking="the feed moved", no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Nothing to retract",
|
||||
breaking=None, no_migration=None, migration_required=True, dry_run=False,
|
||||
breaking=None, no_migration=None, migration_required=True, impact=None, dry_run=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -527,7 +762,7 @@ def test_migration_required_is_refused_together_with_no_migration(tree):
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Contradiction",
|
||||
breaking="the feed moved", no_migration="kb/ untouched",
|
||||
migration_required=True, dry_run=False,
|
||||
migration_required=True, impact=None, dry_run=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -536,7 +771,7 @@ def test_migration_required_is_refused_without_a_running_candidate(tree):
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Fix",
|
||||
breaking=None, no_migration=None, migration_required=True, dry_run=False,
|
||||
breaking=None, no_migration=None, migration_required=True, impact=None, dry_run=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -564,7 +799,7 @@ def test_a_boundary_crossing_bump_without_breaking_is_refused(tree):
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Renamed the feed",
|
||||
breaking=None, no_migration="kb/ keeps its shape", migration_required=False, dry_run=False,
|
||||
breaking=None, no_migration="kb/ keeps its shape", migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||
|
||||
@@ -573,7 +808,7 @@ def test_breaking_records_what_stops_working_in_the_changelog(tree):
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Renamed the feed",
|
||||
breaking="update_url points at a repo path that no longer exists",
|
||||
no_migration="kb/ keeps its shape", migration_required=False, dry_run=False,
|
||||
no_migration="kb/ keeps its shape", migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
assert version_mod.BREAKING_CHANGE_MARKER in changes
|
||||
@@ -590,7 +825,7 @@ def test_breaking_is_refused_on_a_compatible_bump(tree):
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="New command",
|
||||
breaking="nothing, really", no_migration=None, migration_required=False, dry_run=False,
|
||||
breaking="nothing, really", no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||
|
||||
@@ -601,7 +836,7 @@ def test_breaking_is_refused_on_a_compatible_bump(tree):
|
||||
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, migration_required=False, dry_run=False,
|
||||
breaking=None, no_migration=None, migration_required=False, impact=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"
|
||||
@@ -614,12 +849,13 @@ def test_release_fixes_version_and_the_changelog_heading(tree):
|
||||
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, migration_required=False, dry_run=False,
|
||||
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Second bump",
|
||||
breaking=None, no_migration=None, migration_required=False, dry_run=False,
|
||||
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
_write_summary(tree)
|
||||
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
|
||||
@@ -632,7 +868,7 @@ def test_release_can_replace_the_title(tree):
|
||||
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, migration_required=False, dry_run=False,
|
||||
breaking=None, no_migration=None, migration_required=False, impact=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"
|
||||
@@ -647,7 +883,7 @@ def test_release_refuses_when_version_is_already_a_release(tree):
|
||||
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, migration_required=False, dry_run=False,
|
||||
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
(tree / "VERSION").write_text("9.9.9-beta.1\n", encoding="utf-8")
|
||||
with pytest.raises(typer.Exit):
|
||||
@@ -665,11 +901,11 @@ def test_notes_prints_the_entry_for_the_current_version(tree, capsys):
|
||||
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, migration_required=False, dry_run=False,
|
||||
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Second bump",
|
||||
breaking=None, no_migration=None, migration_required=False, dry_run=False,
|
||||
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||
)
|
||||
version_cmd.notes_command(version=None)
|
||||
out = capsys.readouterr().out
|
||||
|
||||
+140
-27
@@ -459,20 +459,125 @@ 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)
|
||||
# Gitea #95: a long-running candidate's bump list grew to 20 chronological,
|
||||
# ungraded titles (v5.0.0, ~1440 lines) - unreadable as a release announcement.
|
||||
# Grading it at bump time, and letting a session regrade it before release,
|
||||
# is the fix; see instructions/dev/version-parts.md § The candidate model.
|
||||
IMPACT_LEVELS = ("high", "medium", "low")
|
||||
DEFAULT_IMPACT = "medium"
|
||||
_IMPACT_GROUP_RE = re.compile(r"^\*\*(High|Medium|Low) impact\*\*$", re.MULTILINE)
|
||||
|
||||
|
||||
def _bumps_block(entries: list[tuple[str, str]]) -> str:
|
||||
"""Render the bumps region from `(impact, title)` pairs.
|
||||
|
||||
Grouped under a `**High/Medium/Low impact**` heading, in that order, each
|
||||
present only if it holds at least one title. **Except** when every entry
|
||||
is `medium` (the default, and the only grade that existed before this):
|
||||
rendered flat, with no heading at all, exactly as `version bump` always
|
||||
wrote it. That keeps a single-bump patch entry, and every entry a build
|
||||
that predates `--impact` ever wrote, byte-identical to what it was.
|
||||
"""
|
||||
if all(impact == DEFAULT_IMPACT for impact, _ in entries):
|
||||
lines = "\n".join(f"- {title}" for _, title in entries)
|
||||
return f"{_BUMPS_OPEN}\n{lines}\n{_BUMPS_CLOSE}"
|
||||
groups: dict[str, list[str]] = {level: [] for level in IMPACT_LEVELS}
|
||||
for impact, title in entries:
|
||||
groups[impact].append(title)
|
||||
rendered = [
|
||||
f"**{level.capitalize()} impact**\n" + "\n".join(f"- {title}" for title in groups[level])
|
||||
for level in IMPACT_LEVELS
|
||||
if groups[level]
|
||||
]
|
||||
return f"{_BUMPS_OPEN}\n" + "\n\n".join(rendered) + f"\n{_BUMPS_CLOSE}"
|
||||
|
||||
|
||||
def _bump_titles(section: str) -> list[str]:
|
||||
def bump_entries(section: str) -> list[tuple[str, str]]:
|
||||
"""The bumps region parsed back into `(impact, title)` pairs, in rendered
|
||||
order - the addressing `version regrade` and `version_cmd.release_command`
|
||||
use.
|
||||
|
||||
A `**<Grade> impact**` heading sets the running grade for the `- ` lines
|
||||
beneath it; a `- ` line with none above it - the shape every region had
|
||||
before `--impact` existed, and the flat shape `_bumps_block` still writes
|
||||
when every grade is `medium` - reads as `medium`. That is what makes an
|
||||
old region parse the same as a new one that happens to grade everything
|
||||
the same way.
|
||||
"""
|
||||
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("- ")
|
||||
entries: list[tuple[str, str]] = []
|
||||
current = DEFAULT_IMPACT
|
||||
for line in match.group(1).strip("\n").splitlines():
|
||||
stripped = line.strip()
|
||||
heading_match = _IMPACT_GROUP_RE.match(stripped)
|
||||
if heading_match:
|
||||
current = heading_match.group(1).lower()
|
||||
continue
|
||||
if stripped.startswith("- "):
|
||||
entries.append((current, stripped[2:].strip()))
|
||||
return entries
|
||||
|
||||
|
||||
# The free-form paragraph `version release` requires above the changesets once
|
||||
# a candidate collected more than one bump - see `summary_prose` and
|
||||
# `version_cmd.release_command`. A number, not a quality judgement: it catches
|
||||
# the empty and the one-line "TODO" case, nothing subtler.
|
||||
SUMMARY_MIN_CHARS = 200
|
||||
_CHANGESET_HEADING_RE = re.compile(r"^### ", re.MULTILINE)
|
||||
|
||||
|
||||
def summary_prose(section: str) -> str:
|
||||
"""The text between the bumps region (or, for an entry with none, the
|
||||
heading) and the first `### <bump title>` changeset heading - the
|
||||
candidate's own summary of what it did, as opposed to the per-bump detail
|
||||
below it.
|
||||
"""
|
||||
close = section.find(_BUMPS_CLOSE)
|
||||
if close != -1:
|
||||
start = close + len(_BUMPS_CLOSE)
|
||||
else:
|
||||
heading_match = _CHANGES_ENTRY_RE.match(section)
|
||||
start = heading_match.end() if heading_match else 0
|
||||
heading = _CHANGESET_HEADING_RE.search(section, start)
|
||||
end = heading.start() if heading else len(section)
|
||||
return section[start:end]
|
||||
|
||||
|
||||
def regrade(text: str, version: "Version", updates: dict[int, str]) -> str:
|
||||
"""Change the impact grade of one or more of the topmost entry's bump
|
||||
titles, addressed by their 1-based position in `bump_entries`'s rendered
|
||||
order.
|
||||
|
||||
All of `updates` are read against a **single** parse of the region, so
|
||||
`{3: "high", 7: "high"}` in one call means "regrade these two against
|
||||
today's list" - not "regrade #3, re-render, then regrade #7 against
|
||||
whatever that produced". `version_cmd.regrade_command` is the only
|
||||
caller; `version` must already equal the entry it addresses (the same
|
||||
VERSION/newest-entry agreement every other write here requires).
|
||||
"""
|
||||
start, end = _entry_span(text)
|
||||
section = text[start:end]
|
||||
heading_match = _CHANGES_ENTRY_RE.match(section)
|
||||
if not heading_match or Version.parse(heading_match.group(1)) != version:
|
||||
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry does not name {version}")
|
||||
|
||||
entries = bump_entries(section)
|
||||
if not entries:
|
||||
raise VersionError(f"{version}'s {CHANGES_FILENAME} entry has no bump list to regrade")
|
||||
out_of_range = sorted(i for i in updates if i < 1 or i > len(entries))
|
||||
if out_of_range:
|
||||
raise VersionError(
|
||||
f"index/indices out of range (1-{len(entries)}): {', '.join(map(str, out_of_range))}"
|
||||
)
|
||||
|
||||
new_entries = [
|
||||
(updates.get(position, impact), title)
|
||||
for position, (impact, title) in enumerate(entries, start=1)
|
||||
]
|
||||
new_section = _BUMPS_RE.sub(lambda _m: _bumps_block(new_entries), section, count=1)
|
||||
return text[:start] + new_section + text[end:]
|
||||
|
||||
|
||||
def _set_marker_line(section: str, marker: str, line: str) -> str:
|
||||
@@ -481,17 +586,17 @@ def _set_marker_line(section: str, marker: str, line: str) -> str:
|
||||
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.
|
||||
Anchored just above the bumps region (not below it, as before Gitea #95):
|
||||
with a graded, potentially 30-line list, the line an operator most needs
|
||||
to act on stayed the deepest thing in the entry otherwise.
|
||||
"""
|
||||
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)
|
||||
anchor = section.find(_BUMPS_OPEN)
|
||||
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:]
|
||||
return section[:anchor] + f"{line}\n\n" + section[anchor:]
|
||||
return section.rstrip() + f"\n\n{line}\n"
|
||||
|
||||
|
||||
def _clear_marker_line(section: str, marker: str) -> str:
|
||||
@@ -525,10 +630,12 @@ def _update_open_candidate(
|
||||
breaking_reason: Optional[str],
|
||||
no_migration_reason: Optional[str],
|
||||
migration_required: bool = False,
|
||||
impact: str = DEFAULT_IMPACT,
|
||||
) -> 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`.
|
||||
`(impact, title)` to its machine-managed bump list, and set the
|
||||
breaking/no-migration lines only where this call supplies them - see
|
||||
`insert_changes_entry`.
|
||||
|
||||
`migration_required` retracts an earlier `--no-migration` line instead of
|
||||
setting one - the two are mutually exclusive on a single bump, enforced by
|
||||
@@ -541,7 +648,9 @@ def _update_open_candidate(
|
||||
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)
|
||||
section = _BUMPS_RE.sub(
|
||||
lambda _m: _bumps_block(bump_entries(section) + [(impact, title)]), section, count=1
|
||||
)
|
||||
|
||||
if breaking_reason:
|
||||
section = _set_marker_line(section, BREAKING_CHANGE_MARKER, f"{BREAKING_CHANGE_MARKER} {breaking_reason}")
|
||||
@@ -562,6 +671,7 @@ def insert_changes_entry(
|
||||
no_migration_reason: Optional[str] = None,
|
||||
breaking_reason: Optional[str] = None,
|
||||
migration_required: bool = False,
|
||||
impact: str = DEFAULT_IMPACT,
|
||||
) -> str:
|
||||
"""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
|
||||
@@ -573,14 +683,17 @@ def insert_changes_entry(
|
||||
the topmost heading still a pre-release" the right test for "is a
|
||||
candidate still open" here.
|
||||
|
||||
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.
|
||||
A fresh entry gets the skeleton only: heading, date, author, - when a
|
||||
compatibility boundary is crossed - the line saying what breaks, plus the
|
||||
line saying no content has to change where that applies, and then the
|
||||
machine-managed bump list (started with this one `(impact, title)` pair,
|
||||
for a candidate). The break comes first, above the bump list rather than
|
||||
below it (Gitea #95): it is what an operator reading the release notes has
|
||||
to act on, the migration line only qualifies it, and neither should sit
|
||||
beneath a list that can run to dozens of graded entries. The entry's
|
||||
actual prose - the release summary, and each bump's own changeset - is
|
||||
written afterwards by whoever made the change, which is also why `bump`
|
||||
refuses to invent a title.
|
||||
|
||||
`migration_required` only has anything to retract on an already-open
|
||||
candidate, so a fresh entry ignores it - there is no earlier
|
||||
@@ -591,16 +704,16 @@ def insert_changes_entry(
|
||||
return _update_open_candidate(
|
||||
text, version, date, title,
|
||||
breaking_reason=breaking_reason, no_migration_reason=no_migration_reason,
|
||||
migration_required=migration_required,
|
||||
migration_required=migration_required, impact=impact,
|
||||
)
|
||||
|
||||
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:
|
||||
lines += [f"{MIGRATION_NONE_MARKER} - {no_migration_reason}", ""]
|
||||
if version.is_prerelease:
|
||||
lines += [_bumps_block([(impact, title)]), ""]
|
||||
entry = "\n".join(lines) + "\n---\n\n"
|
||||
anchor = re.search(r"^## ", text, re.MULTILINE)
|
||||
if anchor:
|
||||
|
||||
Reference in New Issue
Block a user