Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2f7dec122 | |||
| 29063f511b | |||
| 7263f85936 | |||
| fb97d46888 | |||
| f63a72cb24 |
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+41
-4
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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(),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user