Compare commits

...

5 Commits

Author SHA1 Message Date
torben b2f7dec122 fix: private-instance - Demo-Korpus wandert beim Merge mit, Prozedur korrigiert (2.2.1)
CI / verify (push) Successful in 43s
Release / release (push) Successful in 38s
Files changed:
- CHANGES.md
- VERSION
- instructions/private-instance.md
2026-09-01 19:03:23 +02:00
torben 29063f511b docs: INSTALL.md auf das oeffentliche Repo umgestellt, Weg D fuer die private Instanz
CI / verify (push) Successful in 45s
Files changed:
- INSTALL.md
2026-09-01 18:14:08 +02:00
torben 7263f85936 feat: Publish-Remote Gate und die Anleitung fuer eine private Instanz (2.2.0)
CI / verify (push) Successful in 44s
Release / release (push) Successful in 36s
Files changed:
- .gitignore
- AGENTS.md
- CHANGES.md
- VERSION
- instructions/gates.md
- instructions/private-instance.md
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/config.py
- tools/chemenu/tests/test_git_publish.py
2026-09-01 18:06:55 +02:00
torben fb97d46888 docs: Formulierung im 2.1.0-Eintrag geglaettet
CI / verify (push) Successful in 43s
Files changed:
- CHANGES.md
2026-09-01 17:07:18 +02:00
torben f63a72cb24 fix: raw_dir-Fixture kappt config.ROOT, private Fixture-Namen ersetzt (2.1.1)
CI / verify (push) Successful in 42s
Release / release (push) Successful in 37s
Files changed:
- CHANGES.md
- VERSION
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_corpus_diff.py
- tools/chemenu/tests/test_index_build.py
- tools/chemenu/tests/test_kb_collections.py
- tools/chemenu/tests/test_lint.py
- tools/chemenu/tests/test_migrate_cmd.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_page_ops.py
- tools/chemenu/tests/test_provenance.py
- tools/chemenu/tests/test_search.py
- tools/chemenu/tests/test_touch.py
2026-09-01 17:03:51 +02:00
22 changed files with 784 additions and 115 deletions
+7
View File
@@ -110,6 +110,13 @@ npm-debug.log*
# `dist export`; this anchored pattern deliberately does not match it.
/ENVIRONMENT.md
# Publish-Remote Gate allowlist (see instructions/gates.md). Names the push
# URLs *this* checkout may publish to, so it is per-checkout for exactly the
# reason ENVIRONMENT.md above is: a committed copy would tell a private clone
# that the public upstream is a legitimate target for its own content. Absent
# means unrestricted; `doctor` reports which.
/.wikitool-remotes.json
# Coverage output from `pytest --cov` (see .gitea/workflows/ci.yml). Derived,
# like reports/: recomputable from any commit, and `publish` runs `git add -A`,
# so an unignored htmlcov/ would commit itself on the next content publish.
+8 -3
View File
@@ -20,7 +20,9 @@ tools/wikitool instructions sync
Full procedure, including the tool environment: [instructions/bootstrap.md](instructions/bootstrap.md).
Setting up a brand-new, empty instance instead of cloning this one: `tools/wikitool dist export`
and [instructions/setup-instance.md](instructions/setup-instance.md) - see
[INSTALL.md](INSTALL.md).
[INSTALL.md](INSTALL.md). A *private* instance that keeps taking stack updates from a public
upstream is a third shape, with a safeguard the other two do not need:
[instructions/private-instance.md](instructions/private-instance.md).
## Invariants
@@ -177,16 +179,19 @@ tools/wikitool search --field entity_type=system --field 'confidence<0.6'
## Gates
Two limits are enforced in code rather than by instruction, because a prompt-level limit is
Three limits are enforced in code rather than by instruction, because a prompt-level limit is
one an agent can talk itself past.
- **Mass-Update Gate.** `publish` exits **42** on a change touching too many files, printing
the file list and the `--confirm <token>` line that publishes it once the user approves. The
threshold and the rule live in [instructions/gates.md](instructions/gates.md).
- **Publish-Remote Gate.** `publish` exits **42** on a push to a URL this checkout has not
declared in `.wikitool-remotes.json`. It has no token and no flag: the way past it is a
deliberate edit by the user, never by an agent.
- **Iteration Budget Gate / Loop-Breaker.** Past 60 `wikitool` calls in a session, or after 3
identical calls in a row, further calls are refused.
Both refuse with exit 1. **Do not retry, and do not open the gate.** Stop, summarize the
The last refuses with exit 1. **Do not retry, and do not open a gate.** Stop, summarize the
situation to the user, and get explicit approval. The full procedure - including why
`budget reset` is not the escape hatch - is [instructions/gates.md](instructions/gates.md).
+138 -2
View File
@@ -20,6 +20,143 @@ their date-only headings.
---
## 2.2.1 - 2026-09-01 - private-instance: der Demo-Korpus wandert beim Merge doch mit - Prozedur korrigiert
**Author:** Torben Nehmer
`instructions/private-instance.md` behauptete in 2.2.0, ein `git merge upstream/main` löse
Änderungen am Demo-Korpus stillschweigend auf, weil die private Instanz ihn einmal gelöscht hat:
*deleted-in-ours, unmodified-in-theirs*. Das war **nicht gemessen, sondern angenommen** — und es
ist falsch. Ein Nachbau mit einem Upstream, der seinen Korpus bewegt, zeigt drei verschiedene
Verhalten:
| Upstream tut | `git merge upstream/main` tut |
|---|---|
| ändert eine Seite, die du gelöscht hast | `CONFLICT (modify/delete)` — und **lässt die Upstream-Fassung im Arbeitsbaum liegen**. Ein `git add -A` beim Auflösen holt die Demo-Seite zurück. |
| legt eine neue Seite an | staged sie **stillschweigend**. Kein Konflikt, keine Meldung. |
| löscht eine Seite, die du auch gelöscht hast | nichts. Der einzige harmlose Fall. |
Die mittlere Zeile ist die gefährliche, weil nichts sie ankündigt. Ein Upstream, der einen
Demo-Korpus ausliefert **und** ihn als Testbett benutzt, legt Seiten an — und jede einzelne
landet in der privaten Instanz und taucht dort in `lint`, `index`, `search` und
`confidence decay` auf. Genau diese Doppelnutzung beschreibt Issue #28.
**Korrigiert:** Die Update-Prozedur hält den Merge mit `--no-commit` offen, zwingt `kb/` und
`raw/` danach auf den eigenen Stand zurück (`git rm --cached`, `rm -rf`, `git checkout HEAD --`)
und schließt ihn erst dann. Solange der Merge offen ist, zeigt `HEAD` noch auf den Commit davor,
und genau das macht den Schritt sauber. Anschließend eine Kontrolle, die man nicht überlesen
kann:
```bash
git diff --name-only $BEFORE HEAD -- kb raw # muss leer sein
```
Das Rezept ist wörtlich so nachgespielt worden, wie es jetzt in der Datei steht — mit einem
Upstream, der gleichzeitig eine Seite ändert, eine anlegt, eine löscht und dasselbe unter
`raw/` tut. Ergebnis: Stack aktualisiert, nur eigener Inhalt übrig, Kontrolle leer,
Arbeitsbaum sauber.
**Auch die Decision Points korrigiert.** „Konflikt in `kb/` per Hand als *keep deleted*
auflösen" stand vorher da und ist der Rat, der in die Falle führt: `git add -A` committet die
Fassung, die git im Arbeitsbaum liegen gelassen hat.
**Dateien:** `instructions/private-instance.md`.
---
## 2.2.0 - 2026-09-01 - Publish-Remote Gate: publish schreibt nur an erklaerte Ziele
**Author:** Torben Nehmer
Der Stack bekommt sein drittes Gate. Die beiden bestehenden fragen, ob eine Änderung zu groß
ist und ob ein Rebase gefährlich ist. Dieses fragt, was darunter liegt: **ob das überhaupt das
richtige Repository ist.**
**Das Problem entsteht erst durch die private Instanz.** Ein Checkout mit eigenem Inhalt hat
typischerweise zwei Remotes — sein eigenes und das öffentliche Upstream, von dem er
Stack-Updates zieht. Git unterscheidet die beim Push nicht, also legt ein falsches `--remote`
einen privaten Korpus auf ein öffentliches Repository. Das ist nicht billig rückholbar, und
zwar nachweislich: Beim Veröffentlichen dieses Repos blieb die gesamte alte History nach dem
Force-Push per SHA abrufbar, bis auf dem Server die Reflogs verfielen und `git gc --prune=now`
lief. Ein Force-Push bewegt den Branch, nicht die Objekte.
**`.wikitool-remotes.json` nennt die erlaubten Push-URLs.** Nicht die Remote-*Namen*: Eine
Namensliste ließe ein `publish` durch, dessen `origin` umgebogen wurde, und genau das ist der
Fall, den das Gate fangen soll. Gelesen wird die `pushurl`, wenn der Remote eine setzt, denn
dorthin schreibt `git push` tatsächlich.
**Pro Checkout und gitignored**, aus demselben Grund wie `ENVIRONMENT.md`: Zwei Klone pushen an
zwei verschiedene Orte, eine committete Kopie würde einem privaten Klon also mitteilen, das
öffentliche Upstream sei ein legitimes Ziel für seinen eigenen Inhalt. **Fehlt die Datei, gilt
keine Beschränkung** — ein Checkout mit einem Remote und ohne Privates hat nichts zu schützen,
und eine Pflichtdatei würde aus einer Sicherung Papierkram machen. Eine *kaputte* Datei ist
dagegen ein Fehler und kein „keine Beschränkung": Eine beschädigte Sicherung darf sich nicht
wie eine abgeschaltete verhalten.
**Kein Token, keine Flagge.** Die anderen beiden Gates lösen sich mit einem `--confirm <token>`,
weil ihre Frage („ist diese Änderung richtig?") für genau ein Changeset beantwortbar ist. Dieses
fragt „gehört dieser Inhalt in jenes Repository?", und das ist eine stehende Eigenschaft des
Checkouts, kein Einzelfallurteil. Der Weg daran vorbei ist ein bewusster Edit des Nutzers.
Ein Agent, der die Datei anfasst, um an einer Verweigerung vorbeizukommen, öffnet ein Gate aus
eigenem Antrieb — Invariante 6.
**`doctor` meldet den Zustand** statt ihn zu erzwingen: OK mit Anzahl der Ziele, OK bei
Abwesenheit mit einem Remote, und WARN bei mehr als einem Remote ohne Allowlist — also genau in
der Form, die eine private Instanz annimmt, sobald sie das Upstream hinzufügt.
**Und die Prozedur, für die das Gate gebaut wurde.** `instructions/private-instance.md` (neu)
beschreibt die dritte Instanz-Form neben „frisch aufsetzen" und „Repo klonen": eine private
Arbeitsinstanz, die Stack-Updates von einem öffentlichen Upstream per `git merge` zieht und
deren eigener Inhalt nie zurückwandert. Der Grund, warum das dem Tarball-Weg vorzuziehen ist,
steht dort ausformuliert — `cp -r` hat keinen Drei-Wege-Merge und keine Konflikterkennung.
Schritt 4 der Anleitung ist das Gate, und zwar ausdrücklich **vor** dem ersten `publish`:
später hinzugefügt schützt es das Fenster nicht, das es schließen soll.
**Dateien:** `config.PUBLISH_REMOTES_FILENAME`, `git_publish.read_allowed_push_urls()`,
`push_url_for()`, `publish_remote_refusal()` und die Prüfung vor dem Reconcile-Schritt,
`doctor.check_publish_remotes()`, `.gitignore`, `instructions/gates.md`,
`instructions/private-instance.md` (neu), `AGENTS.md` (Gate-Liste und Bootstrap-Routing),
12 neue Tests in `test_git_publish.py`.
---
## 2.1.1 - 2026-09-01 - raw_dir-Fixture kappt config.ROOT; letzte private Fixture-Namen ersetzt
**Author:** Torben Nehmer
Der erste CI-Lauf nach der Korpus-Bereinigung wurde rot, während dieselbe Suite lokal grün
blieb: `test_legacy_source_pages_flags_url_and_directory` scheiterte mit
`KeyError: 'Source - DirBacked'`.
**Die Ursache ist keine Testschwäche, sondern eine Umgebungsabhängigkeit.** Der Test legt eine
Quellenseite mit `source: raw/documents` an und erwartet, dass `legacy_source_pages()` sie als
„directory, not a file" meldet. Die Prüfung dort ist `(config.ROOT / legacy).is_dir()` — sie
löst also gegen das *echte* Repo-Wurzelverzeichnis auf, während die `raw_dir`-Fixture ihren
Baum unter `tmp_path` baut. Der Test war nie hermetisch; er bestand, weil dieser Checkout
zufällig ein `raw/documents/` besaß.
Die Korpus-Bereinigung hat dieses Verzeichnis geleert. Git verfolgt keine leeren Verzeichnisse,
also verschwand es im CI-Checkout und blieb im lokalen Arbeitsbaum stehen — grün hier, rot
dort, ohne dass sich am Test etwas geändert hätte.
**Geschlossen in der Fixture, nicht im Test.** `raw_dir` setzt jetzt `config.ROOT` auf
`tmp_path`. Das ist dieselbe Bauart und dasselbe Argument wie bei `hermetic_environment`
(Gitea #8): Wenn eine Umgebungsabhängigkeit lokal unsichtbar ist, gehört sie an der Stelle
geschlossen, die alle Tests erreicht, statt bei dem einen, der zufällig darüber stolpert.
Nachgewiesen, indem `raw/documents/` lokal entfernt und die Suite erneut gefahren wurde — 38
Provenance-Tests grün ohne das Verzeichnis, vorher rot.
**Fixture-Namen.** Die Suite benutzte weiterhin reale Systemnamen der Ursprungsinstanz als
Fixture-Bezeichner. In einem
öffentlichen Repo beschreiben sie nichts, verraten aber die Namensgebung einer privaten
Umgebung. Ersetzt durch `aurora`, `almanac`, `Borealis`, `gateway`, `docstore`. `gdeploy`
bleibt: die Seite existiert im öffentlichen Korpus.
**Dateien:** `tools/chemenu/tests/conftest.py` (`raw_dir`), sämtliche Testdateien mit
Fixture-Namen.
---
## 2.1.0 - 2026-09-01 - Dual-Lizenz AGPL-3.0/CC-BY-4.0, und ein Leak-Canary fuer dist export
**Author:** Torben Nehmer
@@ -65,8 +202,7 @@ oder IP-Literale wurde erwogen und verworfen: Der eigene Host steht legitim in `
oder bei jedem Export Fehlalarm geben.
**Beispiele neutralisiert.** Hilfetexte, Docstrings und Contract-Beispiele nannten reale
Seiten und Pfade der Ursprungsinstanz (Quellenseiten und Pfade
aus dem privaten Korpus). In einer
Quellenseiten und Pfade der Ursprungsinstanz. In einer
Distribution ist das nicht nur unpassend, sondern verrät den Inhalt der Instanz, die sie gebaut
hat. Ersetzt durch Beispiele aus dem verbleibenden öffentlichen Korpus.
+51 -21
View File
@@ -1,10 +1,11 @@
# Installation
Dieses Dokument richtet sich an Menschen. Es gibt drei Wege: ein **Release herunterladen**
(der normale Weg zu einer neuen Instanz), eine Distribution **selbst exportieren**, oder
**dieses Repo klonen** (Torbens persönliche Wiki, samt Inhalt). Der agent-seitige Ablauf steckt
in `instructions/`; hier stehen nur die menschlichen Teile - für die vollständige
Kommandoreferenz siehe [tools/CONTRACT.md](tools/CONTRACT.md).
Dieses Dokument richtet sich an Menschen. Es gibt vier Wege: ein **Release herunterladen**
(der normale Weg zu einer neuen Instanz), eine Distribution **selbst exportieren**, **dieses
Repo klonen** (Testbett und Demo, samt Beispielkorpus), oder eine **private Instanz mit diesem
Repo als Upstream** aufsetzen. Der agent-seitige Ablauf steckt in `instructions/`; hier stehen
nur die menschlichen Teile - für die vollständige Kommandoreferenz siehe
[tools/CONTRACT.md](tools/CONTRACT.md).
## Voraussetzungen
@@ -16,19 +17,22 @@ Kommandoreferenz siehe [tools/CONTRACT.md](tools/CONTRACT.md).
## Weg A: Release herunterladen
Der kürzeste Weg zu einer eigenen Instanz - kein Checkout dieses Repos nötig. Jedes Release
trägt genau einen `dist export`-Baum plus eine Prüfsumme. Das Repo ist derzeit privat, der
Download braucht also ein Gitea-Token mit Lesezugriff (siehe
[Konfiguration](#konfiguration)):
trägt genau einen `dist export`-Baum plus eine Prüfsumme. Das Repo ist öffentlich, der Download
braucht also weder Konto noch Token:
```bash
BASE=https://gitea.nehmer.net/torben/chemenu/releases/download/v<version>
curl -LO -H "Authorization: token $WIKITOOL_UPDATE_TOKEN" $BASE/chemenu-stack-<version>.tar.gz
curl -LO -H "Authorization: token $WIKITOOL_UPDATE_TOKEN" $BASE/chemenu-stack-<version>.tar.gz.sha256
curl -LO $BASE/chemenu-stack-<version>.tar.gz
curl -LO $BASE/chemenu-stack-<version>.tar.gz.sha256
sha256sum -c chemenu-stack-<version>.tar.gz.sha256
tar xzf chemenu-stack-<version>.tar.gz
cd chemenu-stack-<version>
```
Die Prüfsumme ist nicht Zierde: Sie ist das Einzige, was einen unterbrochenen Download von
einem vollständigen unterscheidet, und `sha256sum -c` muss `OK` sagen, bevor irgendetwas
entpackt wird.
Danach weiter mit Schritt 2 aus Weg B: den Agenten
[instructions/setup-instance.md](instructions/setup-instance.md) ausführen lassen. Der
entpackte Baum ist bereits eine Distribution - Schritt 1 (`dist export`) entfällt.
@@ -82,10 +86,14 @@ Zwei Schritte, von denen nur der erste rein menschlich ist:
## Weg C: Dieses Repo klonen
Für Torbens Instanz selbst, oder einen Fork davon samt Inhalt:
Für die Arbeit am Stack selbst, oder um sich den mitgelieferten Korpus als begehbares Beispiel
anzusehen. Was hier liegt, ist ein **Testbett und eine Demo**, keine produktive Wissensbasis:
rund 170 Seiten, die den Stack selbst dokumentieren - Gates, Lint, Versionierung, Suche, das
Wiki-Muster. Wer eigenes Wissen sammeln will, nimmt Weg A oder B und fängt mit einem leeren
`kb/` an.
```bash
git clone <repo-url>
git clone https://gitea.nehmer.net/torben/chemenu.git
cd chemenu
```
@@ -102,6 +110,21 @@ Checkout* beschreibt und nicht das Repo. Sie ist optional; wer sie anlegt, spart
folgenden Session die Fragen nach Harness, MCP-Servern und Remote. Vorlage:
`ENVIRONMENT.md.template`, Ablauf: Schritt 5 in `instructions/bootstrap.md`.
## Weg D: Private Instanz mit diesem Repo als Upstream
Die Kombination aus A und C: eine eigene, nicht öffentliche Instanz, die weiterhin
Stack-Updates von hier zieht - per `git merge` statt per Tarball, also mit echtem
Drei-Wege-Merge statt `cp -r`.
Das ist der Weg mit dem höchsten Einsatz, weil ein Checkout dann zwei Remotes hat und git beim
Push nicht unterscheidet, welcher welcher ist. Ein falsches `--remote` legt privaten Inhalt auf
ein öffentliches Repo, und ein Force-Push holt das nicht zurück - die Objekte bleiben per SHA
abrufbar, bis auf dem Server die Reflogs verfallen.
Dagegen gibt es das **Publish-Remote-Gate**, und die Anleitung setzt es an die Stelle, an der
es wirkt: *vor* dem ersten `publish`. Vollständiges Vorgehen:
[instructions/private-instance.md](instructions/private-instance.md).
## Version und Updates
Jede Instanz trägt die Version des **Stacks** (Werkzeuge, Typen, Instruktionen, Contracts) -
@@ -199,22 +222,23 @@ behält Schema und Shape.
| `WIKI_AUTHOR` | Override für den Autornamen neuer Source-Seiten | `git config user.name` - fehlt beides, bricht `new` mit `ERROR` ab |
| `WIKITOOL_SESSION_ID` | Scopt das Iteration-Budget-Gate auf eine Aufgabe statt auf ein Terminal | Parent-Process-ID (siehe [instructions/session-setup.md](instructions/session-setup.md)) |
| `WIKITOOL_UPDATE_URL` | Release-Feed, den `version check` abfragt | Wert aus `.wikitool-release.json`, sonst der Feed der Ursprungs-Instanz |
| `WIKITOOL_UPDATE_TOKEN` | Gitea-Token für den Release-Feed | keiner - **aber das Ursprungs-Repo ist derzeit privat, also wird ein Token gebraucht** (siehe unten) |
| `WIKITOOL_UPDATE_TOKEN` | Gitea-Token für den Release-Feed | keiner - gegen `torben/chemenu` nicht nötig, nur für einen privaten Fork (siehe unten) |
**Privates Ursprungs-Repo.** `torben/chemenu` ist nicht öffentlich lesbar. Gitea
antwortet anonymen Aufrufern für ein unsichtbares Repo mit demselben `404` wie für ein gar
nicht existierendes - ein fehlendes Release und ein fehlender Zugriff sehen also identisch aus.
Für `version check` (und für den Download in Weg A) braucht es deshalb ein Gitea-Token mit
Lesezugriff:
**Gegen das Ursprungs-Repo braucht es kein Token.** `torben/chemenu` ist öffentlich lesbar;
`version check` und der Download in Weg A funktionieren ohne Konfiguration.
**Für einen privaten Fork schon.** Wer den Stack in ein eigenes, nicht öffentliches Repo legt
und `WIKITOOL_UPDATE_URL` auf dessen Feed zeigen lässt, stößt auf eine Eigenheit, die man
kennen sollte: Gitea antwortet anonymen Aufrufern für ein unsichtbares Repo mit demselben
`404` wie für ein gar nicht existierendes. Ein fehlendes Release und ein fehlender Zugriff
sehen dann identisch aus - „kein Update gefunden" wäre in dem Fall schlicht gelogen. Dagegen
hilft ein Gitea-Token mit Lesezugriff:
```bash
export WIKITOOL_UPDATE_TOKEN="<gitea-token>"
tools/wikitool version check
```
Wird das Repo öffentlich geschaltet, entfällt das Token ersatzlos - der Feed ist dann anonym
lesbar und `version check` funktioniert ohne Konfiguration.
## Verifikation
```bash
@@ -259,6 +283,12 @@ tools/wikitool instructions verify
sondern die Aufforderung, die Ausgabe einem Menschen zu zeigen: sie enthält die vollständige
Dateiliste und die exakte `--confirm <token>`-Zeile, die nach Freigabe veröffentlicht.
Details: [instructions/gates.md](instructions/gates.md).
- **`publish` endet mit Exit-Code 42 (Publish-Remote-Gate)** - dieser Checkout hat eine
`.wikitool-remotes.json`, und das angesteuerte Remote steht nicht darin. Ebenfalls kein
Fehler: Die Ausgabe nennt die Push-URL, an die geschrieben würde, und die erlaubten. Anders
als beim Mass-Update-Gate gibt es hier **keinen Token und keine Flagge** - stimmt das Ziel
wirklich, trägt der Mensch dessen URL selbst in die Datei ein. Ein Agent, der die Datei
anfasst, um an der Verweigerung vorbeizukommen, öffnet ein Gate aus eigenem Antrieb.
- **Ich will am Tool-Stack selbst weiterarbeiten (nicht nur Wiki-Inhalt betreiben)** - eine neue
Instanz hat dafür keinen Weg: `dist export` lässt `instructions/dev/` (Stack-Entwicklung,
inkl. der vendorten `commonplace/`-Wissensbasis) bewusst und dauerhaft weg, ohne
+1 -1
View File
@@ -1 +1 @@
2.1.0
2.2.1
+41 -4
View File
@@ -22,10 +22,11 @@ Read the exit code first - it says which of these applies:
## Exit 42: user clearance required
A `wikitool` command that exits **42** is not reporting an error. It is refusing to act until a
human has *read its output*. Two gates use it today - the Mass-Update Gate (`publish`, on a
change touching 10 or more counted files) and the rebase-review gate (`sync` and `publish`, on
a rebase whose incoming commits touch a file this session is also changing) - but the rule is
about the exit code, not the command:
human has *read its output*. Three gates use it today - the Mass-Update Gate (`publish`, on a
change touching 10 or more counted files), the rebase-review gate (`sync` and `publish`, on
a rebase whose incoming commits touch a file this session is also changing), and the
Publish-Remote Gate (`publish`, on a push to a target this checkout has not declared) - but the
rule is about the exit code, not the command:
> **Copy the command's output into your reply - the substance of it, not a description of it -
> and stop.** Run no further commands in that turn.
@@ -62,6 +63,42 @@ clearance.
Background: [[Mass-Update Gate]] (`kb/concepts/Mass-Update Gate.md`).
### Publish-Remote Gate
The Mass-Update Gate asks whether a change is too large to publish. This one asks the question
underneath it: **whether this is the right repository to publish to at all.**
A checkout that holds private content usually has two remotes - its own, and the public upstream
it takes stack updates from. Git does not distinguish them at push time, so one wrong `--remote`
puts a private corpus on a public repository. That is not cheaply reversible: a force-push moves
the branch, but the objects stay fetchable by SHA until someone expires the server's reflogs and
runs `git gc --prune=now` on the bare repo.
`.wikitool-remotes.json` names the push URLs a checkout permits:
```json
{ "schema": 1, "allowed_push_urls": ["ssh://git@example.net:22/you/your-wiki.git"] }
```
It pins **URLs, not remote names** - a name-based list would wave through a `publish` whose
`origin` had been repointed, which is the failure it exists to catch. It reads the remote's
`pushurl` when one is set, because that is where `git push` actually writes.
The file is per-checkout and gitignored, for the same reason `ENVIRONMENT.md` is: two clones push
to two different places, so a committed copy would tell a private clone that the public upstream
is a legitimate target for its own content. **Absent means unrestricted** - a single-remote
checkout with nothing private in it has nothing to protect, and `doctor` reports which state a
checkout is in, WARNing only when there is more than one remote and no allowlist. A malformed
file is an error rather than "no restriction": a corrupted safeguard must not read as a disabled
one.
**This gate has no `--confirm` token, on purpose.** The other two clear with a token because the
question they ask ("is this change right?") is one the agent can put to the user and the user can
answer for that one changeset. This one asks "does this content belong to that repository?", which
is a standing property of the checkout, not a per-push judgment. The way past it is for the user
to add the URL to the file. **An agent must never edit `.wikitool-remotes.json` to get past a
refusal** - that is opening a gate on your own initiative, which AGENTS.md invariant 6 forbids.
## Iteration Budget Gate and loop-breaker
Every `wikitool` call is counted per session. Calls are refused past **60 in a session**, or
+171
View File
@@ -0,0 +1,171 @@
---
type: types/instruction.md
name: private-instance
description: Set up a private working instance as a clone of a public upstream, so stack updates arrive by merge instead of by copying a tarball over the tree.
---
# Set up a private instance against a public upstream
The distribution path in [setup-instance.md](setup-instance.md) builds an instance from a
`dist export` tarball, with no git ancestry in common with the repo it came from. That is the
right shape for someone who only ever *consumes* the stack.
This is the other shape: a private instance that keeps taking stack changes from a public
upstream, and whose own content must never travel back. It costs one safeguard to set up and
saves the whole update procedure afterwards.
**Read this before, not after, the first `publish`.** The gate in step 4 is the thing that makes
the arrangement safe, and adding it later means the window it closes was open in between.
## Why a clone rather than a tarball
`INSTALL.md`'s "Eine Instanz aktualisieren" is `cp -r` as an upgrade strategy: copy `tools/`,
`types/`, `instructions/`, `AGENTS.md`, `VERSION` over the existing tree. It has no three-way
merge, so it cannot notice that the receiving instance changed a file, and it has no conflict
surface, so nobody learns when upstream and local both touched the same one. It overwrites
silently.
A clone gets all of that from git. Stack changes land as real merges, with real conflicts where
they conflict.
**What a plain `git merge` does *not* give you is protection from the upstream's content.** The
private `main` deletes the demo corpus once, but that deletion does not make later upstream
changes to those paths go away. Measured, not assumed:
| Upstream does | `git merge upstream/main` does |
|---|---|
| modifies a page you deleted | `CONFLICT (modify/delete)` - and **leaves the upstream version in your working tree**. Resolve it with `git add -A` and the demo page is back. |
| adds a new page | stages it **silently**. No conflict, no prompt, no mention. |
| deletes a page you also deleted | nothing. The only harmless case. |
The middle row is the one that matters, because nothing announces it. An upstream that ships a
demo corpus *and* uses it as a test bed will add pages, and each one arrives in your instance
and starts showing up in your `lint`, your `index`, your `search` and your `confidence decay`.
So the merge has to be scoped. That is the procedure below, and it is not optional.
## Steps
1. **Clone, and name the two remotes for what they are.**
```bash
git clone <private-repo-url> my-wiki
cd my-wiki
git remote add upstream <public-repo-url>
```
`origin` is yours and is the only thing you ever push to. `upstream` is where stack updates
come from and is fetch-only.
2. **Make the fetch-only half fetch-only in git, too.**
```bash
git remote set-url --push upstream no_push
```
git refuses to push to a URL it cannot resolve. This is a convenience, not the safeguard -
step 4 is the safeguard.
3. **Delete the upstream's demo corpus once, on your own `main`.**
Everything under `kb/` and `raw/` that came with the clone is the upstream's content, not
yours. Remove it with `wikitool rm --page` (never `rm -rf`: `rm` de-links each page from the
rest of the wiki, and a plain delete leaves dead wikilinks and broken citations behind), then
`index rebuild`, `sources rebuild-index`, `lint`.
This is a one-time cut. Afterwards the upstream corpus is frozen from your side, which is
what makes later merges content-free.
4. **Arm the Publish-Remote Gate — before the first `publish`.**
```bash
cat > .wikitool-remotes.json <<'EOF'
{ "schema": 1, "allowed_push_urls": ["<your-private-push-url>"] }
EOF
```
Use the URL `git remote get-url --push origin` prints, exactly. `publish` refuses with exit
42 for anything else, and there is no flag that opens it - see [gates.md](gates.md).
The file is gitignored, so it stays with this checkout and never travels to the upstream.
`wikitool doctor` reports whether the gate is armed, and WARNs at more than one remote
without it.
5. **Take away the write credential, if you can.** A token or deploy key for `origin` only,
with no write access to the upstream, is the one control that holds even if everything above
is misconfigured. Belt and braces.
6. **Personalize and bootstrap.** `USER.md`, `SOUL.md` and optionally `ENVIRONMENT.md` are
yours and unrelated to the upstream's - see the Personalization step of
[setup-instance.md](setup-instance.md), then [bootstrap.md](bootstrap.md) for the venv and
the skills.
## Taking a stack update
Take the machinery, never the content. The merge is held open, the content stages are forced
back to your own state, and only then does it close:
```bash
BEFORE=$(git rev-parse HEAD)
git fetch upstream
# --no-commit holds the merge open; it may report conflicts under kb/ or raw/,
# which the next three lines are about to make irrelevant.
git merge --no-commit --no-ff upstream/main || true
# Whatever the merge did to the content stages, undo it. HEAD is still your
# pre-merge commit while the merge is open, so this restores exactly your side.
git rm -rq --cached --ignore-unmatch kb raw
rm -rf kb raw
git checkout HEAD -- kb raw
git commit --no-edit
```
Then **check that it worked**, rather than trusting that it did:
```bash
git diff --name-only $BEFORE HEAD -- kb raw # must print nothing
```
An empty result is the proof that the update touched machinery only. A non-empty one means a
path slipped through - inspect it before going further.
Then, as after any stack change: `doctor`, `docs verify`, `instructions verify`, `migrate status`,
`lint`. A `migrate status` with outstanding links means the update crossed a compatibility
boundary - follow [migrate-corpus.md](migrate-corpus.md) before doing anything else.
**Why not just `git merge upstream/main`?** Because of the table above: a page the upstream
*adds* arrives with no conflict and no message. You would find out when `lint` starts reporting
pages you never wrote - if you noticed at all.
## Where stack development happens
**In the public repo, not here.** That is not a preference; the stack is built that way. The
development-only half of the instruction layer is pruned from a distribution one-way, with no
command that reconstructs it, so an instance built this way has no tool-development mode to
switch into in the first place.
When a tool bug blocks real content work here - and it will - file the issue against the public
repo (an MCP server or the web UI reaches it from any session; no shared history needed), fix it
there where the tests, `docs verify` and CI's version gate live, and take the fix back with the
merge above. Nothing is lost by the detour: the fix has to pass that CI either way.
## Decision points
- **Merge conflict in `kb/` or `raw/`?** Expected, and already handled: the update procedure
above overwrites those stages with your own afterwards, so the conflict resolves itself.
Never resolve one by hand with `git add -A` - that is exactly how the upstream version, which
git left sitting in your working tree, gets committed into your instance.
- **`git diff` after the merge shows something under `kb/` or `raw/`?** Stop. The scoping step
did not take. Do not publish; find out which path came through and where from.
- **Conflict in `tools/`, `types/` or `instructions/`?** You changed the stack locally, which
step "Where stack development happens" says not to do. Take the upstream side and re-file the
change as an issue there.
## Scope
Not for a first instance with no upstream - that is [setup-instance.md](setup-instance.md). Not
for a fresh clone of a repo you already own and develop in - that is
[bootstrap.md](bootstrap.md). This is specifically the two-remote case, where the cost of a
mistaken push is disclosure rather than inconvenience.
+38 -1
View File
@@ -21,7 +21,7 @@ import typer
from rich.console import Console
from chemenu import config, kb_collections, version as version_mod
from chemenu.commands import instructions_cmd
from chemenu.commands import git_publish, instructions_cmd
from chemenu.commands._util import rel_path
from chemenu.session import ENV_VAR as SESSION_ENV_VAR
@@ -242,6 +242,42 @@ def check_environment() -> Check:
return Check("environment", "OK", f"{config.ENVIRONMENT_FILE} present and filled")
def check_publish_remotes() -> Check:
"""Whether the Publish-Remote Gate is armed in this checkout.
Absent is a legitimate state, not a fault: a checkout with a single remote
and nothing private in it has nothing to protect, and making the file
mandatory would turn a safeguard into paperwork. So this never FAILs - it
reports, the way `environment` does.
It does WARN for the case that actually bites: more than one remote
configured and no allowlist. That is the shape a private instance has after
it adds the public upstream, and it is exactly when a wrong `--remote`
stops being a typo and starts being a disclosure.
"""
urls = git_publish.read_allowed_push_urls()
if urls is not None:
return Check(
"publish-remotes", "OK",
f"{len(urls)} allowed push target(s) in {config.PUBLISH_REMOTES_FILENAME}",
)
result = subprocess.run(
["git", "remote"], cwd=config.ROOT, capture_output=True, text=True
)
remotes = [r for r in result.stdout.split() if r]
if len(remotes) > 1:
return Check(
"publish-remotes", "WARN",
f"{len(remotes)} remotes ({', '.join(remotes)}) and no publish allowlist",
f"Create {config.PUBLISH_REMOTES_FILENAME} naming the push URL this checkout "
"may publish to - see instructions/gates.md",
)
return Check(
"publish-remotes", "OK",
f"No {config.PUBLISH_REMOTES_FILENAME} (unrestricted; one remote configured)",
)
def check_generated_files() -> Check:
missing = [
rel_path(path)
@@ -355,6 +391,7 @@ def run_doctor() -> list[Check]:
check_structure(),
check_personalization(),
check_environment(),
check_publish_remotes(),
check_generated_files(),
check_session_id(),
]
+100
View File
@@ -80,6 +80,86 @@ def _run(args: list[str]) -> subprocess.CompletedProcess:
return subprocess.run(args, cwd=config.ROOT, capture_output=True, text=True)
# --- Publish-Remote Gate -----------------------------------------------------
#
# The Mass-Update Gate asks "is this too much to publish?". This one asks the
# question underneath it: "is this the right place to publish to at all?".
#
# A checkout holding private content typically has two remotes - its own, and
# the public upstream it takes stack updates from. Nothing in git distinguishes
# them at push time, so a single wrong `--remote` puts a private corpus on a
# public repository, where a force-push does not take it back: the objects stay
# fetchable by SHA until someone expires the server's reflogs.
#
# Like the other two gates this refuses with exit 42 and has **no flag that
# opens it**. The way past it is to name the URL in the file, which is an edit
# the user makes deliberately rather than something an agent can decide mid-run.
def read_allowed_push_urls() -> Optional[list[str]]:
"""The URLs this checkout permits `publish` to push to, or None when the
file is absent (unrestricted - see config.PUBLISH_REMOTES_FILENAME)."""
path = config.ROOT / config.PUBLISH_REMOTES_FILENAME
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
fail(
f"{config.PUBLISH_REMOTES_FILENAME} is unreadable ({exc}). It decides where "
"`publish` may push, so a broken one is not treated as 'no restriction' - "
"fix the file or delete it deliberately."
)
return None
urls = data.get("allowed_push_urls")
if not isinstance(urls, list) or not all(isinstance(u, str) for u in urls):
fail(
f"{config.PUBLISH_REMOTES_FILENAME} has no usable `allowed_push_urls` list of "
"strings. Expected: {\"schema\": 1, \"allowed_push_urls\": [\"<url>\"]}"
)
return None
return urls
def push_url_for(remote: str) -> Optional[str]:
"""The URL `git push <remote>` would actually write to - `pushurl` when the
remote sets one, otherwise its fetch URL. Reading the resolved value rather
than the name is the whole point: a repointed `origin` must not pass."""
result = _run(["git", "remote", "get-url", "--push", remote])
if result.returncode != 0:
return None
return result.stdout.strip() or None
def publish_remote_refusal(remote: str, branch: str) -> Optional[str]:
"""The gate's message when `remote` is not an allowed push target, else None."""
allowed = read_allowed_push_urls()
if allowed is None:
return None
url = push_url_for(remote)
if url is None:
return (
f"Publish-Remote Gate: '{remote}' resolves to no push URL, so this publish "
f"cannot be checked against {config.PUBLISH_REMOTES_FILENAME}. Nothing was "
"committed or pushed."
)
if url in allowed:
return None
listed = "\n".join(f" - {u}" for u in allowed) or " (the list is empty)"
return (
f"Publish-Remote Gate: this checkout does not allow publishing to '{remote}'.\n\n"
f" would push to: {url}\n"
f" allowed here:\n{listed}\n\n"
"Nothing was committed or pushed. This checkout holds content that belongs to it "
"alone, and a push to the wrong remote is not cheaply reversible - the objects stay "
"fetchable by SHA even after a force-push, until the server's reflogs are expired.\n\n"
"THE USER CANNOT SEE THIS OUTPUT. It went to your context, not to their screen.\n"
"Show them the two lines above and stop. There is no flag that opens this gate: if "
f"the target really is right, the user adds its URL to {config.PUBLISH_REMOTES_FILENAME} "
"themselves. Do not edit that file to get past this."
)
def parse_porcelain_entries(stdout: str) -> list[tuple[str, str]]:
"""Parse `git status --porcelain -z` output into (status_code, path) pairs.
@@ -919,6 +999,26 @@ def publish_command(
if push and checked_out != branch:
fail(branch_mismatch_message(checked_out, branch))
# Before the reconcile below, which is the first thing that talks to the
# remote at all: a publish aimed at the wrong repository should not even
# fetch from it, and the refusal message promises that nothing was
# committed or pushed.
if push:
refusal = publish_remote_refusal(remote, branch)
if refusal:
emit(
"wikitool",
"gate.refused",
{
"gate": "publish-remote",
"reason": "remote-not-allowed",
"remote": remote,
"branch": branch,
"url": push_url_for(remote),
},
)
needs_clearance(refusal)
# Pull against the remote before anything else, to minimise the window in which this
# publish could diverge from it - and, as a side effect, to finally publish a commit left
# stranded by a previous push that failed (see `_local_ahead_of_remote` below). Skipped
+16
View File
@@ -70,6 +70,22 @@ ENVIRONMENT_TEMPLATE = f"{ENVIRONMENT_FILE}.template"
# `dist export` already computes (AGENTS.md invariant 8). See NOTICE.
LICENSE_FILES = ("LICENSE", "LICENSE-CONTENT", "NOTICE")
# Which push targets `publish` may write to, for a checkout that says so. The
# danger this addresses is one checkout's content reaching another checkout's
# remote - a private instance pushing its own `kb/` to a public upstream, where
# it cannot be taken back.
#
# It pins **URLs, not remote names**: a name-based list would pass a `publish`
# whose `origin` had been repointed, which is the failure it exists to catch.
#
# Per-checkout and gitignored, like `ENVIRONMENT.md` and for the same reason:
# two clones of this repo push to two different places, so a committed copy
# would hand the second one an answer that is wrong rather than missing. Absent
# means unrestricted - `doctor` reports it, and the Publish-Remote Gate simply
# does not apply. A checkout that holds private content should have one; see
# instructions/gates.md.
PUBLISH_REMOTES_FILENAME = ".wikitool-remotes.json"
def default_author() -> str | None:
"""The author to stamp a new source page with, per instance.
+24 -7
View File
@@ -3,6 +3,7 @@ from pathlib import Path
import pytest
from chemenu import config
from chemenu.frontmatter_io import write_page
# Environment the tool reads for its own behaviour. Cleared for every test, so
@@ -92,10 +93,26 @@ def isolated_trace_dir(
@pytest.fixture
def raw_dir(tmp_path: Path) -> Path:
def raw_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""A small fake raw/ tree sitting next to the kb_dir fixture (same
tmp_path), for provenance-coverage tests. One file is deliberately left
uncovered by any source page."""
uncovered by any source page.
`config.ROOT` is repointed at `tmp_path` for the same reason
`hermetic_environment` clears the environment: a `raw/...` path in a
fixture page is resolved against the *repository* root by the code under
test (`provenance.legacy_source_pages`, for one), so without this the
fixture builds a raw tree that the code never looks at and answers from
the developer's own `raw/` instead.
That was not theoretical. `test_legacy_source_pages_flags_url_and_directory`
passed for months only because this checkout happened to have a
`raw/documents/` directory; the run that emptied it turned the test red in
CI while it stayed green locally, because git does not track empty
directories and the local one survived. Same shape as Gitea #8, closed the
same way - in the fixture, not in the one test that happened to trip.
"""
monkeypatch.setattr(config, "ROOT", tmp_path)
raw = tmp_path / "raw"
(raw / "notes").mkdir(parents=True)
(raw / "notes" / "Aurora.md").write_text("# Aurora raw notes\n", encoding="utf-8")
@@ -126,19 +143,19 @@ def kb_dir(tmp_path: Path) -> Path:
{
"type": "types/entity.md", "entity_type": "system",
"tags": ["server"], "created": "2026-07-31", "modified": "2026-07-31",
"related": ["Nathan"], "sources": [], "confidence": 0.9,
"related": ["Borealis"], "sources": [], "confidence": 0.9,
"summary": "Server hosting DocStore with ZFS storage",
},
"\n# aurora\n\n## Description\n\nHosts things.\n\n## Relationships\n\n- **Related to:** [[Nathan]]\n\n## See Also\n\n- [[Nathan]]\n",
"\n# aurora\n\n## Description\n\nHosts things.\n\n## Relationships\n\n- **Related to:** [[Borealis]]\n\n## See Also\n\n- [[Borealis]]\n",
)
write_page(
kb / "entities/systems/Nathan.md",
kb / "entities/systems/Borealis.md",
{
"type": "types/entity.md", "entity_type": "system",
"tags": ["workstation"], "created": "2026-08-02", "modified": "2026-08-02",
"related": ["aurora"], "sources": [], "confidence": 0.9,
},
"\n# Nathan\n\n## Description\n\nA workstation.\n\n## Relationships\n\n- **Related to:** [[aurora]]\n",
"\n# Borealis\n\n## Description\n\nA workstation.\n\n## Relationships\n\n- **Related to:** [[aurora]]\n",
)
write_page(
kb / "entities/tools/gdeploy.md",
@@ -169,7 +186,7 @@ def kb_dir(tmp_path: Path) -> Path:
)
(kb / "index.md").write_text(
"# Wiki Index\n\n[[aurora]] [[Nathan]] [[gdeploy]] [[Modbus]] [[Source - Aurora]]\n",
"# Wiki Index\n\n[[aurora]] [[Borealis]] [[gdeploy]] [[Modbus]] [[Source - Aurora]]\n",
encoding="utf-8",
)
(kb / "log.md").write_text("# Wiki Log\n", encoding="utf-8")
+6 -6
View File
@@ -15,8 +15,8 @@ from chemenu.page import Page
BODY = """
# Aurora
Runs [[DocStore]] and talks to [[Nathan]].[^src-notes]
The second mention of [[Nathan]] is what a careless rewrite loses.
Runs [[DocStore]] and talks to [[Borealis]].[^src-notes]
The second mention of [[Borealis]] is what a careless rewrite loses.
## Fußnoten
@@ -31,7 +31,7 @@ def page(body: str, **frontmatter) -> Page:
"created": "2026-07-31",
"provenance": "sourced",
"confidence_base": 0.9,
"related": ["Nathan"],
"related": ["Borealis"],
"sources": ["Source - Aurora"],
}
base.update(frontmatter)
@@ -57,7 +57,7 @@ def kinds(diff) -> list[str]:
def test_a_dropped_occurrence_is_caught_even_though_the_link_set_is_unchanged():
after = BODY.replace("The second mention of [[Nathan]] is", "Das zweite Vorkommen ist")
after = BODY.replace("The second mention of [[Borealis]] is", "Das zweite Vorkommen ist")
before_shapes, after_shapes = shapes(BODY, after)
# Precondition: the sets really are identical, so a set-based check passes.
@@ -67,7 +67,7 @@ def test_a_dropped_occurrence_is_caught_even_though_the_link_set_is_unchanged():
diff = corpus_diff.compare(before_shapes, after_shapes)
assert kinds(diff) == ["wikilinks"]
assert "'Nathan' 2->1" in diff.findings[0].detail
assert "'Borealis' 2->1" in diff.findings[0].detail
assert not diff.ok
@@ -85,7 +85,7 @@ def test_pure_prose_change_is_not_reported():
def test_a_dropped_citation_is_caught():
after = BODY.replace("[[Nathan]].[^src-notes]", "[[Nathan]].")
after = BODY.replace("[[Borealis]].[^src-notes]", "[[Borealis]].")
diff = corpus_diff.compare(*shapes(BODY, after))
assert "cite-refs" in kinds(diff)
+113
View File
@@ -1,3 +1,4 @@
import json
import subprocess
import pytest
@@ -905,3 +906,115 @@ def test_numstat_survives_a_non_ascii_filename(repo):
change = next(c for c in collect_changes([]) if c.path == name)
assert change.status == "modified"
assert (change.added, change.removed) == (1, 2)
# --- Publish-Remote Gate -----------------------------------------------------
def _allowlist(root, *urls):
(root / config.PUBLISH_REMOTES_FILENAME).write_text(
json.dumps({"schema": 1, "allowed_push_urls": list(urls)}), encoding="utf-8"
)
def test_no_allowlist_means_unrestricted(repo):
"""Absence is a legitimate state: a checkout with nothing private in it
should not have to declare anything to publish at all."""
assert git_publish.read_allowed_push_urls() is None
assert git_publish.publish_remote_refusal("origin", "main") is None
def test_allowed_url_passes_the_gate(repo):
_allowlist(repo, git_publish.push_url_for("origin"))
assert git_publish.publish_remote_refusal("origin", "main") is None
def test_gate_refuses_a_remote_not_on_the_list(repo):
_git(repo, "remote", "add", "upstream", "https://example.com/public.git")
_allowlist(repo, git_publish.push_url_for("origin"))
refusal = git_publish.publish_remote_refusal("upstream", "main")
assert refusal is not None
assert "https://example.com/public.git" in refusal
assert "Nothing was committed or pushed" in refusal
def test_gate_matches_the_url_not_the_remote_name(repo):
"""A name-based list would pass a repointed `origin`, which is the failure
this gate exists to catch."""
_allowlist(repo, "ssh://git@example.com/only-this.git")
assert git_publish.publish_remote_refusal("origin", "main") is not None
def test_gate_reads_pushurl_when_the_remote_sets_one(repo):
"""`git push` writes to `pushurl` when present, so that is the value that
has to be checked - not the fetch URL beside it."""
_git(repo, "remote", "set-url", "--push", "origin", "https://example.com/elsewhere.git")
_allowlist(repo, "https://example.com/elsewhere.git")
assert git_publish.push_url_for("origin") == "https://example.com/elsewhere.git"
assert git_publish.publish_remote_refusal("origin", "main") is None
def test_gate_refuses_when_the_fetch_url_is_listed_but_the_pushurl_is_not(repo):
fetch_url = git_publish.push_url_for("origin")
_git(repo, "remote", "set-url", "--push", "origin", "https://example.com/elsewhere.git")
_allowlist(repo, fetch_url)
assert git_publish.publish_remote_refusal("origin", "main") is not None
def test_publish_exits_42_and_commits_nothing_when_the_remote_is_refused(repo):
_git(repo, "remote", "add", "upstream", "https://example.com/public.git")
_allowlist(repo, git_publish.push_url_for("origin"))
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
(repo / "kb" / "secret.md").write_text("private\n", encoding="utf-8")
with pytest.raises(typer.Exit) as excinfo:
_publish(remote="upstream")
assert excinfo.value.exit_code == EXIT_NEEDS_CLEARANCE
# Nothing committed, and the file is still sitting there unstaged - the
# refusal message promises both. (`git status --porcelain` collapses the
# wholly-untracked `kb/` to one entry, so check the index directly.)
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
assert (repo / "kb" / "secret.md").exists()
assert "secret.md" not in _git(repo, "ls-files").stdout
def test_no_push_skips_the_gate(repo):
"""`--no-push` publishes nowhere, so there is no wrong target to protect
against - and a local commit must stay possible."""
_allowlist(repo, "ssh://git@example.com/only-this.git")
(repo / "kb" / "page.md").write_text("local\n", encoding="utf-8")
_publish(push=False)
assert "page.md" in _git(repo, "show", "--name-only", "HEAD").stdout
def test_unreadable_allowlist_fails_instead_of_falling_open(repo):
"""A broken file must not be read as 'no restriction' - that would turn a
corrupted safeguard into a silently disabled one."""
(repo / config.PUBLISH_REMOTES_FILENAME).write_text("{not json", encoding="utf-8")
with pytest.raises(typer.Exit):
git_publish.read_allowed_push_urls()
def test_allowlist_without_a_usable_list_fails(repo):
(repo / config.PUBLISH_REMOTES_FILENAME).write_text(
json.dumps({"schema": 1, "allowed_push_urls": "not-a-list"}), encoding="utf-8"
)
with pytest.raises(typer.Exit):
git_publish.read_allowed_push_urls()
def test_empty_allowlist_refuses_everything(repo):
"""An empty list is a deliberate 'publish nowhere', not an oversight that
should behave like an absent file."""
_allowlist(repo)
assert git_publish.publish_remote_refusal("origin", "main") is not None
def test_gate_has_no_flag_that_opens_it(repo):
"""The other two gates clear with a token; this one deliberately does not,
because the right fix is a deliberate edit by the user."""
import inspect
params = inspect.signature(publish_command).parameters
assert not any("remote" in name and "confirm" in name for name in params)
+2 -2
View File
@@ -99,13 +99,13 @@ def test_map_deep_links_an_inlined_area_by_anchor(kb_dir):
def test_collection_shard_holds_the_page_rows(plan, kb_dir):
entities = _shard(plan, kb_dir, "entities")
assert "[[aurora]]" in entities
assert "[[Nathan]]" in entities
assert "[[Borealis]]" in entities
assert "[[gdeploy]]" in entities
def test_pages_are_grouped_by_area_and_sorted_case_insensitively(plan, kb_dir):
systems = _section(_shard(plan, kb_dir, "entities"), f"## {_area_title('system')}")
assert systems.index("[[aurora]]") < systems.index("[[Nathan]]")
assert systems.index("[[aurora]]") < systems.index("[[Borealis]]")
assert "[[gdeploy]]" not in systems
+1 -1
View File
@@ -31,7 +31,7 @@ def test_directory_without_a_contract_is_not_a_collection(repo):
def test_area_resolves_to_its_enclosing_collection(repo):
page = repo / "kb" / "entities" / "systems" / "hermes.md"
page = repo / "kb" / "entities" / "systems" / "gateway.md"
assert kb_collections.kb_collection_of(page) == repo / "kb" / "entities"
+2 -2
View File
@@ -128,9 +128,9 @@ def test_lint_detects_orphan_page(kb_dir):
)
report = run_lint(kb_dir)
assert "isolated" in report["orphan_pages"]
# aurora and Nathan link to each other, so they must not be reported as orphans.
# aurora and Borealis link to each other, so they must not be reported as orphans.
assert "aurora" not in report["orphan_pages"]
assert "Nathan" not in report["orphan_pages"]
assert "Borealis" not in report["orphan_pages"]
def test_lint_detects_missing_frontmatter_fields(kb_dir):
+4 -4
View File
@@ -176,7 +176,7 @@ provenance: sourced
# Aurora
Links to [[Nathan]] and again to [[Nathan]].
Links to [[Borealis]] and again to [[Borealis]].
"""
@@ -214,19 +214,19 @@ def test_verify_is_clean_when_only_prose_moved(git_instance, capsys):
def test_verify_catches_a_dropped_link_against_history(git_instance, capsys):
page = git_instance / "kb" / "entities" / "Aurora.md"
page.write_text(PAGE.replace(" and again to [[Nathan]]", ""), encoding="utf-8")
page.write_text(PAGE.replace(" and again to [[Borealis]]", ""), encoding="utf-8")
with pytest.raises(typer.Exit):
migrate_cmd.verify_command(
from_rev="HEAD", path=None, expect_body_change=False, json_out=True, fail_on_error=True
)
result = json.loads(capsys.readouterr().out)
assert result["findings"][0]["kind"] == "wikilinks"
assert "'Nathan' 2->1" in result["findings"][0]["detail"]
assert "'Borealis' 2->1" in result["findings"][0]["detail"]
def test_verify_scopes_to_a_subtree(git_instance, capsys):
page = git_instance / "kb" / "entities" / "Aurora.md"
page.write_text(PAGE.replace(" and again to [[Nathan]]", ""), encoding="utf-8")
page.write_text(PAGE.replace(" and again to [[Borealis]]", ""), encoding="utf-8")
migrate_cmd.verify_command(
from_rev="HEAD",
path=["kb/concepts"],
+8 -8
View File
@@ -60,7 +60,7 @@ def test_new_entity_creates_page_with_expected_frontmatter(monkeypatch, kb_dir):
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", "gateway.example.net",
"--set", "entity_type=system", "--set", "tags=gateway,firewall",
"--set", "related=Nathan", "--set", "confidence=0.9",
"--set", "related=Borealis", "--set", "confidence=0.9",
"--set", "provenance=general",
])
assert result.exit_code == 0, result.output
@@ -70,7 +70,7 @@ def test_new_entity_creates_page_with_expected_frontmatter(monkeypatch, kb_dir):
assert fm["type"] == "types/entity.md"
assert fm["entity_type"] == "system"
assert fm["tags"] == ["gateway", "firewall"]
assert fm["related"] == ["Nathan"]
assert fm["related"] == ["Borealis"]
assert fm["confidence"] == 0.9
assert "# gateway.example.net" in body
@@ -172,18 +172,18 @@ def test_new_source_prefixes_title_and_prefills_related_entities(monkeypatch, kb
result = _invoke_new(monkeypatch, kb_dir, [
"new", "source", "--name", "gateway.example.net",
"--set", "raw_files=raw/notes/gateway.example.net.md",
"--set", "entities=aurora,Nathan",
"--set", "entities=aurora,Borealis",
])
assert result.exit_code == 0, result.output
path = kb_dir / "sources/Source - gateway.example.net.md"
assert path.exists()
fm, body = read_page(path)
assert fm["type"] == "types/source.md"
assert fm["entities"] == ["aurora", "Nathan"]
assert fm["entities"] == ["aurora", "Borealis"]
assert fm["author"] == "Torben" # WIKI_AUTHOR override
assert fm["source_type"] == "notes" # schema default
assert "[[aurora]]" in body
assert "[[Nathan]]" in body
assert "[[Borealis]]" in body
def test_new_source_author_falls_back_to_git_config(monkeypatch, kb_dir):
@@ -264,13 +264,13 @@ def test_new_concept_rejects_invalid_provenance(monkeypatch, kb_dir):
def test_new_comparison_renders_table_columns(monkeypatch, kb_dir):
result = _invoke_new(monkeypatch, kb_dir, [
"new", "comparison", "--name", "A vs B", "--set", "entities=aurora,Nathan",
"new", "comparison", "--name", "A vs B", "--set", "entities=aurora,Borealis",
])
assert result.exit_code == 0, result.output
fm, body = read_page(kb_dir / "comparisons/A vs B.md")
assert fm["entities"] == ["aurora", "Nathan"]
assert fm["entities"] == ["aurora", "Borealis"]
# Table columns are rendered by the generic table_* template filters.
assert "[[aurora]] | [[Nathan]]" in body
assert "[[aurora]] | [[Borealis]]" in body
def test_new_comparison_rejects_single_entity(monkeypatch, kb_dir):
+45 -45
View File
@@ -78,49 +78,49 @@ def test_retarget_cite_ids_is_noop_without_a_footnotes_block():
def test_rename_refreshes_stale_slug_derived_cite_id(patched_wiki):
old_id = cite_id("Nathan")
old_id = cite_id("Borealis")
write_page(
patched_wiki / "concepts/uses-nathan.md",
patched_wiki / "concepts/uses-borealis.md",
{
"type": "types/concept.md", "concept_type": "protocol",
"tags": [], "created": "2026-07-25", "modified": "2026-07-25",
"related": [], "sources": [], "confidence": 0.7,
},
f"\n# uses-nathan\n\nRuns on it [^{old_id}].\n\n## Footnotes\n\n[^{old_id}]: [[Nathan]]\n",
f"\n# uses-borealis\n\nRuns on it [^{old_id}].\n\n## Footnotes\n\n[^{old_id}]: [[Borealis]]\n",
)
page_ops.rename_command(old="Nathan", new="nathan-ws", dry_run=False)
page_ops.rename_command(old="Borealis", new="borealis-ws", dry_run=False)
_frontmatter, body = read_page(patched_wiki / "concepts/uses-nathan.md")
new_id = cite_id("nathan-ws")
assert f"[^{new_id}]: [[nathan-ws]]" in body
_frontmatter, body = read_page(patched_wiki / "concepts/uses-borealis.md")
new_id = cite_id("borealis-ws")
assert f"[^{new_id}]: [[borealis-ws]]" in body
assert f"[^{old_id}]" not in body
def test_rename_updates_body_links_and_frontmatter(patched_wiki):
page_ops.rename_command(old="Nathan", new="nathan-ws", dry_run=False)
page_ops.rename_command(old="Borealis", new="borealis-ws", dry_run=False)
assert (patched_wiki / "entities/systems/nathan-ws.md").exists()
assert not (patched_wiki / "entities/systems/Nathan.md").exists()
assert (patched_wiki / "entities/systems/borealis-ws.md").exists()
assert not (patched_wiki / "entities/systems/Borealis.md").exists()
frontmatter, body = read_page(patched_wiki / "entities/systems/aurora.md")
assert frontmatter["related"] == ["nathan-ws"]
assert "[[nathan-ws]]" in body
assert "[[Nathan]]" not in body
assert frontmatter["related"] == ["borealis-ws"]
assert "[[borealis-ws]]" in body
assert "[[Borealis]]" not in body
def test_rename_rewrites_the_pages_own_h1(patched_wiki):
page_ops.rename_command(old="Nathan", new="nathan-ws", dry_run=False)
_frontmatter, body = read_page(patched_wiki / "entities/systems/nathan-ws.md")
assert "# nathan-ws" in body
assert "# Nathan\n" not in body
page_ops.rename_command(old="Borealis", new="borealis-ws", dry_run=False)
_frontmatter, body = read_page(patched_wiki / "entities/systems/borealis-ws.md")
assert "# borealis-ws" in body
assert "# Borealis\n" not in body
def test_rename_dry_run_writes_nothing(patched_wiki):
page_ops.rename_command(old="Nathan", new="nathan-ws", dry_run=True)
assert (patched_wiki / "entities/systems/Nathan.md").exists()
page_ops.rename_command(old="Borealis", new="borealis-ws", dry_run=True)
assert (patched_wiki / "entities/systems/Borealis.md").exists()
frontmatter, _body = read_page(patched_wiki / "entities/systems/aurora.md")
assert frontmatter["related"] == ["Nathan"]
assert frontmatter["related"] == ["Borealis"]
def test_rename_rejects_missing_page(patched_wiki):
@@ -136,15 +136,15 @@ def test_rename_repoints_references_to_an_existing_page(patched_wiki):
patched_wiki / "entities/tools/gdeploy.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
"created": "2026-07-25", "modified": "2026-07-25",
"related": ["nathan"], "sources": [], "confidence": 0.8},
"\n# gdeploy\n\n## See Also\n\n- [[nathan]]\n",
"related": ["borealis"], "sources": [], "confidence": 0.8},
"\n# gdeploy\n\n## See Also\n\n- [[borealis]]\n",
)
page_ops.rename_command(old="nathan", new="Nathan", dry_run=False)
page_ops.rename_command(old="borealis", new="Borealis", dry_run=False)
frontmatter, body = read_page(patched_wiki / "entities/tools/gdeploy.md")
assert frontmatter["related"] == ["Nathan"]
assert "[[Nathan]]" in body
assert (patched_wiki / "entities/systems/Nathan.md").exists()
assert frontmatter["related"] == ["Borealis"]
assert "[[Borealis]]" in body
assert (patched_wiki / "entities/systems/Borealis.md").exists()
def test_rename_reference_only_mode_requires_the_target_to_exist(patched_wiki):
@@ -161,12 +161,12 @@ def test_rename_reference_only_mode_requires_the_target_to_exist(patched_wiki):
def test_rename_rejects_existing_target(patched_wiki):
with pytest.raises(typer.Exit):
page_ops.rename_command(old="Nathan", new="aurora", dry_run=False)
page_ops.rename_command(old="Borealis", new="aurora", dry_run=False)
def test_rename_rejects_identical_titles(patched_wiki):
with pytest.raises(typer.Exit):
page_ops.rename_command(old="Nathan", new="Nathan", dry_run=False)
page_ops.rename_command(old="Borealis", new="Borealis", dry_run=False)
def test_rename_fixes_a_dangling_source_reference(patched_wiki):
@@ -201,24 +201,24 @@ def test_rename_stops_before_renaming_the_file_if_a_reference_write_fails(patche
monkeypatch.setattr(page_ops_module, "write_page", flaky_write_page)
with pytest.raises(typer.Exit):
page_ops.rename_command(old="Nathan", new="nathan-ws", dry_run=False)
page_ops.rename_command(old="Borealis", new="borealis-ws", dry_run=False)
assert (patched_wiki / "entities/systems/Nathan.md").exists()
assert not (patched_wiki / "entities/systems/nathan-ws.md").exists()
assert (patched_wiki / "entities/systems/Borealis.md").exists()
assert not (patched_wiki / "entities/systems/borealis-ws.md").exists()
def test_rm_refuses_referenced_page_without_yes(patched_wiki):
with pytest.raises(typer.Exit):
page_ops.rm_command(page_title="Nathan", yes=False, dry_run=False)
assert (patched_wiki / "entities/systems/Nathan.md").exists()
page_ops.rm_command(page_title="Borealis", yes=False, dry_run=False)
assert (patched_wiki / "entities/systems/Borealis.md").exists()
def test_rm_deletes_and_delinks_with_yes(patched_wiki):
page_ops.rm_command(page_title="Nathan", yes=True, dry_run=False)
page_ops.rm_command(page_title="Borealis", yes=True, dry_run=False)
assert not (patched_wiki / "entities/systems/Nathan.md").exists()
assert not (patched_wiki / "entities/systems/Borealis.md").exists()
frontmatter, body = read_page(patched_wiki / "entities/systems/aurora.md")
assert frontmatter["related"] == []
assert "[[Nathan]]" not in body
assert "[[Borealis]]" not in body
def test_rm_stops_before_deleting_the_file_if_a_delink_write_fails(patched_wiki, monkeypatch):
@@ -234,9 +234,9 @@ def test_rm_stops_before_deleting_the_file_if_a_delink_write_fails(patched_wiki,
monkeypatch.setattr(page_ops_module, "write_page", flaky_write_page)
with pytest.raises(typer.Exit):
page_ops.rm_command(page_title="Nathan", yes=True, dry_run=False)
page_ops.rm_command(page_title="Borealis", yes=True, dry_run=False)
assert (patched_wiki / "entities/systems/Nathan.md").exists()
assert (patched_wiki / "entities/systems/Borealis.md").exists()
def test_rm_of_unreferenced_page_needs_no_confirmation(patched_wiki):
@@ -259,20 +259,20 @@ def test_rm_leaves_prose_references_and_reports_them(patched_wiki, capsys):
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
"created": "2026-07-25", "modified": "2026-07-25", "related": [],
"sources": [], "confidence": 0.8},
"\n# gdeploy\n\n## Description\n\nRuns on [[Nathan]] nightly.\n",
"\n# gdeploy\n\n## Description\n\nRuns on [[Borealis]] nightly.\n",
)
page_ops.rm_command(page_title="Nathan", yes=True, dry_run=False)
page_ops.rm_command(page_title="Borealis", yes=True, dry_run=False)
_frontmatter, body = read_page(patched_wiki / "entities/tools/gdeploy.md")
assert "Runs on [[Nathan]] nightly." in body
assert "remaining [[Nathan]] reference(s)" in capsys.readouterr().out
assert "Runs on [[Borealis]] nightly." in body
assert "remaining [[Borealis]] reference(s)" in capsys.readouterr().out
def test_rm_dry_run_writes_nothing(patched_wiki):
page_ops.rm_command(page_title="Nathan", yes=True, dry_run=True)
assert (patched_wiki / "entities/systems/Nathan.md").exists()
page_ops.rm_command(page_title="Borealis", yes=True, dry_run=True)
assert (patched_wiki / "entities/systems/Borealis.md").exists()
frontmatter, _body = read_page(patched_wiki / "entities/systems/aurora.md")
assert frontmatter["related"] == ["Nathan"]
assert frontmatter["related"] == ["Borealis"]
def test_inbound_pages_sees_frontmatter_only_references(patched_wiki):
+4 -4
View File
@@ -161,7 +161,7 @@ def test_the_block_is_re_emitted_last_so_the_layout_self_heals():
body = (
"# Page\n\nProse [^s-aurora].\n\n"
+ render_cite_block(definitions)
+ "\n## Siehe auch\n\n- [[Nathan]]\n"
+ "\n## Siehe auch\n\n- [[Borealis]]\n"
)
rebuilt = render_page_body(*split_cite_block(body))
assert rebuilt.index("## Siehe auch") < rebuilt.index("[^s-aurora]:")
@@ -173,7 +173,7 @@ def test_repeated_round_trips_are_stable():
body = (
"# Page\n\nProse [^s-aurora].\n\n"
+ render_cite_block(definitions)
+ "\n## Siehe auch\n\n- [[Nathan]]\n"
+ "\n## Siehe auch\n\n- [[Borealis]]\n"
)
once = render_page_body(*split_cite_block(body))
twice = render_page_body(*split_cite_block(once))
@@ -200,7 +200,7 @@ def test_a_citation_used_in_a_rescued_section_still_resolves():
"# Page\n\nProse.\n\n"
"## Fußnoten\n\n"
"[^s-aurora]: [[Source - Aurora]]\n\n"
"## Beziehungen\n\n- **belegt durch:** [[Nathan]] [^s-aurora]\n"
"## Beziehungen\n\n- **belegt durch:** [[Borealis]] [^s-aurora]\n"
)
assert extract_inline_cites(body) == {("Source - Aurora", None)}
@@ -368,7 +368,7 @@ def test_sources_trace_by_raw_and_by_page(kb_dir, raw_dir, monkeypatch):
kb_dir / "entities/systems/aurora.md",
{
"type": "entity", "entity_type": "system", "tags": ["server"],
"created": "2026-07-31", "modified": "2026-07-31", "related": ["Nathan"],
"created": "2026-07-31", "modified": "2026-07-31", "related": ["Borealis"],
"sources": ["Source - Aurora"], "confidence": 0.9,
},
"\n# aurora\n\n## Description\n\nHosts things.\n",
+3 -3
View File
@@ -74,7 +74,7 @@ def test_parse_predicate_rejects_malformed(raw):
def test_exact_match_on_frontmatter_field(search):
assert _titles(search(_q("entity_type=system"))) == ["aurora", "Nathan"]
assert _titles(search(_q("entity_type=system"))) == ["aurora", "Borealis"]
def test_membership_on_list_field(search):
@@ -86,13 +86,13 @@ def test_substring_match_is_case_insensitive(search):
def test_numeric_comparison(search):
assert _titles(search(_q("confidence>=0.9"))) == ["aurora", "Nathan"]
assert _titles(search(_q("confidence>=0.9"))) == ["aurora", "Borealis"]
def test_date_comparison_handles_yaml_date_objects(search):
"""PyYAML parses `modified: 2026-08-02` into a date, not a string - the
comparison has to normalise it or it never matches."""
assert _titles(search(_q("modified>=2026-08-01"))) == ["Nathan"]
assert _titles(search(_q("modified>=2026-08-01"))) == ["Borealis"]
def test_exists_and_absent_are_complementary(search, pages):
+1 -1
View File
@@ -172,7 +172,7 @@ def test_page_reference_fields_are_refused_and_name_xref(touch_wiki, capsys):
frontmatter write would leave the other half stale."""
for field in ("related", "sources", "entities", "concepts"):
with pytest.raises(typer.Exit):
_touch(page_title="aurora", set_fields=[f"{field}=Nathan"])
_touch(page_title="aurora", set_fields=[f"{field}=Borealis"])
assert "xref" in capsys.readouterr().out