Der Korpus sagt, woran die Reste im Ganzen liegen
Der Befund über das Erzeugnis ruhte auf handverlesenen Fällen. Rund drei Dutzend
Akzeptanzprüfungen sagen, dass diese Hefte aufgehen; sie sagen nicht, woran die
Reste im Ganzen liegen — ob an der Befehlssprache eines Landes, an der
Aufbereitung des Druckwerks oder am Alter der eingesetzten Stammfassung. Die
Auszählung der Gründe beantwortet das für ein Heft; über einen Korpus geführt
beantwortet sie es für das Erzeugnis.
Der Schalter --korpus arbeitet eine Auftragsliste ab und gibt je Auftrag eine
Zeile Kennzahlen aus: erschlossene, angewandte, liegengebliebene und am Stichtag
zurückgestellte Befehle, sodann die gleichen, geprüften, fehlenden, überzähligen
und abweichenden Normen des Abgleichs, zuletzt die Gründe nach Häufigkeit. Die
Liste nennt dieselben Angaben, die auch die Befehlszeile entgegennimmt — ein
Auftrag ist nichts anderes als ein Lauf. Mit --synopsen bleiben die
Einzelsynopsen samt einer Übersichtsseite stehen; ohne sie wäre der Bericht eine
Zahlenreihe, aus der niemand den Grund eines Restes erführe.
Eingecheckt ist der Bericht die Grundlinie. --grundlinie hält den Lauf dagegen
und rügt allein, was fällt: weniger erschlossene oder angewandte Befehle, weniger
gleiche Normen, mehr liegengebliebene, fehlende, überzählige oder abweichende.
Ein Fortschritt ist kein Fehler, sondern der Zweck der Arbeit; er verlangt
allein, die Grundlinie fortzuschreiben. Der Rückschritt endet mit 3 wie ein nicht
aufgehender Abgleich. Ein gescheiterter Auftrag hält den Lauf nicht an, sondern
trägt seinen Fehlertext in seiner Zeile — der Grundsatz der Nichtverwerfung gilt
auch hier.
Der mitgelieferte Korpus umfasst einunddreißig Aufträge und gibt 1413 Befehle
her, von denen 1287 angewandt werden; 632 von 641 verglichenen Normen gehen auf.
Er reproduziert die Zahlen der bestehenden Akzeptanzfälle (UWG 19/0, GEG 119/119,
BayJG 154/154, Berlin 171/171, WDR 101, Bremen 41/37, Hamburg 21/17). Drei
Aufträge tragen mit Absicht hohe Reste: Bei den beiden Beschlussempfehlungen und
bei Rheinland-Pfalz schreibt die Vorlage eine ältere Fassung fort als die
eingesetzte Stammfassung. Sie sind gleichwohl Wächter — steigen ihre Reste, so
hat sich etwas verschlechtert.
Nebenbei: Die Auszählung der Gründe stand doppelt (Befehlszeile und Synopse) und
liegt nun in Pipeline.zaehleGruende; das Ergebnis der Pipeline führt sie mit.
Geprüft: mvnw verify (443 Testfälle) und reuse lint (214/214) gehen durch; der
Korpuslauf ist Teil der Prüfung. Die Rügeregel ist am ganzen Korpus erprobt: Eine
um eins verschärfte Grundlinie führt zur Rüge „UWG-BGBl: angewandte Befehle 19
statt 20“ und zum Rückgabewert 3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I4e10b041b78d95772da1c3ff18ab92b0b45dacaa
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt
index 677db5d..17572b5 100644
--- a/FASSUNGEN.txt
+++ b/FASSUNGEN.txt
@@ -21,7 +21,102 @@
════════════════════════════════════════════════════════════════════════════════
- Fassung vom 28. August 2026,
+ Fassung vom 28. August 2026 (zweite Fassung des Tages),
+ zuletzt geändert durch die am 28. August 2026 vorgenommenen Änderungen
+════════════════════════════════════════════════════════════════════════════════
+
+Der Befund über das Erzeugnis ruhte bislang auf handverlesenen Fällen. Rund drei
+Dutzend Akzeptanzprüfungen sagen, dass diese Hefte aufgehen; sie sagen nicht,
+woran die Reste im Ganzen liegen, und die Auszählung der Gründe blieb auf den
+einzelnen Lauf beschränkt. Der Massenlauf beantwortet das für den Korpus: eine
+Zeile Kennzahlen je Heft, und, eingecheckt, eine Grundlinie, hinter die kein
+späterer Lauf zurückfallen darf. Der Korpus umfasst einunddreißig Aufträge; er
+gibt 1413 Befehle her, von denen 1287 angewandt werden, und 632 von 641 mit der
+amtlichen Nachfassung verglichenen Normen gehen auf.
+
+
+Artikel 1
+Der Korpusbericht
+
+(1) Der Schalter „--korpus“ nimmt eine Auftragsliste entgegen und gibt statt einer
+ Synopse je Auftrag eine Zeile Kennzahlen aus (Korpusbericht). Die Liste ist
+ eine Tabulatortabelle mit den Spalten Bezeichnung, Stammfassung, Hefte,
+ Artikel, Nachfassung und Stichtag; es sind dieselben Angaben, die auch die
+ Befehlszeile entgegennimmt. Ein Auftrag ist nichts anderes als ein Lauf.
+
+(2) Der Bericht führt je Auftrag die erschlossenen, die angewandten, die liegen
+ gebliebenen und die am Stichtag zurückgestellten Befehle, sodann die gleichen,
+ geprüften, fehlenden, überzähligen und abweichenden Normen des Abgleichs und
+ zuletzt die Gründe nach Häufigkeit.
+
+(3) Der Schalter „--grundlinie“ hält den Lauf gegen einen früheren Bericht. Gerügt
+ wird allein, was fällt: weniger erschlossene oder angewandte Befehle, weniger
+ gleiche Normen, mehr liegengebliebene, fehlende, überzählige oder abweichende.
+ Ein Fortschritt ist kein Fehler, sondern der Zweck der Arbeit; er verlangt
+ allein, die Grundlinie fortzuschreiben. Der Rückschritt endet mit dem
+ Rückgabewert 3 wie ein nicht aufgehender Abgleich.
+
+(4) Der Schalter „--synopsen“ behält die Einzelsynopsen und legt eine
+ Übersichtsseite daneben, die auf jede von ihnen verweist. Ohne sie wäre der
+ Bericht eine Zahlenreihe, aus der niemand den Grund eines Restes erführe.
+
+(5) Ein Auftrag, der mit einem Fehler abbricht, hält den Lauf nicht an; seine
+ Zeile trägt den Fehlertext, und die Grundlinie rügt ihn. Der Grundsatz der
+ Nichtverwerfung gilt auch für den Massenlauf.
+
+(6) Die Auszählung der Gründe, die bisher in der Befehlszeilenfassung stand, ist
+ in die Pipeline gewandert (Pipeline.zaehleGruende) und wird von beiden
+ verwendet; das Ergebnis (Pipeline.Ergebnis) führt sie fortan mit.
+
+
+Artikel 2
+Der Beispielkorpus
+
+(1) Unter „sampledata/korpus.tsv“ treten einunddreißig Aufträge hinzu, nämlich
+ dieselben, die die Akzeptanzprüfungen fahren, nebst den Entwürfen, den beiden
+ Beschlussempfehlungen und einem Stichtagsfall.
+
+(2) Drei Aufträge tragen mit Absicht hohe Reste: Bei den Beschlussempfehlungen und
+ bei Rheinland-Pfalz schreibt die Vorlage eine ältere Fassung fort als die
+ eingesetzte Stammfassung, weshalb deren Zieltexte nicht vorkommen. Sie sind
+ gleichwohl Wächter — steigen ihre Reste, so hat sich etwas verschlechtert.
+
+(3) Die Grundlinie („sampledata/korpus-grundlinie.tsv“) hält den Stand dieses
+ Tages fest. Sie und die Auftragsliste sind eigene Arbeit; ihr Vermerk steht in
+ der Zuordnungsdatei (REUSE.toml) und nicht als Kopfzeile in der Datei, weil
+ eine neu gesetzte Grundlinie ihn sonst verlöre.
+
+(4) Das Skript „deploy/korpuslauf.sh“ stellt das Erzeugnis her, falls es fehlt,
+ fährt den Lauf und legt Bericht, Synopsen und Übersicht in „target/korpus“ ab.
+
+
+Artikel 3
+Prüfung
+
+(1) Der Korpuslauf wird mitgeprüft (KorpusberichtTest): Kein Auftrag darf
+ scheitern, und keine Kennzahl darf hinter die Grundlinie zurückfallen.
+
+(2) Die Rügeregel selbst wird für sich geprüft — dass der Fortschritt
+ ungerügt bleibt, der Rückschritt in jeder der vier Kennzahlen gerügt wird, ein
+ gescheiterter Auftrag auffällt und ein aus dem Lauf verschwundener ebenfalls.
+
+(3) Dass die Liste vollständig gelesen wird, ist eigens geprüft: Anmerkungszeilen
+ werden übergangen, „-“ heißt „nicht angegeben“, mehrere Hefte werden durch
+ Komma getrennt, und der Stichtag wird als Datum gelesen.
+
+
+Schlussbestimmung
+
+Die vorstehenden Änderungen sind durch die vollständige Prüfung (mvnw verify,
+vierhundertdreiundvierzig Testfälle) und durch die Lizenzprüfung (reuse lint,
+zweihundertvierzehn von zweihundertvierzehn Dateien) bestätigt worden. Die
+Rügeregel ist überdies am ganzen Korpus erprobt worden: Eine um eins verschärfte
+Grundlinie führt zur Rüge „UWG-BGBl: angewandte Befehle 19 statt 20“ und zum
+Rückgabewert 3.
+
+
+════════════════════════════════════════════════════════════════════════════════
+ Fassung vom 28. August 2026 (erste Fassung des Tages),
zuletzt geändert durch die am 28. August 2026 vorgenommenen Änderungen
════════════════════════════════════════════════════════════════════════════════
diff --git a/README.md b/README.md
index 49f0674..4d06524 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,7 @@
- [§ 6a Die fortgeschriebene Fassung; die Kette](#-6a-die-fortgeschriebene-fassung-die-kette)
- [§ 6b Abgleich mit der amtlichen Nachfassung](#-6b-abgleich-mit-der-amtlichen-nachfassung)
- [§ 6c Probe auf die Inhaltsübersicht](#-6c-probe-auf-die-inhaltsübersicht)
+- [§ 6d Der Massenlauf; der Korpusbericht](#-6d-der-massenlauf-der-korpusbericht)
- [§ 7 Erkannte Änderungsbefehle](#-7-erkannte-änderungsbefehle)
- [§ 8 Reihenfolge der Anwendung](#-8-reihenfolge-der-anwendung)
- [§ 9 Aufbereitung der Druckwerke](#-9-aufbereitung-der-druckwerke)
@@ -269,6 +270,11 @@
gelten dieselben Eingaben wie für das Stammgesetz, auch die Anschriften des § 5
Absatz 4.
+**`--korpus <file>`**
+: Statt einer Synopse den Massenlauf über eine Auftragsliste fahren und einen
+ Bericht der Kennzahlen ausgeben (§ 6d); `--grundlinie` hält ihn gegen einen
+ früheren, `--synopsen` behält die Einzelsynopsen.
+
**`--extract-only`**
: Nur den bereinigten Lineartext des Änderungsdokuments ausgeben. Dies ist
angezeigt, wenn die PDF-Aufbereitung (§ 9) fehlerhaft bleibt: Der Text ist zu
@@ -395,6 +401,55 @@
amtliche Fassung selbst beides verschieden führt: Das Gebäudeenergiegesetz schreibt in
der Überschrift des § 71k „Gas“ und in der Angabe dazu „Erdgas“.
+## § 6d Der Massenlauf; der Korpusbericht
+
+(1) Eine Synopse sagt, was aus *einem* Heft geworden ist. Sie sagt nicht, woran die
+Reste im Ganzen liegen — ob an der Befehlssprache eines Landes, an der Aufbereitung
+des Druckwerks oder am Alter der eingesetzten Stammfassung. Der Schalter `--korpus`
+arbeitet deshalb eine ganze Auftragsliste ab und gibt je Auftrag eine Zeile
+Kennzahlen aus:
+
+```shell
+java -jar target/aendggner-0.1.0-SNAPSHOT.jar \
+ --korpus src/test/resources/sampledata/korpus.tsv \
+ --grundlinie src/test/resources/sampledata/korpus-grundlinie.tsv \
+ --synopsen target/korpus/synopsen -o target/korpus/bericht.tsv
+# → 31 Aufträge, 1413 Befehle, 1287 angewandt, 116 manuell; 632 von 641 Normen gleich
+```
+
+Dasselbe fährt `deploy/korpuslauf.sh`; es stellt das Erzeugnis her, falls es fehlt,
+und behält die Einzelsynopsen nebst einer Übersichtsseite, die auf sie verweist.
+
+(2) Die Auftragsliste ist eine Tabulatortabelle mit den Spalten Bezeichnung,
+Stammfassung, Hefte (durch Komma), Artikel, Nachfassung und Stichtag; „`-`“ heißt
+„nicht angegeben“, Zeilen mit „`#`“ sind Anmerkungen. Die Pfade beziehen sich auf
+das Verzeichnis der Liste. Es sind dieselben Angaben, die auch die Befehlszeile
+entgegennimmt — ein Auftrag der Liste ist nichts anderes als ein Lauf.
+
+(3) Der Bericht führt je Auftrag die erschlossenen, die angewandten, die
+liegengebliebenen und die am Stichtag zurückgestellten Befehle, sodann die gleichen,
+geprüften, fehlenden, überzähligen und abweichenden Normen des Abgleichs und
+zuletzt die Gründe nach Häufigkeit (§ 1 Absatz 5).
+
+(4) Wird der Bericht eingecheckt, so ist er die **Grundlinie**. Der Schalter
+`--grundlinie` hält den Lauf dagegen und rügt jede Kennzahl, die *gefallen* ist:
+weniger erschlossene oder angewandte Befehle, weniger gleiche Normen, mehr
+liegengebliebene, fehlende, überzählige oder abweichende. Ein Fortschritt ist kein
+Fehler, sondern der Zweck der Arbeit; er verlangt allein, die Grundlinie
+fortzuschreiben. Ein Rückschritt endet mit dem Rückgabewert 3 wie ein nicht
+aufgehender Abgleich (§ 6b Absatz 3).
+
+(5) Ein Auftrag, der mit einem Fehler abbricht, hält den Lauf nicht an; seine Zeile
+trägt den Fehlertext, und die Grundlinie rügt ihn. Der Grundsatz der
+Nichtverwerfung (§ 1 Absatz 4) gilt auch hier.
+
+(6) Der mitgelieferte Korpus umfasst, was die Akzeptanzprüfungen fahren (§ 17
+Absatz 2), und wird von diesen mitgeprüft. Drei Aufträge tragen mit Absicht hohe
+Reste: Bei den beiden Beschlussempfehlungen und bei Rheinland-Pfalz schreibt die
+Vorlage eine ältere Fassung fort als die eingesetzte Stammfassung, weshalb deren
+Zieltexte nicht vorkommen. Sie sind gleichwohl Wächter — steigen ihre Reste, so hat
+sich etwas verschlechtert.
+
## § 7 Erkannte Änderungsbefehle
(1) Erkannt werden die gebräuchlichsten Änderungsbefehle des Handbuchs der
@@ -984,6 +1039,14 @@
Beispieldaten nebst Herkunftsnachweis (`SOURCES`) liegen unter
`src/test/resources/sampledata/`.
+(2a) Über die Einzelfälle hinaus wird der **Korpuslauf** (§ 6d) mitgeprüft: Er fährt
+sämtliche Aufträge der Liste `src/test/resources/sampledata/korpus.tsv` und hält das
+Ergebnis gegen die eingecheckte Grundlinie
+(`korpus-grundlinie.tsv`). Damit ist nicht nur gesichert, dass jedes einzelne Heft
+aufgeht, sondern auch, dass keines hinter das zurückfällt, was es einmal hergegeben
+hat — und zwar in einer Zahl, die sich fortschreiben lässt, statt in Dutzenden
+verstreuter Behauptungen.
+
(3) Der Textausgeber (§ 6a) wird nicht am Augenschein geprüft, sondern am
**Rundlauf**: Was er schreibt, muss der Lader wieder zu demselben Gesetz lesen.
Geprüft wird das an sämtlichen Klartext-Stammfassungen des Beispielkorpus und
diff --git a/REUSE.toml b/REUSE.toml
index ce10cb4..a465b73 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -215,7 +215,10 @@
# Die *-alt.txt tragen amtlichen Wortlaut in selbst hergestellter Aufbereitung
# (Einrückung, Superskript-Satznummern, Gliederungsüberschriften); der Wortlaut ist
# gemeinfrei, die Aufbereitung eigene Arbeit. Die SOURCES-Dateien sind vollständig
-# eigener Text, stehen aber im selben Zusammenhang.
+# eigener Text, stehen aber im selben Zusammenhang. Ebenso die Auftragsliste des
+# Korpuslaufs und dessen Grundlinie: Sie werden vom Erzeugnis selbst geschrieben und
+# stehen deshalb hier und nicht als Kopfzeile in der Datei — sonst verlöre eine neu
+# gesetzte Grundlinie ihren Vermerk.
[[annotations]]
path = [
@@ -253,6 +256,8 @@
[[annotations]]
path = [
+ "src/test/resources/sampledata/korpus.tsv",
+ "src/test/resources/sampledata/korpus-grundlinie.tsv",
"src/test/resources/sampledata/AGG/SOURCES",
"src/test/resources/sampledata/BadenWuerttemberg/SOURCES",
"src/test/resources/sampledata/BayJG/SOURCES",
diff --git a/deploy/korpuslauf.sh b/deploy/korpuslauf.sh
new file mode 100755
index 0000000..be0533a
--- /dev/null
+++ b/deploy/korpuslauf.sh
@@ -0,0 +1,55 @@
+#!/bin/sh
+# SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+# Führt den Massenlauf über den Beispielkorpus (§ 6d des Handbuchs) und legt Bericht und
+# Einzelsynopsen in einem Ausgabeverzeichnis ab.
+#
+# Anders als die Akzeptanzprüfungen, die dasselbe im Rahmen von „mvnw verify“ tun, dient
+# dieses Skript dem Hinsehen: Es behält die Synopsen, damit sich jeder Rest an seinem
+# eigenen Dokument nachschlagen lässt, und es nennt den Bericht in der Fassung, in der er
+# auch als Grundlinie liegt.
+#
+# deploy/korpuslauf.sh [ausgabeverzeichnis] [auftragsliste]
+#
+# Voreinstellungen: target/korpus und src/test/resources/sampledata/korpus.tsv.
+# Rückgabewert 3, wenn eine Kennzahl hinter die Grundlinie zurückfällt (wie beim Abgleich
+# mit der amtlichen Nachfassung, § 6b Absatz 3).
+#
+# Ist eine neue Grundlinie zu setzen — weil eine Kennzahl gestiegen ist —, so tut das:
+# cp target/korpus/bericht.tsv src/test/resources/sampledata/korpus-grundlinie.tsv
+# nebst der Vorbemerkung, die in jener Datei steht.
+
+set -eu
+
+wurzel="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
+ziel="${1:-$wurzel/target/korpus}"
+liste="${2:-$wurzel/src/test/resources/sampledata/korpus.tsv}"
+grundlinie="$wurzel/src/test/resources/sampledata/korpus-grundlinie.tsv"
+jar="$wurzel/target/aendggner-0.1.0-SNAPSHOT.jar"
+
+if [ ! -f "$liste" ]; then
+ echo "Die Auftragsliste $liste fehlt." >&2
+ exit 1
+fi
+
+if [ ! -f "$jar" ]; then
+ echo "Das Erzeugnis fehlt; es wird hergestellt."
+ (cd "$wurzel" && ./mvnw -q package -DskipTests)
+fi
+
+mkdir -p "$ziel"
+
+set +e
+java -jar "$jar" \
+ --korpus "$liste" \
+ --grundlinie "$grundlinie" \
+ --synopsen "$ziel/synopsen" \
+ -o "$ziel/bericht.tsv"
+ergebnis=$?
+set -e
+
+echo
+echo "Bericht: $ziel/bericht.tsv"
+echo "Synopsen: $ziel/synopsen"
+exit "$ergebnis"
diff --git a/src/main/java/eu/mulk/aendggner/AendGgner.java b/src/main/java/eu/mulk/aendggner/AendGgner.java
index d089f21..3067a13 100644
--- a/src/main/java/eu/mulk/aendggner/AendGgner.java
+++ b/src/main/java/eu/mulk/aendggner/AendGgner.java
@@ -8,6 +8,7 @@
import eu.mulk.aendggner.aenderung.parse.TextBereiniger;
import eu.mulk.aendggner.anwendung.BefehlAnwender;
import eu.mulk.aendggner.anwendung.Grund;
+import eu.mulk.aendggner.bericht.Korpusbericht;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
@@ -15,7 +16,6 @@
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
-import java.util.EnumMap;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.logging.LogManager;
@@ -36,6 +36,7 @@
@Parameters(
index = "0",
+ arity = "0..1",
paramLabel = "<base law>",
description =
"The base law: gii-norm XML from gesetze-im-internet.de, or — for state law — the"
@@ -134,6 +135,28 @@
+ "exit code 3.")
private String nachfassung;
+ @Option(
+ names = "--korpus",
+ paramLabel = "<file>",
+ description =
+ "Run every job of the given tab-separated job list and write a report of key figures "
+ + "instead of a synopsis. Paths in the list are relative to the list's directory.")
+ private String korpus;
+
+ @Option(
+ names = "--grundlinie",
+ paramLabel = "<file>",
+ description =
+ "With --korpus: hold the run against this earlier report and complain about every figure "
+ + "that has fallen. A regression yields exit code 3.")
+ private String grundlinie;
+
+ @Option(
+ names = "--synopsen",
+ paramLabel = "<dir>",
+ description = "With --korpus: also write each job's synopsis into this directory.")
+ private String synopsen;
+
/** Die angegebenen Eingaben, jede über {@link Bezug} beschafft. */
private static List<Quelle> hole(List<String> angaben) throws IOException, InterruptedException {
var quellen = new ArrayList<Quelle>();
@@ -154,6 +177,15 @@
log.debugf("Logging configured.");
+ if (korpus != null) {
+ return fuehreKorpusAus();
+ }
+
+ if (baseFile == null) {
+ System.err.println("Fehler: das Stammgesetz muss angegeben werden.");
+ return 1;
+ }
+
if (dumpGesetz) {
var gesetz = Pipeline.ladeStammgesetz(Bezug.hole(baseFile));
System.out.printf(
@@ -305,12 +337,7 @@
* jedem Befehl darüber.
*/
private static void haeufigkeitDerGruende(BefehlAnwender.AnwendungsErgebnis anwendung) {
- var haeufigkeit = new EnumMap<Grund, Integer>(Grund.class);
- for (var eintrag : anwendung.protokoll()) {
- if (eintrag.status() == BefehlAnwender.Status.MANUELL_PRUEFEN && eintrag.grund() != null) {
- haeufigkeit.merge(eintrag.grund(), 1, Integer::sum);
- }
- }
+ var haeufigkeit = Pipeline.zaehleGruende(anwendung.protokoll());
if (haeufigkeit.isEmpty()) {
return;
}
@@ -320,6 +347,55 @@
.forEach(e -> System.out.printf(" %4d %s%n", e.getValue(), e.getKey().bezeichnung()));
}
+ /**
+ * Der Massenlauf: die Aufträge der Liste nacheinander, eine Zeile Kennzahlen je Auftrag.
+ *
+ * <p>Der Bericht geht dahin, wohin sonst die Synopse ginge ({@code -o}). Ist eine Grundlinie
+ * angegeben, so wird der Lauf gegen sie gehalten; jede gefallene Kennzahl wird gerügt und der
+ * Lauf endet mit 3 — dieselbe Zahl, mit der ein nicht aufgehender Abgleich endet.
+ */
+ private Integer fuehreKorpusAus() throws IOException {
+ var liste = Path.of(korpus);
+ var wurzel = liste.toAbsolutePath().getParent();
+ var auftraege = Korpusbericht.liesListe(liste);
+ log.infof("Korpuslauf: %d Aufträge aus %s.", auftraege.size(), liste);
+
+ var zeilen =
+ Korpusbericht.fuehreAus(auftraege, wurzel, synopsen == null ? null : Path.of(synopsen));
+ var tsv = Korpusbericht.alsTsv(zeilen);
+ if (output == null || output.equals("-") || output.equals("synopse.html")) {
+ // Die Voreinstellung des Schalters meint die Synopse; ein Bericht gehört dann auf die
+ // Standardausgabe und nicht in eine Datei namens „synopse.html“.
+ System.out.print(tsv);
+ } else {
+ Files.writeString(Path.of(output), tsv, StandardCharsets.UTF_8);
+ System.out.println("Bericht geschrieben: " + output);
+ }
+ if (synopsen != null) {
+ var uebersicht = Path.of(synopsen).resolveSibling("uebersicht.html");
+ Files.writeString(
+ uebersicht,
+ Korpusbericht.alsHtml(zeilen, Path.of(synopsen).getFileName().toString()),
+ StandardCharsets.UTF_8);
+ System.out.println("Übersicht geschrieben: " + uebersicht);
+ }
+ System.out.println(Korpusbericht.summe(zeilen));
+
+ if (grundlinie == null) {
+ return zeilen.stream().anyMatch(z -> z.fehler() != null) ? 3 : 0;
+ }
+ var ruegen =
+ Korpusbericht.gegenGrundlinie(
+ zeilen, Files.readString(Path.of(grundlinie), StandardCharsets.UTF_8));
+ if (ruegen.isEmpty()) {
+ System.out.println("Die Grundlinie ist gehalten.");
+ return 0;
+ }
+ System.out.println("Rückschritte gegenüber der Grundlinie:");
+ ruegen.forEach(r -> System.out.println(" " + r));
+ return 3;
+ }
+
private static String kuerze(String text) {
var einzeilig = text.replaceAll("\\s+", " ");
return einzeilig.length() <= 160 ? einzeilig : einzeilig.substring(0, 157) + "…";
diff --git a/src/main/java/eu/mulk/aendggner/Pipeline.java b/src/main/java/eu/mulk/aendggner/Pipeline.java
index e4ce9fb..483d456 100644
--- a/src/main/java/eu/mulk/aendggner/Pipeline.java
+++ b/src/main/java/eu/mulk/aendggner/Pipeline.java
@@ -30,7 +30,9 @@
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
+import java.util.EnumMap;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import org.jspecify.annotations.Nullable;
@@ -116,6 +118,11 @@
* @param abgleich der normweise Vergleich mit der amtlichen Nachfassung; {@code null}, wenn der
* Auftrag keine nannte.
*/
+ /**
+ * @param gruende die Auszählung der Gründe, aus denen Befehle liegengeblieben sind. Sie steht
+ * schon in der Synopse; als Zahl herausgegeben trägt sie überdies den Korpusbericht (§ 6d),
+ * der über viele Hefte hinweg zeigt, woran die Reste liegen.
+ */
public record Ergebnis(
String html,
long anzahlAngewandt,
@@ -123,7 +130,25 @@
int anzahlGeaenderteNormen,
int anzahlProtokollEintraege,
String neufassung,
- @Nullable Nachfassungsabgleich abgleich) {}
+ @Nullable Nachfassungsabgleich abgleich,
+ Map<Grund, Integer> gruende) {
+
+ public Ergebnis {
+ gruende = Map.copyOf(gruende);
+ }
+ }
+
+ /** Die Gründe der liegengebliebenen Befehle, nach Häufigkeit auszählbar. */
+ public static Map<Grund, Integer> zaehleGruende(
+ List<BefehlAnwender.AngewandteAenderung> protokoll) {
+ var haeufigkeit = new EnumMap<Grund, Integer>(Grund.class);
+ for (var eintrag : protokoll) {
+ if (eintrag.status() == BefehlAnwender.Status.MANUELL_PRUEFEN && eintrag.grund() != null) {
+ haeufigkeit.merge(eintrag.grund(), 1, Integer::sum);
+ }
+ }
+ return haeufigkeit;
+ }
/**
* Ein eingespeistes Änderungsdokument samt erkannter Art und aufbereitetem Text.
@@ -257,7 +282,8 @@
synopse.eintraege().size(),
protokoll.size(),
LandesRechtTextAusgeber.ausgeben(gesetz),
- abgleich);
+ abgleich,
+ zaehleGruende(protokoll));
}
/**
diff --git a/src/main/java/eu/mulk/aendggner/bericht/Korpusbericht.java b/src/main/java/eu/mulk/aendggner/bericht/Korpusbericht.java
new file mode 100644
index 0000000..1daba42
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/bericht/Korpusbericht.java
@@ -0,0 +1,476 @@
+// SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+package eu.mulk.aendggner.bericht;
+
+import eu.mulk.aendggner.Pipeline;
+import eu.mulk.aendggner.Quelle;
+import eu.mulk.aendggner.anwendung.Grund;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Der Massenlauf: viele Hefte in einem Durchgang, eine Zeile Kennzahlen je Heft.
+ *
+ * <p>Wozu: Ein Belegfall sagt, dass <em>dieses</em> Heft aufgeht. Er sagt nicht, woran die Reste im
+ * Ganzen liegen — ob an der Befehlssprache eines Landes, an der Aufbereitung des Druckwerks oder am
+ * Alter der Stammfassung. Die Auszählung der Gründe (§ 1 Absatz 5 des Handbuchs) beantwortet das
+ * für ein Heft; über einen Korpus geführt beantwortet sie es für das Erzeugnis.
+ *
+ * <p>Der Bericht ist zugleich eine Grundlinie: Wird er eingecheckt, so schlägt jeder Lauf an, bei
+ * dem eine Kennzahl <em>fällt</em> — weniger erschlossene Befehle, weniger angewandte, weniger
+ * gleiche Normen. Steigt eine, ist die Grundlinie fortzuschreiben; das ist der Vermerk der Welle.
+ *
+ * <p>Verworfen wird auch hier nichts: Ein Auftrag, der mit einem Fehler abbricht, hinterlässt seine
+ * Zeile samt Fehlertext und hält den Lauf nicht an.
+ */
+public final class Korpusbericht {
+
+ private Korpusbericht() {}
+
+ /** Kein Wert — in der Liste wie im Bericht. */
+ private static final String LEER = "-";
+
+ /**
+ * Ein Auftrag der Liste.
+ *
+ * @param bezeichnung der Name, unter dem der Auftrag im Bericht und in der Grundlinie steht.
+ * @param stamm die Stammfassung, relativ zum Verzeichnis der Liste.
+ * @param hefte die Änderungsdokumente, in der Reihenfolge der Liste.
+ * @param artikel nur diesen Artikel anwenden; {@code null} = alle betroffenen.
+ * @param nachfassung die amtliche Nachfassung; {@code null} = kein Abgleich, dann bleiben dessen
+ * Spalten leer.
+ * @param stichtag die Fassung dieses Tages; {@code null} = alle Befehle anwenden.
+ */
+ public record Auftrag(
+ String bezeichnung,
+ String stamm,
+ List<String> hefte,
+ @Nullable String artikel,
+ @Nullable String nachfassung,
+ @Nullable LocalDate stichtag) {
+
+ public Auftrag {
+ hefte = List.copyOf(hefte);
+ }
+ }
+
+ /**
+ * Die Kennzahlen eines Laufes.
+ *
+ * @param fehler der Grund des Abbruchs; {@code null}, wenn der Auftrag durchgelaufen ist.
+ */
+ public record Zeile(
+ String bezeichnung,
+ int befehle,
+ long angewandt,
+ long manuell,
+ long zurueckgestellt,
+ @Nullable Integer gleich,
+ @Nullable Integer geprueft,
+ @Nullable Integer fehlend,
+ @Nullable Integer ueberzaehlig,
+ @Nullable Integer abweichend,
+ Map<Grund, Integer> gruende,
+ @Nullable String fehler) {
+
+ public Zeile {
+ gruende = Map.copyOf(gruende);
+ }
+ }
+
+ /** Die Spaltenüberschriften des Berichts; sie stehen als Kommentarzeile obenan. */
+ private static final List<String> SPALTEN =
+ List.of(
+ "Bezeichnung",
+ "Befehle",
+ "angewandt",
+ "manuell",
+ "zurückgestellt",
+ "gleich",
+ "geprüft",
+ "fehlend",
+ "überzählig",
+ "abweichend",
+ "Gründe");
+
+ // ---------------------------------------------------------------- Liste lesen
+
+ /**
+ * Liest die Auftragsliste. Sie ist eine Tabulatortabelle mit den Spalten Bezeichnung,
+ * Stammfassung, Hefte (durch Komma getrennt), Artikel, Nachfassung, Stichtag; „-“ steht für
+ * „nicht angegeben“. Zeilen, die mit „#“ beginnen, und Leerzeilen werden übergangen.
+ */
+ public static List<Auftrag> liesListe(Path liste) throws IOException {
+ var auftraege = new ArrayList<Auftrag>();
+ int nummer = 0;
+ for (var zeile : Files.readAllLines(liste, StandardCharsets.UTF_8)) {
+ nummer++;
+ if (zeile.isBlank() || zeile.startsWith("#")) {
+ continue;
+ }
+ var felder = zeile.split("\t");
+ if (felder.length < 3) {
+ throw new IOException(
+ "%s, Zeile %d: Ein Auftrag braucht wenigstens Bezeichnung, Stammfassung und Heft."
+ .formatted(liste, nummer));
+ }
+ auftraege.add(
+ new Auftrag(
+ felder[0].strip(),
+ felder[1].strip(),
+ List.of(felder[2].strip().split("\\s*,\\s*")),
+ feld(felder, 3),
+ feld(felder, 4),
+ feld(felder, 5) == null ? null : LocalDate.parse(feld(felder, 5))));
+ }
+ return auftraege;
+ }
+
+ private static @Nullable String feld(String[] felder, int index) {
+ if (index >= felder.length) {
+ return null;
+ }
+ var wert = felder[index].strip();
+ return wert.isEmpty() || wert.equals(LEER) ? null : wert;
+ }
+
+ // ---------------------------------------------------------------- Lauf
+
+ /**
+ * Führt alle Aufträge aus.
+ *
+ * @param wurzel das Verzeichnis, auf das sich die Pfade der Liste beziehen.
+ * @param synopsen wohin die Einzelsynopsen zu legen sind; {@code null} = keine ablegen.
+ */
+ public static List<Zeile> fuehreAus(
+ List<Auftrag> auftraege, Path wurzel, @Nullable Path synopsen) {
+ var zeilen = new ArrayList<Zeile>(auftraege.size());
+ for (var auftrag : auftraege) {
+ zeilen.add(fuehreAus(auftrag, wurzel, synopsen));
+ }
+ return zeilen;
+ }
+
+ public static Zeile fuehreAus(Auftrag auftrag, Path wurzel, @Nullable Path synopsen) {
+ try {
+ var hefte = new ArrayList<Quelle>(auftrag.hefte().size());
+ for (var heft : auftrag.hefte()) {
+ hefte.add(Quelle.lies(wurzel.resolve(heft)));
+ }
+ var ergebnis =
+ Pipeline.erzeugeSynopse(
+ new Pipeline.Auftrag(
+ Quelle.lies(wurzel.resolve(auftrag.stamm())),
+ hefte,
+ auftrag.artikel(),
+ false,
+ auftrag.stichtag(),
+ auftrag.nachfassung() == null
+ ? null
+ : Quelle.lies(wurzel.resolve(auftrag.nachfassung()))));
+ if (synopsen != null) {
+ Files.createDirectories(synopsen);
+ Files.writeString(
+ synopsen.resolve(dateiname(auftrag.bezeichnung()) + ".html"),
+ ergebnis.html(),
+ StandardCharsets.UTF_8);
+ }
+ var abgleich = ergebnis.abgleich();
+ return new Zeile(
+ auftrag.bezeichnung(),
+ ergebnis.anzahlProtokollEintraege(),
+ ergebnis.anzahlAngewandt(),
+ ergebnis.anzahlManuell(),
+ // Was am Stichtag noch nicht galt, ist weder angewandt noch liegengeblieben.
+ ergebnis.anzahlProtokollEintraege()
+ - ergebnis.anzahlAngewandt()
+ - ergebnis.anzahlManuell(),
+ abgleich == null ? null : abgleich.gleich(),
+ abgleich == null ? null : abgleich.geprueft(),
+ abgleich == null ? null : abgleich.fehlende().size(),
+ abgleich == null ? null : abgleich.ueberzaehlige().size(),
+ abgleich == null ? null : abgleich.abweichungen().size(),
+ ergebnis.gruende(),
+ null);
+ } catch (Exception e) {
+ // Ein gescheiterter Auftrag hält den Lauf nicht an; sein Fehler steht in seiner Zeile.
+ return new Zeile(
+ auftrag.bezeichnung(),
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ Map.of(),
+ e.getClass().getSimpleName() + ": " + e.getMessage());
+ }
+ }
+
+ private static String dateiname(String bezeichnung) {
+ return bezeichnung.replaceAll("[^\\p{L}\\p{N}]+", "-");
+ }
+
+ // ---------------------------------------------------------------- Ausgabe
+
+ /** Der Bericht als Tabulatortabelle — die Form, in der er auch als Grundlinie liegt. */
+ public static String alsTsv(List<Zeile> zeilen) {
+ var sb = new StringBuilder("# ").append(String.join("\t", SPALTEN)).append('\n');
+ for (var zeile : zeilen) {
+ sb.append(zeile.bezeichnung())
+ .append('\t')
+ .append(zeile.befehle())
+ .append('\t')
+ .append(zeile.angewandt())
+ .append('\t')
+ .append(zeile.manuell())
+ .append('\t')
+ .append(zeile.zurueckgestellt())
+ .append('\t')
+ .append(zahl(zeile.gleich()))
+ .append('\t')
+ .append(zahl(zeile.geprueft()))
+ .append('\t')
+ .append(zahl(zeile.fehlend()))
+ .append('\t')
+ .append(zahl(zeile.ueberzaehlig()))
+ .append('\t')
+ .append(zahl(zeile.abweichend()))
+ .append('\t')
+ .append(gruendeText(zeile.gruende()));
+ if (zeile.fehler() != null) {
+ sb.append('\t').append(zeile.fehler().replace('\t', ' ').replace('\n', ' '));
+ }
+ sb.append('\n');
+ }
+ return sb.toString();
+ }
+
+ private static String zahl(@Nullable Integer wert) {
+ return wert == null ? LEER : String.valueOf(wert);
+ }
+
+ /** „Zieltext nicht vorhanden:3; Befehl nicht erkannt:1“ — in der Reihenfolge der Gründe. */
+ static String gruendeText(Map<Grund, Integer> gruende) {
+ if (gruende.isEmpty()) {
+ return LEER;
+ }
+ var geordnet = new EnumMap<>(gruende);
+ var sb = new StringBuilder();
+ for (var eintrag : geordnet.entrySet()) {
+ if (sb.length() > 0) {
+ sb.append("; ");
+ }
+ sb.append(eintrag.getKey().bezeichnung()).append(':').append(eintrag.getValue());
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Der Bericht als Übersichtsseite. Sie ist kein Ersatz für die Tabulatortabelle, sondern deren
+ * Lesefassung: Jede Zeile verweist auf die Synopse ihres Auftrags, sodass sich ein Rest an seinem
+ * eigenen Dokument nachschlagen lässt.
+ *
+ * @param verzeichnis das Verzeichnis der Einzelsynopsen, relativ zur Übersicht; {@code null} =
+ * ohne Verweise.
+ */
+ public static String alsHtml(List<Zeile> zeilen, @Nullable String verzeichnis) {
+ var sb = new StringBuilder();
+ sb.append("<!DOCTYPE html>\n<html lang=\"de\"><head><meta charset=\"utf-8\">\n")
+ .append("<title>Korpusbericht — ÄndGgner</title>\n")
+ .append("<style>\n")
+ .append("body { font-family: system-ui, sans-serif; margin: 2rem; max-width: 70rem; }\n")
+ .append("table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }\n")
+ .append("th, td { border: 1px solid #999; padding: 0.25rem 0.5rem; text-align: right; }\n")
+ .append("th:first-child, td:first-child { text-align: left; }\n")
+ .append("td.gruende { text-align: left; font-size: 0.8rem; }\n")
+ .append("tr.rest td { background: #fff6f2; }\n")
+ .append("caption { text-align: left; padding-bottom: 0.5rem; }\n")
+ .append("</style></head><body>\n")
+ .append("<h1>Korpusbericht</h1>\n<p>")
+ .append(esc(summe(zeilen)))
+ .append("</p>\n<table><thead><tr>");
+ for (var spalte : SPALTEN) {
+ sb.append("<th>").append(esc(spalte)).append("</th>");
+ }
+ sb.append("</tr></thead><tbody>\n");
+ for (var zeile : zeilen) {
+ sb.append(zeile.manuell() > 0 || zeile.fehler() != null ? "<tr class=\"rest\">" : "<tr>");
+ sb.append("<td>");
+ if (verzeichnis == null) {
+ sb.append(esc(zeile.bezeichnung()));
+ } else {
+ sb.append("<a href=\"")
+ .append(esc(verzeichnis))
+ .append('/')
+ .append(esc(dateiname(zeile.bezeichnung())))
+ .append(".html\">")
+ .append(esc(zeile.bezeichnung()))
+ .append("</a>");
+ }
+ sb.append("</td>");
+ for (var wert :
+ List.of(
+ String.valueOf(zeile.befehle()),
+ String.valueOf(zeile.angewandt()),
+ String.valueOf(zeile.manuell()),
+ String.valueOf(zeile.zurueckgestellt()),
+ zahl(zeile.gleich()),
+ zahl(zeile.geprueft()),
+ zahl(zeile.fehlend()),
+ zahl(zeile.ueberzaehlig()),
+ zahl(zeile.abweichend()))) {
+ sb.append("<td>").append(esc(wert)).append("</td>");
+ }
+ sb.append("<td class=\"gruende\">")
+ .append(esc(zeile.fehler() != null ? zeile.fehler() : gruendeText(zeile.gruende())))
+ .append("</td></tr>\n");
+ }
+ return sb.append("</tbody></table>\n</body></html>\n").toString();
+ }
+
+ private static String esc(String text) {
+ return text.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """);
+ }
+
+ /** Die Summe über den Korpus — die Zahl, die eine Welle vor und nach sich nennt. */
+ public static String summe(List<Zeile> zeilen) {
+ long befehle = zeilen.stream().mapToLong(Zeile::befehle).sum();
+ long angewandt = zeilen.stream().mapToLong(Zeile::angewandt).sum();
+ long manuell = zeilen.stream().mapToLong(Zeile::manuell).sum();
+ long gleich = zeilen.stream().filter(z -> z.gleich() != null).mapToLong(Zeile::gleich).sum();
+ long geprueft =
+ zeilen.stream().filter(z -> z.geprueft() != null).mapToLong(Zeile::geprueft).sum();
+ long gescheitert = zeilen.stream().filter(z -> z.fehler() != null).count();
+ var sb =
+ new StringBuilder(
+ "%d Aufträge, %d Befehle, %d angewandt, %d manuell; %d von %d Normen gleich"
+ .formatted(zeilen.size(), befehle, angewandt, manuell, gleich, geprueft));
+ if (gescheitert > 0) {
+ sb.append("; %d Aufträge gescheitert".formatted(gescheitert));
+ }
+ return sb.toString();
+ }
+
+ // ---------------------------------------------------------------- Grundlinie
+
+ /**
+ * Hält den Lauf gegen die Grundlinie und meldet jede Kennzahl, die <em>gefallen</em> ist.
+ *
+ * <p>Nur der Rückschritt wird gerügt. Ein Fortschritt ist kein Fehler, sondern der Zweck der
+ * Arbeit; er verlangt allein, die Grundlinie fortzuschreiben.
+ *
+ * @return die Rügen; leer, wenn nichts gefallen ist.
+ */
+ public static List<String> gegenGrundlinie(List<Zeile> lauf, String grundlinie) {
+ var alt = liesGrundlinie(grundlinie);
+ var ruegen = new ArrayList<String>();
+ for (var zeile : lauf) {
+ if (zeile.fehler() != null) {
+ ruegen.add("%s: gescheitert — %s".formatted(zeile.bezeichnung(), zeile.fehler()));
+ continue;
+ }
+ var frueher = alt.get(zeile.bezeichnung());
+ if (frueher == null) {
+ // Ein neuer Auftrag ist kein Rückschritt; die Grundlinie ist bloß noch nicht
+ // fortgeschrieben.
+ continue;
+ }
+ pruefe(
+ ruegen, zeile.bezeichnung(), "erschlossene Befehle", zeile.befehle(), frueher[1], true);
+ pruefe(
+ ruegen, zeile.bezeichnung(), "angewandte Befehle", zeile.angewandt(), frueher[2], true);
+ pruefe(
+ ruegen,
+ zeile.bezeichnung(),
+ "liegengebliebene Befehle",
+ zeile.manuell(),
+ frueher[3],
+ false);
+ pruefe(ruegen, zeile.bezeichnung(), "gleiche Normen", wert(zeile.gleich()), frueher[5], true);
+ pruefe(
+ ruegen, zeile.bezeichnung(), "fehlende Normen", wert(zeile.fehlend()), frueher[7], false);
+ pruefe(
+ ruegen,
+ zeile.bezeichnung(),
+ "überzählige Normen",
+ wert(zeile.ueberzaehlig()),
+ frueher[8],
+ false);
+ pruefe(
+ ruegen,
+ zeile.bezeichnung(),
+ "abweichende Normen",
+ wert(zeile.abweichend()),
+ frueher[9],
+ false);
+ }
+ for (var bezeichnung : alt.keySet()) {
+ if (lauf.stream().noneMatch(z -> z.bezeichnung().equals(bezeichnung))) {
+ ruegen.add(
+ "%s: steht in der Grundlinie, ist im Lauf aber nicht vorgekommen."
+ .formatted(bezeichnung));
+ }
+ }
+ return ruegen;
+ }
+
+ private static long wert(@Nullable Integer zahl) {
+ return zahl == null ? Long.MIN_VALUE : zahl;
+ }
+
+ /**
+ * @param hoeherIstBesser ob ein Fallen der Zahl den Rückschritt bedeutet (angewandte Befehle,
+ * gleiche Normen) oder ihr Steigen (liegengebliebene, abweichende).
+ */
+ private static void pruefe(
+ List<String> ruegen,
+ String bezeichnung,
+ String kennzahl,
+ long jetzt,
+ String frueherText,
+ boolean hoeherIstBesser) {
+ if (frueherText.equals(LEER) || jetzt == Long.MIN_VALUE) {
+ return;
+ }
+ long frueher;
+ try {
+ frueher = Long.parseLong(frueherText);
+ } catch (NumberFormatException e) {
+ return;
+ }
+ boolean rueckschritt = hoeherIstBesser ? jetzt < frueher : jetzt > frueher;
+ if (rueckschritt) {
+ ruegen.add("%s: %s %d statt %d.".formatted(bezeichnung, kennzahl, jetzt, frueher));
+ }
+ }
+
+ private static Map<String, String[]> liesGrundlinie(String tsv) {
+ var zeilen = new LinkedHashMap<String, String[]>();
+ for (var zeile : tsv.split("\n")) {
+ if (zeile.isBlank() || zeile.startsWith("#")) {
+ continue;
+ }
+ var felder = zeile.split("\t");
+ if (felder.length >= 10) {
+ zeilen.put(felder[0].strip(), felder);
+ }
+ }
+ return zeilen;
+ }
+}
diff --git a/src/test/java/eu/mulk/aendggner/bericht/KorpusberichtTest.java b/src/test/java/eu/mulk/aendggner/bericht/KorpusberichtTest.java
new file mode 100644
index 0000000..e709337
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/bericht/KorpusberichtTest.java
@@ -0,0 +1,134 @@
+// SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+package eu.mulk.aendggner.bericht;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Der Massenlauf über den Beispielkorpus, gehalten gegen die eingecheckte Grundlinie.
+ *
+ * <p>Die Einzelprüfungen sagen, dass ein bestimmtes Heft aufgeht. Diese sagt, dass keines der
+ * einunddreißig Hefte hinter das zurückfällt, was es einmal hergegeben hat — und zwar in einer
+ * Zahl, die sich fortschreiben lässt, statt in Dutzenden verstreuter Behauptungen.
+ */
+class KorpusberichtTest {
+
+ private static final Path SAMPLEDATA = Path.of("src/test/resources/sampledata");
+ private static final Path LISTE = SAMPLEDATA.resolve("korpus.tsv");
+ private static final Path GRUNDLINIE = SAMPLEDATA.resolve("korpus-grundlinie.tsv");
+
+ @Test
+ void derKorpusFaelltNichtHinterDieGrundlinieZurueck() throws Exception {
+ assumeTrue(Files.exists(LISTE) && Files.exists(GRUNDLINIE), "Korpusliste fehlt");
+ var auftraege = Korpusbericht.liesListe(LISTE);
+ assumeTrue(
+ auftraege.stream().allMatch(a -> Files.exists(SAMPLEDATA.resolve(a.stamm()))),
+ "Beispieldaten des Korpus fehlen");
+
+ var zeilen = Korpusbericht.fuehreAus(auftraege, SAMPLEDATA.toAbsolutePath(), null);
+
+ // Ein gescheiterter Auftrag hält den Lauf nicht an — unbemerkt bleiben darf er aber nicht;
+ // sonst meldete der Bericht lauter Nullen und niemand sähe den Grund.
+ assertThat(zeilen).hasSameSizeAs(auftraege);
+ assertThat(zeilen).allSatisfy(z -> assertThat(z.fehler()).isNull());
+
+ var ruegen =
+ Korpusbericht.gegenGrundlinie(zeilen, Files.readString(GRUNDLINIE, StandardCharsets.UTF_8));
+
+ assertThat(ruegen)
+ .as(
+ "Rückschritte gegenüber der Grundlinie; steht dagegen ein Fortschritt darin, so ist"
+ + " korpus-grundlinie.tsv fortzuschreiben:\n%s\n\nLauf:\n%s",
+ String.join("\n", ruegen), Korpusbericht.alsTsv(zeilen))
+ .isEmpty();
+ }
+
+ @Test
+ void dieListeWirdVollstaendigGelesen() throws Exception {
+ assumeTrue(Files.exists(LISTE), "Korpusliste fehlt");
+ var auftraege = Korpusbericht.liesListe(LISTE);
+
+ assertThat(auftraege).isNotEmpty();
+ // Kommentar- und Leerzeilen sind übergangen, „-“ heißt „nicht angegeben“.
+ assertThat(auftraege).noneMatch(a -> a.bezeichnung().startsWith("#"));
+ assertThat(auftraege).allSatisfy(a -> assertThat(a.hefte()).isNotEmpty());
+ var mitArtikel =
+ auftraege.stream().filter(a -> "GEG-BGBl-zeitrichtig".equals(a.bezeichnung())).findFirst();
+ assertThat(mitArtikel).isPresent();
+ assertThat(mitArtikel.orElseThrow().artikel()).isEqualTo("1");
+ assertThat(mitArtikel.orElseThrow().nachfassung()).isNull();
+ var mehrereHefte =
+ auftraege.stream()
+ .filter(a -> "BayJG-mit-Aenderungsantrag".equals(a.bezeichnung()))
+ .findFirst()
+ .orElseThrow();
+ assertThat(mehrereHefte.hefte()).hasSize(2);
+ var mitStichtag =
+ auftraege.stream()
+ .filter(a -> "IfSG-Stichtag".equals(a.bezeichnung()))
+ .findFirst()
+ .orElseThrow();
+ assertThat(mitStichtag.stichtag()).isNotNull();
+ }
+
+ @Test
+ void nurDerRueckschrittWirdGeruegt() {
+ var grundlinie =
+ """
+ # Bezeichnung\tBefehle\tangewandt\tmanuell\tzurückgestellt\tgleich\tgeprüft\tfehlend\tüberzählig\tabweichend\tGründe
+ Fall\t10\t8\t2\t0\t20\t22\t1\t0\t1\t-
+ """;
+
+ var besser =
+ List.of(new Korpusbericht.Zeile("Fall", 10, 9, 1, 0, 21, 22, 0, 0, 1, Map.of(), null));
+ assertThat(Korpusbericht.gegenGrundlinie(besser, grundlinie)).isEmpty();
+
+ var schlechter =
+ List.of(new Korpusbericht.Zeile("Fall", 10, 7, 3, 0, 19, 22, 1, 0, 2, Map.of(), null));
+ assertThat(Korpusbericht.gegenGrundlinie(schlechter, grundlinie))
+ .anySatisfy(r -> assertThat(r).contains("angewandte Befehle 7 statt 8"))
+ .anySatisfy(r -> assertThat(r).contains("liegengebliebene Befehle 3 statt 2"))
+ .anySatisfy(r -> assertThat(r).contains("gleiche Normen 19 statt 20"))
+ .anySatisfy(r -> assertThat(r).contains("abweichende Normen 2 statt 1"));
+ }
+
+ @Test
+ void einGescheiterterAuftragWirdGeruegtStattUebergangen() {
+ var grundlinie =
+ """
+ # Bezeichnung\tBefehle\tangewandt\tmanuell\tzurückgestellt\tgleich\tgeprüft\tfehlend\tüberzählig\tabweichend\tGründe
+ Fall\t10\t8\t2\t0\t-\t-\t-\t-\t-\t-
+ """;
+
+ var gescheitert =
+ List.of(
+ new Korpusbericht.Zeile(
+ "Fall", 0, 0, 0, 0, null, null, null, null, null, Map.of(), "IOException: weg"));
+ assertThat(Korpusbericht.gegenGrundlinie(gescheitert, grundlinie))
+ .singleElement()
+ .asString()
+ .contains("gescheitert");
+ }
+
+ @Test
+ void einVerschwundenerAuftragFaelltAuf() {
+ var grundlinie =
+ """
+ # Bezeichnung\tBefehle\tangewandt\tmanuell\tzurückgestellt\tgleich\tgeprüft\tfehlend\tüberzählig\tabweichend\tGründe
+ Fall\t10\t8\t2\t0\t-\t-\t-\t-\t-\t-
+ """;
+
+ assertThat(Korpusbericht.gegenGrundlinie(List.of(), grundlinie))
+ .singleElement()
+ .asString()
+ .contains("ist im Lauf aber nicht vorgekommen");
+ }
+}
diff --git a/src/test/resources/sampledata/korpus-grundlinie.tsv b/src/test/resources/sampledata/korpus-grundlinie.tsv
new file mode 100644
index 0000000..60da4f6
--- /dev/null
+++ b/src/test/resources/sampledata/korpus-grundlinie.tsv
@@ -0,0 +1,44 @@
+# Grundlinie des Korpuslaufs (§ 6d des Handbuchs), Stand 28. August 2026.
+#
+# Jede Zeile hält fest, was ein Auftrag der Liste „korpus.tsv“ zu diesem Zeitpunkt hergegeben hat.
+# Der Lauf wird dagegen gehalten (KorpusberichtTest, „--grundlinie“): Gerügt wird allein, was
+# FÄLLT — weniger erschlossene oder angewandte Befehle, weniger gleiche Normen, mehr
+# liegengebliebene, fehlende, überzählige oder abweichende. Ein Fortschritt ist kein Fehler,
+# sondern der Zweck der Arbeit; er verlangt allein, diese Datei fortzuschreiben.
+#
+# Die hohen Reste bei GEG-Beschlussempfehlung, IfSG-Beschlussempfehlung und RP-UKAPOGS beruhen
+# darauf, dass die Vorlage eine ältere Fassung fortschreibt als die eingesetzte Stammfassung
+# („Zieltext nicht vorhanden“); sie sind kein Mangel des Erzeugnisses, wohl aber ein Wächter:
+# Steigen sie, hat sich etwas verschlechtert.
+# Bezeichnung Befehle angewandt manuell zurückgestellt gleich geprüft fehlend überzählig abweichend Gründe
+UWG-BGBl 19 19 0 0 - - - - - -
+UWG-RegE 20 20 0 0 - - - - - -
+AGG-RegE 24 23 1 0 - - - - - Stelle nicht auffindbar:1
+ProdHaftG-RegE 1 1 0 0 - - - - - -
+GEG-BGBl-zeitrichtig 119 119 0 0 - - - - - -
+IfSG-BGBl-zeitrichtig 75 75 0 0 - - - - - -
+BayJG-GVBl 154 154 0 0 - - - - - -
+SN-SaechsBeamtVG 5 5 0 0 - - - - - -
+NI-NEFG 7 7 0 0 - - - - - -
+SH-Gemeindeordnung 1 1 0 0 167 167 0 0 0 -
+SH-Kreisordnung 1 1 0 0 86 86 0 0 0 -
+NRW-WDR-Gesetz 101 101 0 0 - - - - - -
+NRW-LMG 18 18 0 0 - - - - - -
+NRW-TMZ-Gesetz 4 4 0 0 - - - - - -
+NRW-17-RAEStV-AusfG 1 1 0 0 - - - - - -
+BE-ASOG 6 6 0 0 171 171 0 0 0 -
+BE-LAF-ErrG 2 2 0 0 5 5 0 0 0 -
+BW-KomWO 37 31 6 0 91 93 0 0 2 Befehl nicht erkannt:6
+HE-StVRZustV 21 21 0 0 52 52 0 0 0 -
+TH-KiGaFinVO 10 10 0 0 - - - - - -
+BB-FraktG 4 4 0 0 24 24 0 0 0 -
+HH-GutachterausschussVO 21 17 4 0 10 14 0 0 4 Befehl nicht erkannt:2; Stelle nicht auffindbar:1; Zieltext nicht vorhanden:1
+HB-BerufsfachschulVO 41 37 4 0 26 29 1 0 2 Befehl nicht erkannt:3; Fundstelle mehrdeutig:1
+MV-APOAmtsTA 24 20 4 0 - - - - - Befehl nicht erkannt:4
+RP-UKAPOGS 23 9 14 0 - - - - - Stelle nicht auffindbar:2; Zieltext nicht vorhanden:10; Bestand widerspricht dem Befehl:2
+GEG-GModG-RegE 97 87 10 0 - - - - - Befehl nicht erkannt:6; Stelle nicht auffindbar:2; Zieltext nicht vorhanden:2
+GEG-Beschlussempfehlung 119 70 49 0 - - - - - Befehl nicht erkannt:1; Stelle nicht auffindbar:3; Zieltext nicht vorhanden:40; Fundstelle mehrdeutig:1; Bestand widerspricht dem Befehl:4
+IfSG-Beschlussempfehlung 75 51 24 0 - - - - - Befehl nicht erkannt:1; Stelle nicht auffindbar:1; Zieltext nicht vorhanden:21; Bestand widerspricht dem Befehl:1
+BayJG-Landtagsentwurf 154 154 0 0 - - - - - -
+BayJG-mit-Aenderungsantrag 154 154 0 0 - - - - - -
+IfSG-Stichtag 75 65 0 10 - - - - - -
diff --git a/src/test/resources/sampledata/korpus.tsv b/src/test/resources/sampledata/korpus.tsv
new file mode 100644
index 0000000..2832539
--- /dev/null
+++ b/src/test/resources/sampledata/korpus.tsv
@@ -0,0 +1,46 @@
+# Auftragsliste des Korpuslaufs (§ 6d des Handbuchs).
+#
+# Spalten, durch Tabulator getrennt:
+# Bezeichnung | Stammfassung | Hefte (durch Komma) | Artikel | Nachfassung | Stichtag
+# „-“ heißt: nicht angegeben. Die Pfade beziehen sich auf das Verzeichnis dieser Datei.
+#
+# Aufgenommen ist, was die Akzeptanzprüfungen des EndToEndTest fahren. Deren Zahlen müssen hier
+# unverändert herauskommen; weichen sie ab, liegt der Fehler an der Liste und nicht am Erzeugnis.
+#
+# Bezeichnung Stamm Hefte Artikel Nachfassung Stichtag
+UWG-BGBl UWG/BJNR141400004.xml UWG/bgbl126s0043_regelungstext.pdf - - -
+UWG-RegE UWG/BJNR141400004.xml UWG/BT-Drs-21-1855_Regierungsentwurf.pdf - - -
+AGG-RegE AGG/BJNR189710006.xml AGG/BT-Drs-21-6178_Regierungsentwurf.pdf - - -
+ProdHaftG-RegE ProdHaftG/BJNR021980989.xml ProdHaftG/RegE_Produkthaftungsrecht_2025-12-17.pdf - - -
+GEG-BGBl-zeitrichtig GEG/BJNR172810020-2023.xml GEG/bgbl123s0280_regelungstext.pdf 1 - -
+IfSG-BGBl-zeitrichtig IfSG/BJNR104510000-2020.xml IfSG/bgbl120s2397_78991.pdf - - -
+BayJG-GVBl BayJG/BayJG-alt.txt BayJG/gvbl-2026-06.pdf - - -
+SN-SaechsBeamtVG Sachsen/SaechsBeamtVG-alt.txt Sachsen/SaechsGVBl-2026-S134_AendG-SaechsBeamtVG_revosax.pdf - - -
+NI-NEFG Niedersachsen/NEFG-alt.txt Niedersachsen/Nds-GVBl-2026-10_ELER-Foerdergesetz-AendG.pdf - - -
+SH-Gemeindeordnung SchleswigHolstein/GO-SH-alt.txt SchleswigHolstein/GVOBl-2026-27_Kommunalrecht-AendG.pdf 1 SchleswigHolstein/GO-SH-neu.txt -
+SH-Kreisordnung SchleswigHolstein/KrO-SH-alt.txt SchleswigHolstein/GVOBl-2026-27_Kommunalrecht-AendG.pdf 2 SchleswigHolstein/KrO-SH-neu.txt -
+NRW-WDR-Gesetz NRW/WDR-Gesetz-alt.txt NRW/GV-NRW-2026-S202_22-Rundfunkaenderungsgesetz.pdf - - -
+NRW-LMG NRW/LMG-NRW-alt.txt NRW/GV-NRW-2026-S202_22-Rundfunkaenderungsgesetz.pdf - - -
+NRW-TMZ-Gesetz NRW/TMZ-Gesetz-alt.txt NRW/GV-NRW-2026-S202_22-Rundfunkaenderungsgesetz.pdf - - -
+NRW-17-RAEStV-AusfG NRW/17-RAEStV-AusfG-alt.txt NRW/GV-NRW-2026-S202_22-Rundfunkaenderungsgesetz.pdf - - -
+BE-ASOG Berlin/ASOG-Bln-alt.txt Berlin/GVBl-2026-17_ASOG-LAF-AendG.pdf 1 Berlin/ASOG-Bln-neu.txt -
+BE-LAF-ErrG Berlin/LAF-ErrG-alt.txt Berlin/GVBl-2026-17_ASOG-LAF-AendG.pdf 2 Berlin/LAF-ErrG-neu.txt -
+BW-KomWO BadenWuerttemberg/KomWO-BW-alt.txt BadenWuerttemberg/GBl-2026-26_KommunalwahlO-AendVO.pdf - BadenWuerttemberg/KomWO-BW-neu.txt -
+HE-StVRZustV Hessen/StVRZustV-alt.txt Hessen/GVBl-2026-05_11-AendVO-verkehrsrechtl-Zustaendigkeiten.pdf - Hessen/StVRZustV-neu.txt -
+TH-KiGaFinVO Thueringen/ThuerKigaFinVO-alt.txt Thueringen/GVBl-TH-2026-02_KiGaFinanzVO-AendVO-ua.pdf - - -
+BB-FraktG Brandenburg/FraktG-alt.txt Brandenburg/GVBl-I-2026-12_FraktG-AendG.pdf - Brandenburg/FraktG-neu.txt -
+HH-GutachterausschussVO Hamburg/GAusschV-HH-alt.txt Hamburg/HmbGVBl-2026-17_GutachterausschussVO-AendVO.pdf - Hamburg/GAusschV-HH-neu.txt -
+HB-BerufsfachschulVO Bremen/AssBerFSchulV-BR-alt.txt Bremen/BremGBl-2026-87_BerufsfachschulVO-AendVO.pdf - Bremen/AssBerFSchulV-BR-neu.txt -
+MV-APOAmtsTA MecklenburgVorpommern/APOAmtsTA-MV-alt.txt MecklenburgVorpommern/GVOBl-MV-2026-21_Besoldungsanpassung-ua.pdf 44 - -
+# Rheinland-Pfalz führt keine Vorfassungen; angewandt wird deshalb auf die Nachfassung, und
+# aussagekräftig ist allein die Zahl der erschlossenen Befehle (23). Die liegengebliebenen sind
+# die Folge des falschen Standes, nicht eines Mangels — siehe § 13 Absatz 4 des Handbuchs.
+RP-UKAPOGS RheinlandPfalz/UKAPOGS-E3-neu.txt RheinlandPfalz/GVBl-2026-21_UKAPOGS-E3-AendVO.pdf - - -
+# Entwürfe und Zusammenstellungen: hier gibt es keine Nachfassung, wohl aber die Frage, wie viel
+# eine Vorlage hergibt.
+GEG-GModG-RegE GEG/BJNR172810020.xml GEG/Regierungsentwurf_GModG_2026-05-13.pdf - - -
+GEG-Beschlussempfehlung GEG/BJNR172810020.xml GEG/BT-Drs-20-7619_Beschlussempfehlung.pdf - - -
+IfSG-Beschlussempfehlung IfSG/BJNR104510000.xml IfSG/1924334.pdf - - -
+BayJG-Landtagsentwurf BayJG/BayJG-alt.txt BayJG/Ltg-Drs-19-9707_Gesetzentwurf.pdf - - -
+BayJG-mit-Aenderungsantrag BayJG/BayJG-alt.txt BayJG/Ltg-Drs-19-9707_Gesetzentwurf.pdf,BayJG/Ltg-Drs-19-10365_Aenderungsantrag-Gruene.pdf - - -
+IfSG-Stichtag IfSG/BJNR104510000-2020.xml IfSG/bgbl120s2397_78991.pdf - - 2020-11-19