Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b461421e8 | |||
| 41f5dfe1cd | |||
| 23307c3c5f |
+136
@@ -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
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
type: types/instruction.md
|
||||
name: corpus-policy
|
||||
description: What "curated enough" means for kb/ when it is demo and testbed at once, the measurable floors that define it, and what a reactive fix to the corpus may and may not do.
|
||||
---
|
||||
# Keep kb/ curated enough to develop against, without a second corpus
|
||||
|
||||
This instance runs one `kb/` for two purposes at once: a public demo and the testbed this stack
|
||||
is developed against. There is deliberately no fixture corpus, no `--with-demo` export, and no
|
||||
second repository - see Gitea #28. The corpus's size and shape are set by what targeted
|
||||
development needs, not by a synthetic fixture size or a demo aesthetic.
|
||||
|
||||
## When to run
|
||||
|
||||
- Before judging whether the corpus can exercise a change under development - ranking, index
|
||||
scaling, orphan detection, a new label, a new type-spec.
|
||||
- Before a reactive fix touches `kb/` content rather than the failing code - the floors below
|
||||
are what decides whether the fix may proceed as-is.
|
||||
- Picking up Gitea #28 or #30, or any issue that references this file.
|
||||
|
||||
## The floors
|
||||
|
||||
Each is mechanically checkable with an existing `wikitool` command; none needs new tool code.
|
||||
A floor exists to keep some class of bug observable, not to describe an aesthetic target - so
|
||||
when a session is about to make one of these numbers *worse*, that is the signal to stop and
|
||||
think, not a number to defend for its own sake.
|
||||
|
||||
| Floor | Check | Why this number |
|
||||
|---|---|---|
|
||||
| Every page type has ≥1 page | `wikitool search --field type=types/<t>.md` | A type with zero pages means its schema, its collection contract and its lint rules are unexercised |
|
||||
| Every declared subtype has ≥1 page | `wikitool search --field <x>_type=<v>` | Same reasoning, one level down - `entity_type`, `concept_type`, `source_type` |
|
||||
| ≥5 pages corpus-wide with ≥3 `sources:` entries | one-off script, see below | Provenance fan-in - multiple sources backing one claim - is a real case only a handful of pages exercise; fewer than 5 and a provenance-index bug can hide |
|
||||
| Orphan pages (no inbound link) between 1 and 10 | `wikitool lint` | Zero orphans makes orphan detection itself unobservable; more than 10 means the corpus stopped being curated |
|
||||
| Average outbound wikilinks per page ≥4 | one-off script, see below | Below this, ranking and graph-traversal work has too little structure to exercise |
|
||||
|
||||
A floor is a lower bound only. There is no upper bound on page count or on any of these numbers
|
||||
except the orphan ceiling above - a corpus that outgrows these floors through real ingests is
|
||||
not a problem this file cares about.
|
||||
|
||||
**Measured 2026-09-03** (see Gitea #28): 181 pages, 14/14 types and subtypes covered, 12 pages
|
||||
with ≥3 sources, 3 orphans, 6.2 average outbound links. All floors held without any manufactured
|
||||
content - the corpus was already big enough when the question was asked.
|
||||
|
||||
A type or subtype sitting at exactly the floor - one page - shows no set-level bugs, only that
|
||||
the type is *reachable*. That is a soft target for the next `wiki-ingest` that happens to
|
||||
produce a matching page, never a reason to write one: filing an unsourced page to clear a floor
|
||||
is exactly what AGENTS.md invariant 3 forbids, floor or no floor. The same holds for an
|
||||
authorised link label with zero live uses (`wikitool xref` reports these) - fill it when a real
|
||||
edge calls for it, never manufacture one to exercise the label.
|
||||
|
||||
To check the two floors without a dedicated command, walk `kb/**/*.md` (excluding
|
||||
`INDEX.md`/`COLLECTION.md`/`CONTRACT.md`/`CONVENTIONS.md`), parse frontmatter, and: count pages
|
||||
whose `related:` array (resolved against page titles) has ≥3 entries for outbound density; count
|
||||
`sources:` array length ≥3 for the provenance floor. `wikitool search` and `wikitool lint`
|
||||
cover everything else in the table.
|
||||
|
||||
## What a reactive fix may do to kb/ content
|
||||
|
||||
Three tiers, by how much of the corpus a change touches:
|
||||
|
||||
1. **Pointwise - always allowed.** Creating, updating, renaming or deleting a single page
|
||||
through the normal tools (`new`, `touch`, the page-lifecycle procedure), below the
|
||||
Mass-Update Gate's threshold. This is ordinary work and needs no special permission.
|
||||
2. **Corpus-wide - planned only, never reactive.** A migration, a vocabulary sweep, a bulk
|
||||
`touch` across many pages. This needs its own issue and, per `work/CONTRACT.md`, a `work/`
|
||||
run - never a same-session reaction to whatever the session was originally doing. If a
|
||||
session hits the Mass-Update Gate (exit 42, see `instructions/gates.md`) while working on
|
||||
something else, it does not fetch the `--confirm` token to push through: it stops, opens an
|
||||
issue for the corpus-wide change, and finishes the original task without it.
|
||||
3. **Reactive - never allowed.** Deleting or reshaping a page to make a failing test pass;
|
||||
restructuring corpus content to route around a tool bug (AGENTS.md invariant 7); using
|
||||
`kb/` as a scratch surface for a tool experiment. If a stack change under development needs a
|
||||
corpus shape that does not exist, build it as a pytest fixture (see the next section) -
|
||||
never manufacture it in `kb/`.
|
||||
|
||||
## Relationship to the test fixtures
|
||||
|
||||
`tools/chemenu/tests/conftest.py`'s `kb_dir`/`raw_dir` fixtures and `test_pipeline_l0.py` cover
|
||||
the **small, isolated** case: a handful of pages, built fresh per test, hermetic. `kb/` covers
|
||||
the **large, connected** case: 181+ pages, grown link density, real provenance history that no
|
||||
per-test fixture reconstructs economically. The cut: if a `tmp_path` tree can reproduce what the
|
||||
test needs, it belongs in a fixture; if the test needs density or scale that only a grown corpus
|
||||
has, it belongs against `kb/`. Neither absorbs the other's job - see
|
||||
[testing-conventions.md](testing-conventions.md).
|
||||
|
||||
## Decision points
|
||||
|
||||
- **A floor would be violated by an in-progress change - is that a blocker?** Only for the
|
||||
orphan ceiling and the type/subtype floors, since those two can go to zero. The density and
|
||||
provenance floors move gradually with ordinary ingests and are not gating on any single
|
||||
session.
|
||||
- **Corpus is "too small" for a feature under development?** That is not this file's problem to
|
||||
solve by adding pages - see tier 3 above. Either the feature waits for a real ingest to supply
|
||||
the shape, or it gets a pytest fixture.
|
||||
@@ -28,8 +28,12 @@ issues at that URL, which is exactly why `dist export` excludes
|
||||
an assumption nobody has checked, a decision that needs the user.
|
||||
- Picking an issue up: before doing anything else, read the body as the current
|
||||
spec, and re-label it if the ground has moved since.
|
||||
- **While working on one:** the body is updated as the state moves, not at the
|
||||
end (step 2). A session that is interrupted leaves the body as its handover.
|
||||
- Prioritising: deciding what to pick up next, or re-labelling after the ground
|
||||
moved.
|
||||
- Closing one: the body is rewritten to its final state first, and only then
|
||||
closed (step 7).
|
||||
|
||||
## 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
|
||||
whoever wrote it is a note, and notes were the problem.
|
||||
|
||||
2. **Treat the body as the current truth, not as a historical first post.**
|
||||
Work on one issue spans several sessions, often weeks apart, and the body is
|
||||
the only thing that connects them: a session opening the issue must be able
|
||||
to reconstruct what is decided and what is still open from the body alone,
|
||||
without a human re-explaining it. So when the state changes, **rewrite the
|
||||
body** - do not append to a text that has become wrong. An additively grown
|
||||
log forces every later reader to reconstruct the current state by filtering
|
||||
the whole history.
|
||||
2. **The body is the working state, not a historical first post - keep it
|
||||
current as you go.** It is this stack's plan file: the same thing a harness's
|
||||
own plan document is, and it is maintained the same way. Not written once,
|
||||
not brought up to date at the end, but **updated whenever something in it
|
||||
stops being true** - a decision made, a criterion met, an approach ruled out,
|
||||
a new constraint found.
|
||||
|
||||
The test is an abort, not a milestone. A session can end at any moment - an
|
||||
interrupt, a context limit, a crash, a human walking away - and whatever the
|
||||
body says at that instant is the entire handover. So the standard is: **at
|
||||
every point, a fresh session must be able to open the body and pick the work
|
||||
up from there**, without a human re-explaining it and without reading back
|
||||
through the comments. If the body would mislead someone who read it right
|
||||
now, it is already out of date, whether or not the work is finished.
|
||||
|
||||
That means updating *during* the work, not only at its end:
|
||||
|
||||
- a decision gets made → the decision and its reasoning replace the question
|
||||
- an acceptance criterion is done → tick it, in the same session that did it
|
||||
- something turns out differently than the issue assumed → the assumption is
|
||||
corrected where it stands, not contradicted three paragraphs later
|
||||
- work is deferred or dropped → say so, with the reason, where the criterion is
|
||||
|
||||
**Rewrite, never append.** Do not add to a text that has become wrong: an
|
||||
additively grown log forces every later reader to reconstruct the current
|
||||
state by filtering the whole history, which is the exact cost the body exists
|
||||
to remove. Comments carry the history (step 3); the body carries the state.
|
||||
|
||||
Body rewrites and comments are an LLM session's job. A human normally
|
||||
touches only labels and metadata directly.
|
||||
|
||||
3. **Comment a changelog, never a copy.** Every body rewrite gets one short
|
||||
comment naming only what changed against the previous state - what is new,
|
||||
what is gone, what was corrected. Do not snapshot the old body into a
|
||||
comment: a full copy per revision forces a human to diff two prose texts,
|
||||
which is not a readable history, only another copy.
|
||||
3. **Comment a changelog, never a copy.** A body rewrite gets one short comment
|
||||
naming only what changed against the previous state - what is new, what is
|
||||
gone, what was corrected. Do not snapshot the old body into a comment: a full
|
||||
copy per revision forces a human to diff two prose texts, which is not a
|
||||
readable history, only another copy.
|
||||
|
||||
One comment per *session's worth* of change, not per edit. Step 2 asks the
|
||||
body to be kept current continuously, and a comment for every tick would bury
|
||||
the board in noise; the changelog line summarises what that session moved.
|
||||
Trivial upkeep - a typo, a tightened sentence - needs no comment at all.
|
||||
|
||||
```
|
||||
**Changelog:** Decision 2 tightened - `kind/` may now change over an
|
||||
@@ -127,10 +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
|
||||
re-labelling is how a board stops meaning anything.
|
||||
|
||||
7. **Close with what actually happened**, not with a commit hash alone: which
|
||||
proposals were implemented, which were deliberately left out and why, and
|
||||
what was verified. The issue is the only place that record survives - a
|
||||
changelog entry says what changed, not what was decided against.
|
||||
7. **Closing is the last body update, not a comment.** If step 2 was followed
|
||||
the body is already nearly there, and closing only settles what the final
|
||||
run established. If it was not, closing is where the whole debt comes due -
|
||||
and it comes due at the worst moment, because a closed body is the version
|
||||
everyone reads afterwards and nobody revisits.
|
||||
|
||||
Either way the body reaches its final state *before* the issue closes:
|
||||
proposals that were decided read as decided, a "to decide" section has become
|
||||
the decision with its reasoning, acceptance criteria are ticked or struck with
|
||||
a reason, and what was verified is named. Then close, with the one-line
|
||||
changelog comment step 3 asks for.
|
||||
|
||||
Record what actually happened, not a commit hash alone: which proposals were
|
||||
implemented, which were deliberately left out and why, and what was verified.
|
||||
The issue is the only place that record survives - a changelog entry says
|
||||
what changed, not what was decided against.
|
||||
|
||||
**A closing report in a comment does not satisfy this.** It reads as
|
||||
complete to whoever writes it and leaves a body still phrased as open work:
|
||||
unticked boxes, an undecided decision section, present tense about a defect
|
||||
that no longer exists. #44 closed exactly that way, with a thorough comment
|
||||
above a body that still asked for a decision that had already been made and
|
||||
shipped. Nothing mechanical catches it (see below), which is why it is a step
|
||||
rather than a habit.
|
||||
|
||||
## What no tool checks
|
||||
|
||||
`wikitool` does not know this tracker exists, and should not learn. It ships to
|
||||
instances that have no issues at that URL, while this file and the workflow it
|
||||
describes are pruned by `dist export` - a Gitea client inside the shipped tool
|
||||
would be a dev-only dependency carried by every instance, to check a board none
|
||||
of them have. The tracker is reachable only through the `gitea-mcp` server, in a
|
||||
session, by an agent.
|
||||
|
||||
So there is no `docs verify` for the board. Nothing reports a closed issue whose
|
||||
body still reads as open, a body that contradicts its own comments, or an issue
|
||||
missing one of the four mandatory labels. Every one of those is caught by a
|
||||
session following this file, or not at all - which is the argument for the
|
||||
sequence in step 7 being explicit about the order (body first, then close),
|
||||
rather than leaving it to be inferred from step 2.
|
||||
|
||||
## Decision points
|
||||
|
||||
@@ -144,7 +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
|
||||
alone would otherwise be misled - a changed decision, a dropped criterion, a
|
||||
new constraint. A comment carries the changelog line for that rewrite, and
|
||||
nothing else that a future session needs in order to act.
|
||||
nothing else that a future session needs in order to act. Closing an issue is
|
||||
always a rewrite - see step 7.
|
||||
- **An old issue carries only `prio/` and `size/`?** Complete it to all four
|
||||
when you touch it, rather than in a sweep. The board reaches the new scheme
|
||||
issue by issue, as each is picked up.
|
||||
|
||||
@@ -43,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
|
||||
areas.
|
||||
[issue-tracking.md](../issue-tracking.md) - open work lives in Gitea issues, one per work
|
||||
package, labelled `area/`, `kind/`, `prio/` and `size/`, with the body kept as the current
|
||||
truth rather than as a first post. There is no `TODO.md`. Read it before filing something
|
||||
for later, before editing an issue, or before deciding what to pick up next.
|
||||
package, labelled `area/`, `kind/`, `prio/` and `size/`. There is no `TODO.md`. **The body
|
||||
of the issue you are working on is this session's plan file:** keep it current as the state
|
||||
moves, 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
|
||||
empty machine; what the autouse fixture already neutralizes, and what a test still has to
|
||||
establish itself. Read it before adding or changing a test.
|
||||
[version-parts.md](../version-parts.md) - which part a change bumps: the drop-in test, the
|
||||
catalogue of breaks that cross the compatibility boundary with `kb/` untouched, and what to
|
||||
put in front of the user before a breaking bump. Read it before step 3.
|
||||
[corpus-policy.md](../corpus-policy.md) - what "curated enough" means for the shared
|
||||
demo/testbed `kb/`, the measurable floors that define it, and what a reactive fix may and may
|
||||
not do to corpus content. Read it before judging whether the corpus can exercise a change, or
|
||||
before any fix that would touch `kb/` content.
|
||||
More instructions are added here incrementally as stack-development needs come up - this
|
||||
list grows without needing this skill file to change shape.
|
||||
3. **Raise the version, if the change ships.** A change under `tools/`, `types/`,
|
||||
|
||||
@@ -40,6 +40,48 @@ resolved paths and `conventions`' parsed `kb/CONVENTIONS.md`. A test that *rewri
|
||||
conventions file mid-test calls `conventions.reset_cache()` itself - the fixture answers for the
|
||||
boundary between tests, not for one inside a test.
|
||||
|
||||
## Which tree a test writes into
|
||||
|
||||
The environment is one half of the isolation; `config.ROOT` is the other. With `CHEMENU_ROOT`
|
||||
cleared, `ROOT` falls back to the checkout pytest is running from - deliberately, because most
|
||||
tests want the shipped `types/`. It also means that any code path resolving a file through
|
||||
`config.ROOT` or `config.KB_DIR` reaches **the real repository**, no matter which tree the
|
||||
fixture built.
|
||||
|
||||
Both corpus fixtures therefore repoint it: `raw_dir` and `kb_dir` each set
|
||||
`config.ROOT` to their `tmp_path` and re-declare the shipped `types/` through
|
||||
`use_shipped_type_specs()`. `config`'s module `__getattr__` resolves the derived paths on
|
||||
access, so repointing `ROOT` carries `KB_DIR`, `RAW_DIR` and the rest with it. A new fixture
|
||||
that builds a tree does the same thing - that is the rule here, not a per-test judgment.
|
||||
|
||||
`kb_dir` did not, until Gitea #44. Two things came of that. A test calling
|
||||
`kb_state.write_kb_state()` overwrote the real `.wikitool-kb.json`, which `git status` made
|
||||
visible within the minute. Quieter and worse: `lint`'s collection lookup resolved a page
|
||||
against `config.KB_DIR`, so every fixture page read back as "no collection" and the
|
||||
`unauthorised_labels` check skipped every edge in silence - the finding had no working test at
|
||||
all, and its green run read like an assurance.
|
||||
|
||||
Two guards came out of it, both in `conftest.py`:
|
||||
|
||||
| Guard | Default | Cost |
|
||||
|---|---|---|
|
||||
| `repository_tree_guard` (session) | on | two `git status --porcelain` calls per run |
|
||||
| `per_test_tree_guard` | off, `CHEMENU_TREE_GUARD=each` turns it on | one `git status` per test |
|
||||
|
||||
The session guard compares the working tree before against after and fails the run if anything
|
||||
moved, so it says nothing about uncommitted work a developer already had. It cannot name the
|
||||
test that did it; `CHEMENU_TREE_GUARD=each` can, and is the way to bisect once it fires. Where
|
||||
git is unavailable or the checkout is not a repository, both are silently inert.
|
||||
|
||||
Neither guard sees the second, quieter half: a check that silently *does nothing* under test
|
||||
writes no file. That one is only caught by a test that asserts the finding actually fires -
|
||||
which is why `test_unauthorised_label_is_judged_in_a_tree_that_is_not_the_configured_kb`
|
||||
lints a tree `ROOT` deliberately points away from.
|
||||
|
||||
**A function that takes a directory resolves against that directory.** `run_lint(kb_dir)`
|
||||
reading `config.KB_DIR` for one of its own lookups was the defect behind the quiet half, and
|
||||
no fixture can fix that shape from the outside.
|
||||
|
||||
## When to run
|
||||
|
||||
Whenever you add or change a test under `tools/chemenu/tests/`.
|
||||
@@ -80,7 +122,12 @@ Whenever you add or change a test under `tools/chemenu/tests/`.
|
||||
`conftest.py` in the same change. A variable the tool reads and the fixture does not clear
|
||||
is the exact hole this whole file is about, reopened.
|
||||
|
||||
5. **Verify against an empty machine before publishing**, not only in your own shell:
|
||||
5. **Writing a fixture that builds a tree?** Repoint `config.ROOT` at it and call
|
||||
`use_shipped_type_specs(monkeypatch)`, as `raw_dir` and `kb_dir` do - see
|
||||
[Which tree a test writes into](#which-tree-a-test-writes-into). A fixture that returns a
|
||||
path without repointing hands the code under test the real repository.
|
||||
|
||||
6. **Verify against an empty machine before publishing**, not only in your own shell:
|
||||
|
||||
```bash
|
||||
cd tools && env -i PATH="$PATH" HOME="$(mktemp -d)" \
|
||||
@@ -92,7 +139,7 @@ Whenever you add or change a test under `tools/chemenu/tests/`.
|
||||
`.venv/bin/python -m pytest -q`. A difference between the two is a leak, and the leaking
|
||||
variable belongs in step 4's list.
|
||||
|
||||
6. **Check the coverage report when adding tests to close a gap**, rather than guessing which
|
||||
7. **Check the coverage report when adding tests to close a gap**, rather than guessing which
|
||||
lines were uncovered:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -165,9 +165,15 @@ def run_lint(kb_dir: Path) -> dict:
|
||||
# propagated, a deleted page, or a URL pasted where a title belongs - used
|
||||
# to pass every check. Which fields hold page titles is declared by each
|
||||
# type-spec's `page_ref_fields:`, not hardcoded here.
|
||||
# Resolved against the directory `run_lint()` was handed, not against
|
||||
# `config.KB_DIR`. A page under a tree that is not the configured corpus -
|
||||
# every fixture tree, and any `lint <path>` aimed elsewhere - raised
|
||||
# `ValueError` here and read as "no collection", which made the label
|
||||
# authorisation below skip the edge in silence rather than judge it
|
||||
# (Gitea #44).
|
||||
def _collection_of(page):
|
||||
try:
|
||||
return page.path.relative_to(config.KB_DIR).parts[0]
|
||||
return page.path.relative_to(kb_dir).parts[0]
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
@@ -211,7 +217,9 @@ def run_lint(kb_dir: Path) -> dict:
|
||||
destination = _collection_of(target_page)
|
||||
if destination is None:
|
||||
continue
|
||||
allowed = kb_collections.authorised_labels(source_collection, destination)
|
||||
allowed = kb_collections.authorised_labels(
|
||||
source_collection, destination, kb_dir
|
||||
)
|
||||
if edge.label not in allowed:
|
||||
unauthorised_labels.append(
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -37,6 +38,78 @@ _GIT_ENV = (
|
||||
)
|
||||
|
||||
|
||||
def _working_tree_state() -> str | None:
|
||||
"""`git status --porcelain` for the checkout the tests live in, or None if
|
||||
there is no git available to ask."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(config._PACKAGE_ROOT), "status", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return result.stdout if result.returncode == 0 else None
|
||||
|
||||
|
||||
_TREE_GUARD_MESSAGE = (
|
||||
"A test wrote into the repository checkout instead of into its tmp_path.\n"
|
||||
"`git status --porcelain` moved while the suite ran:\n\n"
|
||||
" before:\n{before}\n"
|
||||
" after:\n{after}\n\n"
|
||||
"This is the class of bug Gitea #44 describes: code under test resolves a "
|
||||
"path through `config.ROOT`/`config.KB_DIR` rather than through the "
|
||||
"directory the fixture handed it, so the write lands in the real tree. Fix "
|
||||
"the fixture (repoint `config.ROOT`, as `raw_dir` and `kb_dir` do) or the "
|
||||
"code path, never the symptom.\n"
|
||||
"To find the test that did it, re-run with CHEMENU_TREE_GUARD=each - the "
|
||||
"guard then checks after every test and fails on the first one that moves "
|
||||
"the tree."
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def repository_tree_guard():
|
||||
"""Fail the run if the suite moved a file in the real checkout.
|
||||
|
||||
Two `git status` calls for the whole session, which is why this is on by
|
||||
default: it catches the whole class rather than the one case that was
|
||||
noticed. It compares before against after rather than demanding a clean
|
||||
tree, so it says nothing about a developer's own uncommitted work.
|
||||
|
||||
It cannot name the culprit - set `CHEMENU_TREE_GUARD=each` for that, which
|
||||
trades a `git status` per test for a failure on the test that did it.
|
||||
"""
|
||||
before = _working_tree_state()
|
||||
yield
|
||||
after = _working_tree_state()
|
||||
if before is None or after is None or before == after:
|
||||
return
|
||||
raise AssertionError(
|
||||
_TREE_GUARD_MESSAGE.format(before=before or "(clean)", after=after or "(clean)")
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def per_test_tree_guard(repository_tree_guard):
|
||||
"""The bisect half of `repository_tree_guard`, off unless asked for.
|
||||
|
||||
`CHEMENU_TREE_GUARD=each` turns the session-wide "something moved the tree"
|
||||
into "this test moved the tree", at the cost of a `git status` per test.
|
||||
"""
|
||||
if os.environ.get("CHEMENU_TREE_GUARD") != "each":
|
||||
yield
|
||||
return
|
||||
before = _working_tree_state()
|
||||
yield
|
||||
after = _working_tree_state()
|
||||
if before is not None and after is not None and before != after:
|
||||
raise AssertionError(
|
||||
_TREE_GUARD_MESSAGE.format(before=before or "(clean)", after=after or "(clean)")
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def hermetic_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Cut every test off from the machine it runs on.
|
||||
@@ -159,13 +232,25 @@ def raw_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kb_dir(tmp_path: Path) -> Path:
|
||||
def kb_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""A minimal fixture kb/ with the standard collection layout, populated
|
||||
with a handful of pages covering entities/concepts/sources/comparisons.
|
||||
|
||||
Every collection carries a COLLECTION.md, both because that is what makes it
|
||||
a collection and because the scanner must prove it skips them at a depth the
|
||||
kb-root meta files never reach."""
|
||||
kb-root meta files never reach.
|
||||
|
||||
`config.ROOT` is repointed for the same reason `raw_dir` does it, one
|
||||
collection over: code under test that resolves a path through
|
||||
`config.ROOT`/`config.KB_DIR` rather than through the directory it was
|
||||
handed otherwise reaches the *real* repository. That was not theoretical
|
||||
either - a test calling `kb_state.write_kb_state()` overwrote this
|
||||
checkout's `.wikitool-kb.json`, and `lint`'s collection lookup answered
|
||||
"no collection" for every fixture page, which left `unauthorised_labels`
|
||||
with no working test at all (Gitea #44).
|
||||
"""
|
||||
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||
use_shipped_type_specs(monkeypatch)
|
||||
kb = tmp_path / "kb"
|
||||
for sub in ("entities/projects", "entities/systems", "entities/tools",
|
||||
"entities/technologies", "entities/people",
|
||||
|
||||
@@ -74,3 +74,23 @@ def test_wiki_author_overrides_the_git_identity(tmp_path: Path,
|
||||
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||
monkeypatch.setenv("WIKI_AUTHOR", "Env Override")
|
||||
assert config.default_author() == "Env Override"
|
||||
|
||||
|
||||
def test_kb_dir_repoints_the_configured_root_at_its_own_tree(kb_dir: Path, tmp_path: Path):
|
||||
"""The other half of the isolation, and the one `kb_dir` was missing until
|
||||
Gitea #44: a fixture that builds a corpus but leaves `config.ROOT` on the
|
||||
real checkout hands every `config.KB_DIR` lookup the developer's own wiki -
|
||||
which is how a test overwrote the repository's `.wikitool-kb.json`."""
|
||||
assert config.ROOT == tmp_path
|
||||
assert config.KB_DIR == kb_dir
|
||||
|
||||
|
||||
def test_raw_dir_repoints_the_configured_root_at_its_own_tree(raw_dir: Path, tmp_path: Path):
|
||||
assert config.ROOT == tmp_path
|
||||
assert config.RAW_DIR == raw_dir
|
||||
|
||||
|
||||
def test_both_corpus_fixtures_keep_the_shipped_type_specs_reachable(kb_dir: Path):
|
||||
"""Repointing `ROOT` moves `TYPES_DIR` with it, so the repoint has to be
|
||||
paired with `use_shipped_type_specs()` or no page type resolves at all."""
|
||||
assert (config.TYPES_DIR / "entity.md").is_file()
|
||||
|
||||
@@ -15,7 +15,6 @@ from chemenu.commands.lint import (
|
||||
)
|
||||
from chemenu.frontmatter_io import write_page
|
||||
from chemenu.provenance import cite_id, render_cite_block
|
||||
from chemenu.tests.conftest import use_shipped_type_specs
|
||||
from chemenu.version import Version
|
||||
|
||||
|
||||
@@ -482,26 +481,18 @@ def test_lint_does_not_count_a_shell_prompt_as_a_quote(kb_dir):
|
||||
|
||||
# --- the migration gate on `unlabelled_edges` / `unauthorised_labels` -------
|
||||
#
|
||||
# These four repoint `config.ROOT` at the fixture tree, which the `kb_dir`
|
||||
# fixture alone does not do. Two things need it: `write_kb_state()` writes
|
||||
# `.wikitool-kb.json` relative to `ROOT`, and `lint`'s collection lookup
|
||||
# resolves a page against `config.KB_DIR` rather than against the directory it
|
||||
# was handed - so without the repoint the gate would be read off the real
|
||||
# repository's state file.
|
||||
# These read and write `.wikitool-kb.json`, which `kb_state` resolves relative
|
||||
# to `config.ROOT`. The `kb_dir` fixture repoints `ROOT` at its own tmp_path
|
||||
# (Gitea #44), so the gate is read off the fixture tree; before it did, these
|
||||
# four ran against the real repository's state file and one of them overwrote
|
||||
# it.
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rooted_kb(kb_dir, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||
use_shipped_type_specs(monkeypatch)
|
||||
return kb_dir
|
||||
|
||||
|
||||
def _page_with_an_unlabelled_edge(rooted_kb):
|
||||
def _page_with_an_unlabelled_edge(kb_dir):
|
||||
"""A `related:` entry that is a bare title rather than a `label: title`
|
||||
mapping - the shape every page was in before the 4.0.0 migration."""
|
||||
write_page(
|
||||
rooted_kb / "entities/tools/bare-edge.md",
|
||||
kb_dir / "entities/tools/bare-edge.md",
|
||||
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
|
||||
"modified": "2026-09-03", "related": ["Modbus"], "sources": [], "confidence": 0.8,
|
||||
"provenance": "general", "summary": "One edge whose label was never declared."},
|
||||
@@ -509,14 +500,14 @@ def _page_with_an_unlabelled_edge(rooted_kb):
|
||||
)
|
||||
|
||||
|
||||
def test_unlabelled_edge_is_advisory_below_kb_version_4(rooted_kb):
|
||||
def test_unlabelled_edge_is_advisory_below_kb_version_4(kb_dir):
|
||||
"""The window the migration document describes: the machinery has landed,
|
||||
the corpus has not been converted yet, and `lint --fail-on-error` must not
|
||||
refuse the very tree the migration tells the instance to publish unit by
|
||||
unit."""
|
||||
_page_with_an_unlabelled_edge(rooted_kb)
|
||||
_page_with_an_unlabelled_edge(kb_dir)
|
||||
kb_state.write_kb_state(Version(3, 0, 0), [])
|
||||
report = run_lint(rooted_kb)
|
||||
report = run_lint(kb_dir)
|
||||
assert report["unlabelled_edges"] != []
|
||||
assert "unlabelled_edges" not in hard_error_keys()
|
||||
# Narrowed to the finding under test: the fixture corpus carries unrelated
|
||||
@@ -525,34 +516,64 @@ def test_unlabelled_edge_is_advisory_below_kb_version_4(rooted_kb):
|
||||
assert has_hard_errors({"unlabelled_edges": report["unlabelled_edges"]}) is False
|
||||
|
||||
|
||||
def test_unlabelled_edge_is_hard_at_kb_version_4(rooted_kb):
|
||||
def test_unlabelled_edge_is_hard_at_kb_version_4(kb_dir):
|
||||
"""Once the migration is recorded, a bare title is no longer a page waiting
|
||||
its turn - it is an edge whose author did not say what it asserts."""
|
||||
_page_with_an_unlabelled_edge(rooted_kb)
|
||||
_page_with_an_unlabelled_edge(kb_dir)
|
||||
kb_state.write_kb_state(Version(4, 0, 0), [])
|
||||
report = run_lint(rooted_kb)
|
||||
report = run_lint(kb_dir)
|
||||
assert report["unlabelled_edges"] != []
|
||||
assert "unlabelled_edges" in hard_error_keys()
|
||||
assert has_hard_errors({"unlabelled_edges": report["unlabelled_edges"]}) is True
|
||||
|
||||
|
||||
def test_unauthorised_label_is_hard_at_kb_version_4(rooted_kb):
|
||||
def test_unauthorised_label_is_hard_at_kb_version_4(kb_dir):
|
||||
"""The fixture contracts authorise `depends-on` but not `contradicts`."""
|
||||
write_page(
|
||||
rooted_kb / "entities/tools/off-menu.md",
|
||||
kb_dir / "entities/tools/off-menu.md",
|
||||
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
|
||||
"modified": "2026-09-03", "related": [{"contradicts": "Modbus"}], "sources": [],
|
||||
"confidence": 0.8, "provenance": "general", "summary": "A label off this menu."},
|
||||
"\n# off-menu\n\nA label the source collection never authorised.\n",
|
||||
)
|
||||
kb_state.write_kb_state(Version(4, 0, 0), [])
|
||||
report = run_lint(rooted_kb)
|
||||
report = run_lint(kb_dir)
|
||||
assert report["unauthorised_labels"] != []
|
||||
assert "unauthorised_labels" in hard_error_keys()
|
||||
assert has_hard_errors({"unauthorised_labels": report["unauthorised_labels"]}) is True
|
||||
|
||||
|
||||
def test_a_tree_that_never_declared_a_kb_version_keeps_every_key(rooted_kb):
|
||||
def test_unauthorised_label_is_judged_in_a_tree_that_is_not_the_configured_kb(
|
||||
kb_dir, tmp_path, monkeypatch
|
||||
):
|
||||
"""`run_lint()` judges the tree it was handed, not the configured corpus.
|
||||
|
||||
The collection lookup used to resolve a page against `config.KB_DIR`; a
|
||||
page anywhere else raised `ValueError`, read back as "no collection", and
|
||||
the label check skipped the edge without a word. That is why
|
||||
`unauthorised_labels` was untested in practice before Gitea #44 - every
|
||||
fixture tree was somewhere else. Here `ROOT` deliberately points away from
|
||||
the tree under lint, which is the case the old code got wrong.
|
||||
"""
|
||||
write_page(
|
||||
kb_dir / "entities/tools/off-menu.md",
|
||||
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
|
||||
"modified": "2026-09-03", "related": [{"contradicts": "Modbus"}], "sources": [],
|
||||
"confidence": 0.8, "provenance": "general", "summary": "A label off this menu."},
|
||||
"\n# off-menu\n\nA label the source collection never authorised.\n",
|
||||
)
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
monkeypatch.setattr(config, "ROOT", elsewhere)
|
||||
assert config.KB_DIR != kb_dir
|
||||
|
||||
report = run_lint(kb_dir)
|
||||
assert {
|
||||
"page": "off-menu", "target": "Modbus", "label": "contradicts", "destination": "concepts"
|
||||
} in report["unauthorised_labels"]
|
||||
|
||||
|
||||
def test_a_tree_that_never_declared_a_kb_version_keeps_every_key(kb_dir):
|
||||
"""No `.wikitool-kb.json` means a fresh instance, which starts at the
|
||||
current shape rather than migrating into it - so there is no outstanding
|
||||
migration for a gated finding to be the noise of."""
|
||||
|
||||
Reference in New Issue
Block a user