Compare commits

..

3 Commits

Author SHA1 Message Date
torben 9b461421e8 feat: Korpus-Kuratierungsrichtlinie - Floors und Leitplanke fuer reaktive Fixes (4.2.0, #28)
CI / verify (push) Successful in 52s
Release / release (push) Successful in 37s
Files changed:
- CHANGES.md
- VERSION
- instructions/dev/corpus-policy.md
- instructions/dev/stack-dev/SKILL.md
2026-09-03 08:11:16 +02:00
torben 41f5dfe1cd docs: Issue-Body ist das Plan-File - fortlaufend aktuell, Abschluss ist die letzte Aktualisierung (4.1.2, #44)
CI / verify (push) Successful in 52s
Release / release (push) Successful in 38s
Files changed:
- CHANGES.md
- VERSION
- instructions/dev/issue-tracking.md
- instructions/dev/stack-dev/SKILL.md
2026-09-03 06:39:16 +02:00
torben 23307c3c5f fix: Testisolation - kb_dir repointet config.ROOT, lint loest Kollektionen gegen den uebergebenen Baum auf (4.1.1, #44)
CI / verify (push) Successful in 55s
Release / release (push) Successful in 38s
Files changed:
- CHANGES.md
- VERSION
- instructions/dev/testing-conventions.md
- tools/chemenu/lint_core.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_hermetic_env.py
- tools/chemenu/tests/test_lint.py
2026-09-03 06:33:38 +02:00
10 changed files with 536 additions and 54 deletions
+136
View File
@@ -20,6 +20,142 @@ their date-only headings.
--- ---
## 4.2.0 - 2026-09-03 - Korpus-Kuratierungsrichtlinie: Untergrenzen und Leitplanke für reaktive Fixes
**Author:** Torben Nehmer
Ein Demo-Korpus will klein und stabil sein, ein Testbett groß, unordentlich und in Bewegung -
dieses Repo verlangt seit der Veröffentlichung beides vom selben `kb/` (Gitea #28). Die Sitzung
vom 2026-09-02 hatte Fixture, `--with-demo` und ein zweites Repo bereits verworfen; offen blieb
nur, wie kuratiert "kuratiert genug" heißt und welche Leitplanke reaktive Fixes bekommen.
**Neu:** `instructions/dev/corpus-policy.md`. Fünf Untergrenzen, jede mit einer bestehenden
`wikitool`-Prüfung messbar, keine davon durch neuen Tool-Code: jeder Seitentyp und jeder
deklarierte Subtyp mit mindestens einer Seite, mindestens fünf Seiten mit mindestens drei
Quellen, ein bis zehn Orphan-Seiten, im Schnitt mindestens vier ausgehende Wikilinks pro Seite.
Gemessen am 2026-09-03: 181 Seiten, alle Typ-/Subtyp-Floors erfüllt, 12 Seiten mit ≥3 Quellen, 3
Orphans, Ø 6,2 ausgehende Links - der Korpus war bereits groß genug, ohne dass eine einzige
Seite eigens dafür angelegt werden musste. Eine Untergrenze wird nie durch eine erfundene Seite
gefüllt, sondern durch eine echte Quelle beim nächsten passenden Ingest - Invariante 3 gilt
unverändert.
**Die Leitplanke für reaktive Fixes** unterscheidet drei Stufen: punktuelle Änderungen (immer
erlaubt, gewöhnliche Arbeit), korpusweite Änderungen (nur geplant, mit eigenem Issue und
`work/`-Run - trifft eine Session das Mass-Update-Gate während sie etwas anderes tat, holt sie
sich nicht den `--confirm`-Token, sondern stoppt und legt ein Issue an) und reaktive Eingriffe
in Korpusinhalt, um einen Test grün zu machen oder einen Tool-Bug zu umgehen (nie erlaubt,
Invariante 7). Das Verhältnis zu `kb_dir`/`raw_dir` und `test_pipeline_l0.py` bleibt wie im
ursprünglichen Befund: kleiner, isolierter Fall in der Fixture, großer, vernetzter Fall in
`kb/` - keine Fixture-Extraktion aus dem Korpus.
Dev-only und rein additiv - kein Feld, kein Kommando, keine Datei außerhalb von
`instructions/dev/` ändert sich, daher `--minor` ohne `--breaking`.
**Migration:** none required.
Berührt: `instructions/dev/corpus-policy.md` (neu),
`instructions/dev/stack-dev/SKILL.md` (Schritt 2, Routing-Zeile).
---
## 4.1.2 - 2026-09-03 - Issue-Abschluss ist ein Body-Rewrite, nicht nur ein Kommentar
**Author:** Torben Nehmer
Aufgefallen beim Schließen von #44: der Abschlussbericht stand als Kommentar da, der Body
darunter weiterhin als offene Arbeit — Abschnitt „Zu entscheiden" über eine längst getroffene
Entscheidung, ungehakte Checkliste, Präsens über einen Defekt, den es nicht mehr gab.
Die Regel gab es dafür schon: Schritt 2 von `instructions/dev/issue-tracking.md` sagt, der Body
ist die aktuelle Wahrheit und wird umgeschrieben, wenn sich der Stand ändert. Nur ließ die
Formulierung offen, *wann* — und Schritt 7 („Close with what actually happened") war vollständig
erfüllbar, ohne den Body anzufassen. Ein Abschlussbericht im Kommentar fühlt sich beim Schreiben
vollständig an; dass der Body dabei zurückbleibt, merkt erst der nächste Leser.
**Schritt 2 ist deshalb schärfer geworden: der Body ist das Plan-File dieses Stacks.** Dasselbe,
was das Plan-Dokument eines Harness ist, und genauso gepflegt — fortlaufend, sobald etwas darin
nicht mehr stimmt, nicht am Ende. Der Maßstab ist der Abbruch, nicht der Meilenstein: eine
Session kann jederzeit enden, und was der Body in diesem Moment sagt, ist die vollständige
Übergabe. Eine frische Session muss zu **jedem** Zeitpunkt allein aus dem Body weiterarbeiten
können, ohne Kommentare rückwärts zu lesen und ohne einen Menschen, der es neu erklärt. Entschieden
ersetzt die Frage, erledigt hakt das Kriterium ab, verworfen steht mit Begründung dort, wo das
Kriterium stand.
Schritt 7 ist damit kein Sonderakt mehr, sondern die letzte dieser Aktualisierungen: erst Body
auf den Endstand, dann schließen, dann die Changelog-Zeile aus Schritt 3. Wer Schritt 2 befolgt
hat, ist fast fertig; wer nicht, zahlt die ganze Schuld im schlechtesten Moment — der
geschlossene Body ist die Fassung, die danach alle lesen und niemand mehr aufsucht. #44 steht
als Beispiel drin.
Schritt 3 zieht die Konsequenz: **ein Kommentar pro Session-Umfang, nicht pro Edit.** Ein
fortlaufend gepflegter Body mit einem Changelog-Kommentar je Änderung wäre Lärm; triviale Pflege
braucht gar keinen. Der `stack-dev`-Skill sagt es beim Aufgreifen mit, weil dort die Entscheidung
fällt, ob eine Session den Body überhaupt anfasst.
**Und die ehrliche Antwort auf die Frage nach dem Tooling: es gibt keins, und es soll keins
geben.** `wikitool` kennt diesen Tracker nicht. Es wird an Instanzen ausgeliefert, die unter
dieser URL keine Issues haben, während `instructions/dev/` von `dist export` gepruned wird —
ein Gitea-Client im ausgelieferten Tool wäre eine Dev-Abhängigkeit, die jede Instanz mitträgt,
um ein Board zu prüfen, das keine von ihnen hat. Der Tracker ist ausschließlich über
`gitea-mcp` erreichbar, also in einer Session, durch einen Agenten.
Kein `docs verify` fängt also einen geschlossenen Issue, dessen Body offen klingt, einen Body,
der seinen eigenen Kommentaren widerspricht, oder ein fehlendes Pflichtlabel. Das steht jetzt
als eigener Abschnitt „What no tool checks" in der Instruktion — nicht als Bedauern, sondern als
Begründung dafür, warum die Reihenfolge in Schritt 7 ausgeschrieben ist statt aus Schritt 2
erschlossen zu werden.
---
## 4.1.1 - 2026-09-03 - Testisolation: kb_dir repointet config.ROOT, lint löst Kollektionen gegen den übergebenen Baum auf
**Author:** Torben Nehmer
Issue #44, gefunden beim Bau der Migrations-Gate-Tests für 4.1.0: die `kb_dir`-Fixture baute
ihren Baum unter `tmp_path`, ließ `config.ROOT` aber auf dem echten Checkout stehen. Jeder
Codepfad, der eine Datei über `config.ROOT`/`config.KB_DIR` auflöst statt über das übergebene
Verzeichnis, traf damit das echte Repository.
**Der laute Fall** war ein Test, der `kb_state.write_kb_state()` rief und dabei das
`.wikitool-kb.json` des Repos überschrieb — Applied-Ledger leer statt zwei Einträgen. In
`git status` sofort sichtbar und reversibel; bei einer gitignorierten Datei wäre es das nicht
gewesen.
**Der stillere Fall** ist der teurere. `lint`s Kollektions-Lookup löste eine Seite gegen
`config.KB_DIR` auf. Für eine Seite unter `tmp_path/kb/` warf das `ValueError`, die Funktion
antwortete „keine Kollektion", und die Label-Autorisierung übersprang die Kante wortlos.
`unauthorised_labels` war damit faktisch ungetestet — jeder Test, der das Finding hätte
auslösen können, bekam eine leere Liste und behauptete nichts. Ein grüner Lauf, der wie eine
Zusicherung aussah.
**Der Fix ist der Codepfad, nicht die Fixture.** `run_lint()` bekommt ein Verzeichnis
übergeben und löst jetzt auch intern dagegen auf; `authorised_labels()` bekommt denselben Baum
gereicht, statt auf `config.KB_DIR` zurückzufallen. Der Regressionstest lintet einen Baum, von
dem `ROOT` bewusst wegzeigt — genau der Fall, den die alte Auflösung verschluckte. Eine Funktion,
die ein Verzeichnis entgegennimmt, löst dagegen auf: keine Fixture kann diese Form von außen
reparieren.
**Beide Korpus-Fixturen repointen jetzt.** `kb_dir` tut, was `raw_dir` längst tat — `ROOT` auf
das eigene `tmp_path`, plus `use_shipped_type_specs()`. Der Suite-Lauf kippte dadurch keinen
einzigen Test. Die lokale `rooted_kb`-Umgehung aus 4.1.0 entfällt damit; die Auswahl zwischen
zwei fast gleichen Fixturen war Wissen, das nirgends stand.
**Und ein Wächter für die ganze Klasse.** `repository_tree_guard` (session-scoped, autouse)
vergleicht `git status --porcelain` vor und nach dem Lauf und lässt die Suite scheitern, wenn
sich im Checkout etwas bewegt hat — zwei `git status`-Aufrufe pro Lauf, deshalb per Default an.
Er vergleicht vorher gegen nachher statt einen sauberen Baum zu verlangen, sagt also nichts über
die unveröffentlichte Arbeit des Entwicklers. Den Verursacher benennt er nicht;
`CHEMENU_TREE_GUARD=each` prüft nach jedem Test und tut es. Ohne git oder außerhalb eines
Repositorys sind beide still.
Was der Wächter nicht sieht: eine Prüfung, die unter Test nichts tut, schreibt keine Datei.
Dagegen hilft nur ein Test, der das Finding tatsächlich auslöst — der neue tut das.
`instructions/dev/testing-conventions.md` hat dafür einen eigenen Abschnitt („Which tree a test
writes into"), einen Schritt in der Checkliste und die Regel für neue Fixturen.
---
## 4.1.0 - 2026-09-03 - Link-Taxonomie: Lint-Findings hart ab kb_version 4.0.0, outbound: an das Type-Spec gebunden, part-of/composition als Inversenpaar ## 4.1.0 - 2026-09-03 - Link-Taxonomie: Lint-Findings hart ab kb_version 4.0.0, outbound: an das Type-Spec gebunden, part-of/composition als Inversenpaar
**Author:** Torben Nehmer **Author:** Torben Nehmer
+1 -1
View File
@@ -1 +1 @@
4.1.0 4.2.0
+94
View File
@@ -0,0 +1,94 @@
---
type: types/instruction.md
name: corpus-policy
description: What "curated enough" means for kb/ when it is demo and testbed at once, the measurable floors that define it, and what a reactive fix to the corpus may and may not do.
---
# Keep kb/ curated enough to develop against, without a second corpus
This instance runs one `kb/` for two purposes at once: a public demo and the testbed this stack
is developed against. There is deliberately no fixture corpus, no `--with-demo` export, and no
second repository - see Gitea #28. The corpus's size and shape are set by what targeted
development needs, not by a synthetic fixture size or a demo aesthetic.
## When to run
- Before judging whether the corpus can exercise a change under development - ranking, index
scaling, orphan detection, a new label, a new type-spec.
- Before a reactive fix touches `kb/` content rather than the failing code - the floors below
are what decides whether the fix may proceed as-is.
- Picking up Gitea #28 or #30, or any issue that references this file.
## The floors
Each is mechanically checkable with an existing `wikitool` command; none needs new tool code.
A floor exists to keep some class of bug observable, not to describe an aesthetic target - so
when a session is about to make one of these numbers *worse*, that is the signal to stop and
think, not a number to defend for its own sake.
| Floor | Check | Why this number |
|---|---|---|
| Every page type has ≥1 page | `wikitool search --field type=types/<t>.md` | A type with zero pages means its schema, its collection contract and its lint rules are unexercised |
| Every declared subtype has ≥1 page | `wikitool search --field <x>_type=<v>` | Same reasoning, one level down - `entity_type`, `concept_type`, `source_type` |
| ≥5 pages corpus-wide with ≥3 `sources:` entries | one-off script, see below | Provenance fan-in - multiple sources backing one claim - is a real case only a handful of pages exercise; fewer than 5 and a provenance-index bug can hide |
| Orphan pages (no inbound link) between 1 and 10 | `wikitool lint` | Zero orphans makes orphan detection itself unobservable; more than 10 means the corpus stopped being curated |
| Average outbound wikilinks per page ≥4 | one-off script, see below | Below this, ranking and graph-traversal work has too little structure to exercise |
A floor is a lower bound only. There is no upper bound on page count or on any of these numbers
except the orphan ceiling above - a corpus that outgrows these floors through real ingests is
not a problem this file cares about.
**Measured 2026-09-03** (see Gitea #28): 181 pages, 14/14 types and subtypes covered, 12 pages
with ≥3 sources, 3 orphans, 6.2 average outbound links. All floors held without any manufactured
content - the corpus was already big enough when the question was asked.
A type or subtype sitting at exactly the floor - one page - shows no set-level bugs, only that
the type is *reachable*. That is a soft target for the next `wiki-ingest` that happens to
produce a matching page, never a reason to write one: filing an unsourced page to clear a floor
is exactly what AGENTS.md invariant 3 forbids, floor or no floor. The same holds for an
authorised link label with zero live uses (`wikitool xref` reports these) - fill it when a real
edge calls for it, never manufacture one to exercise the label.
To check the two floors without a dedicated command, walk `kb/**/*.md` (excluding
`INDEX.md`/`COLLECTION.md`/`CONTRACT.md`/`CONVENTIONS.md`), parse frontmatter, and: count pages
whose `related:` array (resolved against page titles) has ≥3 entries for outbound density; count
`sources:` array length ≥3 for the provenance floor. `wikitool search` and `wikitool lint`
cover everything else in the table.
## What a reactive fix may do to kb/ content
Three tiers, by how much of the corpus a change touches:
1. **Pointwise - always allowed.** Creating, updating, renaming or deleting a single page
through the normal tools (`new`, `touch`, the page-lifecycle procedure), below the
Mass-Update Gate's threshold. This is ordinary work and needs no special permission.
2. **Corpus-wide - planned only, never reactive.** A migration, a vocabulary sweep, a bulk
`touch` across many pages. This needs its own issue and, per `work/CONTRACT.md`, a `work/`
run - never a same-session reaction to whatever the session was originally doing. If a
session hits the Mass-Update Gate (exit 42, see `instructions/gates.md`) while working on
something else, it does not fetch the `--confirm` token to push through: it stops, opens an
issue for the corpus-wide change, and finishes the original task without it.
3. **Reactive - never allowed.** Deleting or reshaping a page to make a failing test pass;
restructuring corpus content to route around a tool bug (AGENTS.md invariant 7); using
`kb/` as a scratch surface for a tool experiment. If a stack change under development needs a
corpus shape that does not exist, build it as a pytest fixture (see the next section) -
never manufacture it in `kb/`.
## Relationship to the test fixtures
`tools/chemenu/tests/conftest.py`'s `kb_dir`/`raw_dir` fixtures and `test_pipeline_l0.py` cover
the **small, isolated** case: a handful of pages, built fresh per test, hermetic. `kb/` covers
the **large, connected** case: 181+ pages, grown link density, real provenance history that no
per-test fixture reconstructs economically. The cut: if a `tmp_path` tree can reproduce what the
test needs, it belongs in a fixture; if the test needs density or scale that only a grown corpus
has, it belongs against `kb/`. Neither absorbs the other's job - see
[testing-conventions.md](testing-conventions.md).
## Decision points
- **A floor would be violated by an in-progress change - is that a blocker?** Only for the
orphan ceiling and the type/subtype floors, since those two can go to zero. The density and
provenance floors move gradually with ordinary ingests and are not gating on any single
session.
- **Corpus is "too small" for a feature under development?** That is not this file's problem to
solve by adding pages - see tier 3 above. Either the feature waits for a real ingest to supply
the shape, or it gets a pytest fixture.
+83 -18
View File
@@ -28,8 +28,12 @@ issues at that URL, which is exactly why `dist export` excludes
an assumption nobody has checked, a decision that needs the user. an assumption nobody has checked, a decision that needs the user.
- Picking an issue up: before doing anything else, read the body as the current - Picking an issue up: before doing anything else, read the body as the current
spec, and re-label it if the ground has moved since. spec, and re-label it if the ground has moved since.
- **While working on one:** the body is updated as the state moves, not at the
end (step 2). A session that is interrupted leaves the body as its handover.
- Prioritising: deciding what to pick up next, or re-labelling after the ground - Prioritising: deciding what to pick up next, or re-labelling after the ground
moved. moved.
- Closing one: the body is rewritten to its final state first, and only then
closed (step 7).
## Steps ## Steps
@@ -38,23 +42,47 @@ issues at that URL, which is exactly why `dist export` excludes
specific files or commands involved. An issue that only makes sense to specific files or commands involved. An issue that only makes sense to
whoever wrote it is a note, and notes were the problem. whoever wrote it is a note, and notes were the problem.
2. **Treat the body as the current truth, not as a historical first post.** 2. **The body is the working state, not a historical first post - keep it
Work on one issue spans several sessions, often weeks apart, and the body is current as you go.** It is this stack's plan file: the same thing a harness's
the only thing that connects them: a session opening the issue must be able own plan document is, and it is maintained the same way. Not written once,
to reconstruct what is decided and what is still open from the body alone, not brought up to date at the end, but **updated whenever something in it
without a human re-explaining it. So when the state changes, **rewrite the stops being true** - a decision made, a criterion met, an approach ruled out,
body** - do not append to a text that has become wrong. An additively grown a new constraint found.
log forces every later reader to reconstruct the current state by filtering
the whole history. The test is an abort, not a milestone. A session can end at any moment - an
interrupt, a context limit, a crash, a human walking away - and whatever the
body says at that instant is the entire handover. So the standard is: **at
every point, a fresh session must be able to open the body and pick the work
up from there**, without a human re-explaining it and without reading back
through the comments. If the body would mislead someone who read it right
now, it is already out of date, whether or not the work is finished.
That means updating *during* the work, not only at its end:
- a decision gets made → the decision and its reasoning replace the question
- an acceptance criterion is done → tick it, in the same session that did it
- something turns out differently than the issue assumed → the assumption is
corrected where it stands, not contradicted three paragraphs later
- work is deferred or dropped → say so, with the reason, where the criterion is
**Rewrite, never append.** Do not add to a text that has become wrong: an
additively grown log forces every later reader to reconstruct the current
state by filtering the whole history, which is the exact cost the body exists
to remove. Comments carry the history (step 3); the body carries the state.
Body rewrites and comments are an LLM session's job. A human normally Body rewrites and comments are an LLM session's job. A human normally
touches only labels and metadata directly. touches only labels and metadata directly.
3. **Comment a changelog, never a copy.** Every body rewrite gets one short 3. **Comment a changelog, never a copy.** A body rewrite gets one short comment
comment naming only what changed against the previous state - what is new, naming only what changed against the previous state - what is new, what is
what is gone, what was corrected. Do not snapshot the old body into a gone, what was corrected. Do not snapshot the old body into a comment: a full
comment: a full copy per revision forces a human to diff two prose texts, copy per revision forces a human to diff two prose texts, which is not a
which is not a readable history, only another copy. readable history, only another copy.
One comment per *session's worth* of change, not per edit. Step 2 asks the
body to be kept current continuously, and a comment for every tick would bury
the board in noise; the changelog line summarises what that session moved.
Trivial upkeep - a typo, a tightened sentence - needs no comment at all.
``` ```
**Changelog:** Decision 2 tightened - `kind/` may now change over an **Changelog:** Decision 2 tightened - `kind/` may now change over an
@@ -127,10 +155,46 @@ issues at that URL, which is exactly why `dist export` excludes
answered can drop a size and move `kind/decision` to `kind/build`. Silent answered can drop a size and move `kind/decision` to `kind/build`. Silent
re-labelling is how a board stops meaning anything. re-labelling is how a board stops meaning anything.
7. **Close with what actually happened**, not with a commit hash alone: which 7. **Closing is the last body update, not a comment.** If step 2 was followed
proposals were implemented, which were deliberately left out and why, and the body is already nearly there, and closing only settles what the final
what was verified. The issue is the only place that record survives - a run established. If it was not, closing is where the whole debt comes due -
changelog entry says what changed, not what was decided against. and it comes due at the worst moment, because a closed body is the version
everyone reads afterwards and nobody revisits.
Either way the body reaches its final state *before* the issue closes:
proposals that were decided read as decided, a "to decide" section has become
the decision with its reasoning, acceptance criteria are ticked or struck with
a reason, and what was verified is named. Then close, with the one-line
changelog comment step 3 asks for.
Record what actually happened, not a commit hash alone: which proposals were
implemented, which were deliberately left out and why, and what was verified.
The issue is the only place that record survives - a changelog entry says
what changed, not what was decided against.
**A closing report in a comment does not satisfy this.** It reads as
complete to whoever writes it and leaves a body still phrased as open work:
unticked boxes, an undecided decision section, present tense about a defect
that no longer exists. #44 closed exactly that way, with a thorough comment
above a body that still asked for a decision that had already been made and
shipped. Nothing mechanical catches it (see below), which is why it is a step
rather than a habit.
## What no tool checks
`wikitool` does not know this tracker exists, and should not learn. It ships to
instances that have no issues at that URL, while this file and the workflow it
describes are pruned by `dist export` - a Gitea client inside the shipped tool
would be a dev-only dependency carried by every instance, to check a board none
of them have. The tracker is reachable only through the `gitea-mcp` server, in a
session, by an agent.
So there is no `docs verify` for the board. Nothing reports a closed issue whose
body still reads as open, a body that contradicts its own comments, or an issue
missing one of the four mandatory labels. Every one of those is caught by a
session following this file, or not at all - which is the argument for the
sequence in step 7 being explicit about the order (body first, then close),
rather than leaving it to be inferred from step 2.
## Decision points ## Decision points
@@ -144,7 +208,8 @@ issues at that URL, which is exactly why `dist export` excludes
- **Rewrite the body, or add a comment?** Rewrite whenever a reader of the body - **Rewrite the body, or add a comment?** Rewrite whenever a reader of the body
alone would otherwise be misled - a changed decision, a dropped criterion, a alone would otherwise be misled - a changed decision, a dropped criterion, a
new constraint. A comment carries the changelog line for that rewrite, and new constraint. A comment carries the changelog line for that rewrite, and
nothing else that a future session needs in order to act. nothing else that a future session needs in order to act. Closing an issue is
always a rewrite - see step 7.
- **An old issue carries only `prio/` and `size/`?** Complete it to all four - **An old issue carries only `prio/` and `size/`?** Complete it to all four
when you touch it, rather than in a sweep. The board reaches the new scheme when you touch it, rather than in a sweep. The board reaches the new scheme
issue by issue, as each is picked up. issue by issue, as each is picked up.
+9 -3
View File
@@ -43,15 +43,21 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
engineering, memory and deploy-time learning; consult before a design decision in those engineering, memory and deploy-time learning; consult before a design decision in those
areas. areas.
[issue-tracking.md](../issue-tracking.md) - open work lives in Gitea issues, one per work [issue-tracking.md](../issue-tracking.md) - open work lives in Gitea issues, one per work
package, labelled `area/`, `kind/`, `prio/` and `size/`, with the body kept as the current package, labelled `area/`, `kind/`, `prio/` and `size/`. There is no `TODO.md`. **The body
truth rather than as a first post. There is no `TODO.md`. Read it before filing something of the issue you are working on is this session's plan file:** keep it current as the state
for later, before editing an issue, or before deciding what to pick up next. moves, not at the end, so an interrupted session leaves a body the next one can resume from.
Read it before filing something for later, before editing or closing an issue, or before
deciding what to pick up next.
[testing-conventions.md](../testing-conventions.md) - the suite runs against a deliberately [testing-conventions.md](../testing-conventions.md) - the suite runs against a deliberately
empty machine; what the autouse fixture already neutralizes, and what a test still has to 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. 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 [version-parts.md](../version-parts.md) - which part a change bumps: the drop-in test, the
catalogue of breaks that cross the compatibility boundary with `kb/` untouched, and what to catalogue of breaks that cross the compatibility boundary with `kb/` untouched, and what to
put in front of the user before a breaking bump. Read it before step 3. put in front of the user before a breaking bump. Read it before step 3.
[corpus-policy.md](../corpus-policy.md) - what "curated enough" means for the shared
demo/testbed `kb/`, the measurable floors that define it, and what a reactive fix may and may
not do to corpus content. Read it before judging whether the corpus can exercise a change, or
before any fix that would touch `kb/` content.
More instructions are added here incrementally as stack-development needs come up - this More instructions are added here incrementally as stack-development needs come up - this
list grows without needing this skill file to change shape. list grows without needing this skill file to change shape.
3. **Raise the version, if the change ships.** A change under `tools/`, `types/`, 3. **Raise the version, if the change ships.** A change under `tools/`, `types/`,
+49 -2
View File
@@ -40,6 +40,48 @@ resolved paths and `conventions`' parsed `kb/CONVENTIONS.md`. A test that *rewri
conventions file mid-test calls `conventions.reset_cache()` itself - the fixture answers for the conventions file mid-test calls `conventions.reset_cache()` itself - the fixture answers for the
boundary between tests, not for one inside a test. boundary between tests, not for one inside a test.
## Which tree a test writes into
The environment is one half of the isolation; `config.ROOT` is the other. With `CHEMENU_ROOT`
cleared, `ROOT` falls back to the checkout pytest is running from - deliberately, because most
tests want the shipped `types/`. It also means that any code path resolving a file through
`config.ROOT` or `config.KB_DIR` reaches **the real repository**, no matter which tree the
fixture built.
Both corpus fixtures therefore repoint it: `raw_dir` and `kb_dir` each set
`config.ROOT` to their `tmp_path` and re-declare the shipped `types/` through
`use_shipped_type_specs()`. `config`'s module `__getattr__` resolves the derived paths on
access, so repointing `ROOT` carries `KB_DIR`, `RAW_DIR` and the rest with it. A new fixture
that builds a tree does the same thing - that is the rule here, not a per-test judgment.
`kb_dir` did not, until Gitea #44. Two things came of that. A test calling
`kb_state.write_kb_state()` overwrote the real `.wikitool-kb.json`, which `git status` made
visible within the minute. Quieter and worse: `lint`'s collection lookup resolved a page
against `config.KB_DIR`, so every fixture page read back as "no collection" and the
`unauthorised_labels` check skipped every edge in silence - the finding had no working test at
all, and its green run read like an assurance.
Two guards came out of it, both in `conftest.py`:
| Guard | Default | Cost |
|---|---|---|
| `repository_tree_guard` (session) | on | two `git status --porcelain` calls per run |
| `per_test_tree_guard` | off, `CHEMENU_TREE_GUARD=each` turns it on | one `git status` per test |
The session guard compares the working tree before against after and fails the run if anything
moved, so it says nothing about uncommitted work a developer already had. It cannot name the
test that did it; `CHEMENU_TREE_GUARD=each` can, and is the way to bisect once it fires. Where
git is unavailable or the checkout is not a repository, both are silently inert.
Neither guard sees the second, quieter half: a check that silently *does nothing* under test
writes no file. That one is only caught by a test that asserts the finding actually fires -
which is why `test_unauthorised_label_is_judged_in_a_tree_that_is_not_the_configured_kb`
lints a tree `ROOT` deliberately points away from.
**A function that takes a directory resolves against that directory.** `run_lint(kb_dir)`
reading `config.KB_DIR` for one of its own lookups was the defect behind the quiet half, and
no fixture can fix that shape from the outside.
## When to run ## When to run
Whenever you add or change a test under `tools/chemenu/tests/`. Whenever you add or change a test under `tools/chemenu/tests/`.
@@ -80,7 +122,12 @@ Whenever you add or change a test under `tools/chemenu/tests/`.
`conftest.py` in the same change. A variable the tool reads and the fixture does not clear `conftest.py` in the same change. A variable the tool reads and the fixture does not clear
is the exact hole this whole file is about, reopened. is the exact hole this whole file is about, reopened.
5. **Verify against an empty machine before publishing**, not only in your own shell: 5. **Writing a fixture that builds a tree?** Repoint `config.ROOT` at it and call
`use_shipped_type_specs(monkeypatch)`, as `raw_dir` and `kb_dir` do - see
[Which tree a test writes into](#which-tree-a-test-writes-into). A fixture that returns a
path without repointing hands the code under test the real repository.
6. **Verify against an empty machine before publishing**, not only in your own shell:
```bash ```bash
cd tools && env -i PATH="$PATH" HOME="$(mktemp -d)" \ cd tools && env -i PATH="$PATH" HOME="$(mktemp -d)" \
@@ -92,7 +139,7 @@ Whenever you add or change a test under `tools/chemenu/tests/`.
`.venv/bin/python -m pytest -q`. A difference between the two is a leak, and the leaking `.venv/bin/python -m pytest -q`. A difference between the two is a leak, and the leaking
variable belongs in step 4's list. variable belongs in step 4's list.
6. **Check the coverage report when adding tests to close a gap**, rather than guessing which 7. **Check the coverage report when adding tests to close a gap**, rather than guessing which
lines were uncovered: lines were uncovered:
```bash ```bash
+10 -2
View File
@@ -165,9 +165,15 @@ def run_lint(kb_dir: Path) -> dict:
# propagated, a deleted page, or a URL pasted where a title belongs - used # propagated, a deleted page, or a URL pasted where a title belongs - used
# to pass every check. Which fields hold page titles is declared by each # to pass every check. Which fields hold page titles is declared by each
# type-spec's `page_ref_fields:`, not hardcoded here. # type-spec's `page_ref_fields:`, not hardcoded here.
# Resolved against the directory `run_lint()` was handed, not against
# `config.KB_DIR`. A page under a tree that is not the configured corpus -
# every fixture tree, and any `lint <path>` aimed elsewhere - raised
# `ValueError` here and read as "no collection", which made the label
# authorisation below skip the edge in silence rather than judge it
# (Gitea #44).
def _collection_of(page): def _collection_of(page):
try: try:
return page.path.relative_to(config.KB_DIR).parts[0] return page.path.relative_to(kb_dir).parts[0]
except (ValueError, IndexError): except (ValueError, IndexError):
return None return None
@@ -211,7 +217,9 @@ def run_lint(kb_dir: Path) -> dict:
destination = _collection_of(target_page) destination = _collection_of(target_page)
if destination is None: if destination is None:
continue continue
allowed = kb_collections.authorised_labels(source_collection, destination) allowed = kb_collections.authorised_labels(
source_collection, destination, kb_dir
)
if edge.label not in allowed: if edge.label not in allowed:
unauthorised_labels.append( unauthorised_labels.append(
{ {
+87 -2
View File
@@ -1,4 +1,5 @@
import os import os
import subprocess
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -37,6 +38,78 @@ _GIT_ENV = (
) )
def _working_tree_state() -> str | None:
"""`git status --porcelain` for the checkout the tests live in, or None if
there is no git available to ask."""
try:
result = subprocess.run(
["git", "-C", str(config._PACKAGE_ROOT), "status", "--porcelain"],
capture_output=True,
text=True,
timeout=60,
)
except (OSError, subprocess.SubprocessError):
return None
return result.stdout if result.returncode == 0 else None
_TREE_GUARD_MESSAGE = (
"A test wrote into the repository checkout instead of into its tmp_path.\n"
"`git status --porcelain` moved while the suite ran:\n\n"
" before:\n{before}\n"
" after:\n{after}\n\n"
"This is the class of bug Gitea #44 describes: code under test resolves a "
"path through `config.ROOT`/`config.KB_DIR` rather than through the "
"directory the fixture handed it, so the write lands in the real tree. Fix "
"the fixture (repoint `config.ROOT`, as `raw_dir` and `kb_dir` do) or the "
"code path, never the symptom.\n"
"To find the test that did it, re-run with CHEMENU_TREE_GUARD=each - the "
"guard then checks after every test and fails on the first one that moves "
"the tree."
)
@pytest.fixture(scope="session", autouse=True)
def repository_tree_guard():
"""Fail the run if the suite moved a file in the real checkout.
Two `git status` calls for the whole session, which is why this is on by
default: it catches the whole class rather than the one case that was
noticed. It compares before against after rather than demanding a clean
tree, so it says nothing about a developer's own uncommitted work.
It cannot name the culprit - set `CHEMENU_TREE_GUARD=each` for that, which
trades a `git status` per test for a failure on the test that did it.
"""
before = _working_tree_state()
yield
after = _working_tree_state()
if before is None or after is None or before == after:
return
raise AssertionError(
_TREE_GUARD_MESSAGE.format(before=before or "(clean)", after=after or "(clean)")
)
@pytest.fixture(autouse=True)
def per_test_tree_guard(repository_tree_guard):
"""The bisect half of `repository_tree_guard`, off unless asked for.
`CHEMENU_TREE_GUARD=each` turns the session-wide "something moved the tree"
into "this test moved the tree", at the cost of a `git status` per test.
"""
if os.environ.get("CHEMENU_TREE_GUARD") != "each":
yield
return
before = _working_tree_state()
yield
after = _working_tree_state()
if before is not None and after is not None and before != after:
raise AssertionError(
_TREE_GUARD_MESSAGE.format(before=before or "(clean)", after=after or "(clean)")
)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def hermetic_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): def hermetic_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Cut every test off from the machine it runs on. """Cut every test off from the machine it runs on.
@@ -159,13 +232,25 @@ def raw_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
@pytest.fixture @pytest.fixture
def kb_dir(tmp_path: Path) -> Path: def kb_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""A minimal fixture kb/ with the standard collection layout, populated """A minimal fixture kb/ with the standard collection layout, populated
with a handful of pages covering entities/concepts/sources/comparisons. with a handful of pages covering entities/concepts/sources/comparisons.
Every collection carries a COLLECTION.md, both because that is what makes it Every collection carries a COLLECTION.md, both because that is what makes it
a collection and because the scanner must prove it skips them at a depth the a collection and because the scanner must prove it skips them at a depth the
kb-root meta files never reach.""" kb-root meta files never reach.
`config.ROOT` is repointed for the same reason `raw_dir` does it, one
collection over: code under test that resolves a path through
`config.ROOT`/`config.KB_DIR` rather than through the directory it was
handed otherwise reaches the *real* repository. That was not theoretical
either - a test calling `kb_state.write_kb_state()` overwrote this
checkout's `.wikitool-kb.json`, and `lint`'s collection lookup answered
"no collection" for every fixture page, which left `unauthorised_labels`
with no working test at all (Gitea #44).
"""
monkeypatch.setattr(config, "ROOT", tmp_path)
use_shipped_type_specs(monkeypatch)
kb = tmp_path / "kb" kb = tmp_path / "kb"
for sub in ("entities/projects", "entities/systems", "entities/tools", for sub in ("entities/projects", "entities/systems", "entities/tools",
"entities/technologies", "entities/people", "entities/technologies", "entities/people",
+20
View File
@@ -74,3 +74,23 @@ def test_wiki_author_overrides_the_git_identity(tmp_path: Path,
monkeypatch.setattr(config, "ROOT", tmp_path) monkeypatch.setattr(config, "ROOT", tmp_path)
monkeypatch.setenv("WIKI_AUTHOR", "Env Override") monkeypatch.setenv("WIKI_AUTHOR", "Env Override")
assert config.default_author() == "Env Override" assert config.default_author() == "Env Override"
def test_kb_dir_repoints_the_configured_root_at_its_own_tree(kb_dir: Path, tmp_path: Path):
"""The other half of the isolation, and the one `kb_dir` was missing until
Gitea #44: a fixture that builds a corpus but leaves `config.ROOT` on the
real checkout hands every `config.KB_DIR` lookup the developer's own wiki -
which is how a test overwrote the repository's `.wikitool-kb.json`."""
assert config.ROOT == tmp_path
assert config.KB_DIR == kb_dir
def test_raw_dir_repoints_the_configured_root_at_its_own_tree(raw_dir: Path, tmp_path: Path):
assert config.ROOT == tmp_path
assert config.RAW_DIR == raw_dir
def test_both_corpus_fixtures_keep_the_shipped_type_specs_reachable(kb_dir: Path):
"""Repointing `ROOT` moves `TYPES_DIR` with it, so the repoint has to be
paired with `use_shipped_type_specs()` or no page type resolves at all."""
assert (config.TYPES_DIR / "entity.md").is_file()
+47 -26
View File
@@ -15,7 +15,6 @@ from chemenu.commands.lint import (
) )
from chemenu.frontmatter_io import write_page from chemenu.frontmatter_io import write_page
from chemenu.provenance import cite_id, render_cite_block from chemenu.provenance import cite_id, render_cite_block
from chemenu.tests.conftest import use_shipped_type_specs
from chemenu.version import Version from chemenu.version import Version
@@ -482,26 +481,18 @@ def test_lint_does_not_count_a_shell_prompt_as_a_quote(kb_dir):
# --- the migration gate on `unlabelled_edges` / `unauthorised_labels` ------- # --- the migration gate on `unlabelled_edges` / `unauthorised_labels` -------
# #
# These four repoint `config.ROOT` at the fixture tree, which the `kb_dir` # These read and write `.wikitool-kb.json`, which `kb_state` resolves relative
# fixture alone does not do. Two things need it: `write_kb_state()` writes # to `config.ROOT`. The `kb_dir` fixture repoints `ROOT` at its own tmp_path
# `.wikitool-kb.json` relative to `ROOT`, and `lint`'s collection lookup # (Gitea #44), so the gate is read off the fixture tree; before it did, these
# resolves a page against `config.KB_DIR` rather than against the directory it # four ran against the real repository's state file and one of them overwrote
# was handed - so without the repoint the gate would be read off the real # it.
# repository's state file.
@pytest.fixture def _page_with_an_unlabelled_edge(kb_dir):
def rooted_kb(kb_dir, tmp_path, monkeypatch):
monkeypatch.setattr(config, "ROOT", tmp_path)
use_shipped_type_specs(monkeypatch)
return kb_dir
def _page_with_an_unlabelled_edge(rooted_kb):
"""A `related:` entry that is a bare title rather than a `label: title` """A `related:` entry that is a bare title rather than a `label: title`
mapping - the shape every page was in before the 4.0.0 migration.""" mapping - the shape every page was in before the 4.0.0 migration."""
write_page( write_page(
rooted_kb / "entities/tools/bare-edge.md", kb_dir / "entities/tools/bare-edge.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03", {"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
"modified": "2026-09-03", "related": ["Modbus"], "sources": [], "confidence": 0.8, "modified": "2026-09-03", "related": ["Modbus"], "sources": [], "confidence": 0.8,
"provenance": "general", "summary": "One edge whose label was never declared."}, "provenance": "general", "summary": "One edge whose label was never declared."},
@@ -509,14 +500,14 @@ def _page_with_an_unlabelled_edge(rooted_kb):
) )
def test_unlabelled_edge_is_advisory_below_kb_version_4(rooted_kb): def test_unlabelled_edge_is_advisory_below_kb_version_4(kb_dir):
"""The window the migration document describes: the machinery has landed, """The window the migration document describes: the machinery has landed,
the corpus has not been converted yet, and `lint --fail-on-error` must not the corpus has not been converted yet, and `lint --fail-on-error` must not
refuse the very tree the migration tells the instance to publish unit by refuse the very tree the migration tells the instance to publish unit by
unit.""" unit."""
_page_with_an_unlabelled_edge(rooted_kb) _page_with_an_unlabelled_edge(kb_dir)
kb_state.write_kb_state(Version(3, 0, 0), []) kb_state.write_kb_state(Version(3, 0, 0), [])
report = run_lint(rooted_kb) report = run_lint(kb_dir)
assert report["unlabelled_edges"] != [] assert report["unlabelled_edges"] != []
assert "unlabelled_edges" not in hard_error_keys() assert "unlabelled_edges" not in hard_error_keys()
# Narrowed to the finding under test: the fixture corpus carries unrelated # Narrowed to the finding under test: the fixture corpus carries unrelated
@@ -525,34 +516,64 @@ def test_unlabelled_edge_is_advisory_below_kb_version_4(rooted_kb):
assert has_hard_errors({"unlabelled_edges": report["unlabelled_edges"]}) is False assert has_hard_errors({"unlabelled_edges": report["unlabelled_edges"]}) is False
def test_unlabelled_edge_is_hard_at_kb_version_4(rooted_kb): def test_unlabelled_edge_is_hard_at_kb_version_4(kb_dir):
"""Once the migration is recorded, a bare title is no longer a page waiting """Once the migration is recorded, a bare title is no longer a page waiting
its turn - it is an edge whose author did not say what it asserts.""" its turn - it is an edge whose author did not say what it asserts."""
_page_with_an_unlabelled_edge(rooted_kb) _page_with_an_unlabelled_edge(kb_dir)
kb_state.write_kb_state(Version(4, 0, 0), []) kb_state.write_kb_state(Version(4, 0, 0), [])
report = run_lint(rooted_kb) report = run_lint(kb_dir)
assert report["unlabelled_edges"] != [] assert report["unlabelled_edges"] != []
assert "unlabelled_edges" in hard_error_keys() assert "unlabelled_edges" in hard_error_keys()
assert has_hard_errors({"unlabelled_edges": report["unlabelled_edges"]}) is True assert has_hard_errors({"unlabelled_edges": report["unlabelled_edges"]}) is True
def test_unauthorised_label_is_hard_at_kb_version_4(rooted_kb): def test_unauthorised_label_is_hard_at_kb_version_4(kb_dir):
"""The fixture contracts authorise `depends-on` but not `contradicts`.""" """The fixture contracts authorise `depends-on` but not `contradicts`."""
write_page( write_page(
rooted_kb / "entities/tools/off-menu.md", kb_dir / "entities/tools/off-menu.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03", {"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
"modified": "2026-09-03", "related": [{"contradicts": "Modbus"}], "sources": [], "modified": "2026-09-03", "related": [{"contradicts": "Modbus"}], "sources": [],
"confidence": 0.8, "provenance": "general", "summary": "A label off this menu."}, "confidence": 0.8, "provenance": "general", "summary": "A label off this menu."},
"\n# off-menu\n\nA label the source collection never authorised.\n", "\n# off-menu\n\nA label the source collection never authorised.\n",
) )
kb_state.write_kb_state(Version(4, 0, 0), []) kb_state.write_kb_state(Version(4, 0, 0), [])
report = run_lint(rooted_kb) report = run_lint(kb_dir)
assert report["unauthorised_labels"] != [] assert report["unauthorised_labels"] != []
assert "unauthorised_labels" in hard_error_keys() assert "unauthorised_labels" in hard_error_keys()
assert has_hard_errors({"unauthorised_labels": report["unauthorised_labels"]}) is True assert has_hard_errors({"unauthorised_labels": report["unauthorised_labels"]}) is True
def test_a_tree_that_never_declared_a_kb_version_keeps_every_key(rooted_kb): def test_unauthorised_label_is_judged_in_a_tree_that_is_not_the_configured_kb(
kb_dir, tmp_path, monkeypatch
):
"""`run_lint()` judges the tree it was handed, not the configured corpus.
The collection lookup used to resolve a page against `config.KB_DIR`; a
page anywhere else raised `ValueError`, read back as "no collection", and
the label check skipped the edge without a word. That is why
`unauthorised_labels` was untested in practice before Gitea #44 - every
fixture tree was somewhere else. Here `ROOT` deliberately points away from
the tree under lint, which is the case the old code got wrong.
"""
write_page(
kb_dir / "entities/tools/off-menu.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
"modified": "2026-09-03", "related": [{"contradicts": "Modbus"}], "sources": [],
"confidence": 0.8, "provenance": "general", "summary": "A label off this menu."},
"\n# off-menu\n\nA label the source collection never authorised.\n",
)
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
monkeypatch.setattr(config, "ROOT", elsewhere)
assert config.KB_DIR != kb_dir
report = run_lint(kb_dir)
assert {
"page": "off-menu", "target": "Modbus", "label": "contradicts", "destination": "concepts"
} in report["unauthorised_labels"]
def test_a_tree_that_never_declared_a_kb_version_keeps_every_key(kb_dir):
"""No `.wikitool-kb.json` means a fresh instance, which starts at the """No `.wikitool-kb.json` means a fresh instance, which starts at the
current shape rather than migrating into it - so there is no outstanding current shape rather than migrating into it - so there is no outstanding
migration for a gated finding to be the noise of.""" migration for a gated finding to be the noise of."""