aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--FASSUNGEN.txt97
-rw-r--r--README.md63
-rw-r--r--REUSE.toml7
-rwxr-xr-xdeploy/korpuslauf.sh55
-rw-r--r--src/main/java/eu/mulk/aendggner/AendGgner.java90
-rw-r--r--src/main/java/eu/mulk/aendggner/Pipeline.java30
-rw-r--r--src/main/java/eu/mulk/aendggner/bericht/Korpusbericht.java476
-rw-r--r--src/test/java/eu/mulk/aendggner/bericht/KorpusberichtTest.java134
-rw-r--r--src/test/resources/sampledata/korpus-grundlinie.tsv44
-rw-r--r--src/test/resources/sampledata/korpus.tsv46
10 files changed, 1031 insertions, 11 deletions
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt
index 677db5d..17572b5 100644
--- a/FASSUNGEN.txt
+++ b/FASSUNGEN.txt
@@ -21,7 +21,102 @@ Vorbemerkung zur Führung dieses Verzeichnisses
════════════════════════════════════════════════════════════════════════════════
- 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 @@ durchgerechneten Beispiele sind der kürzeste Weg zum ersten Ergebnis
- [§ 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 @@ java -jar target/aendggner-0.1.0-SNAPSHOT.jar \
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 @@ Befehl fortzuschreiben hieße, Recht zu erfinden.
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 @@ Ergebnis gegen die amtliche Nachfassung stellen — mit demselben Abgleich, den
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 @@ SPDX-License-Identifier = "LicenseRef-AmtlichesWerk"
# 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 @@ SPDX-License-Identifier = "AGPL-3.0-or-later AND LicenseRef-AmtlichesWerk"
[[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.PatchTextExtraktor;
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.nio.file.Path;
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 @@ public class AendGgner implements Callable<Integer> {
@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 @@ public class AendGgner implements Callable<Integer> {
+ "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 @@ public class AendGgner implements Callable<Integer> {
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 @@ public class AendGgner implements Callable<Integer> {
* 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 @@ public class AendGgner implements Callable<Integer> {
.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.io.IOException;
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 @@ public final class Pipeline {
* @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 @@ public final class Pipeline {
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 @@ public final class Pipeline {
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("&", "&amp;")
+ .replace("<", "&lt;")
+ .replace(">", "&gt;")
+ .replace("\"", "&quot;");
+ }
+
+ /** 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