aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-26 07:19:59 +0200
committerMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-26 07:19:59 +0200
commit63e1283230c5457666de90c9af74657b52579909 (patch)
tree901d7c16bfbd9d4c80b801cbac6a22c7667cd936 /src
parenta6b42bc533eb2d453ea838b90b93081de3f00c3c (diff)
Die Fassung merkt sich, welche Hefte sie schon trägt
Die Kette führte kein Gedächtnis, und der Grund lag tiefer als die Warnung des Handbuchs besagte. Der Textausgeber schrieb die Standzeile sehr wohl aus; der Textparser aber verwarf alles, was vor dem ersten Normkopf steht. Was das Erzeugnis errechnet hatte, kam mithin standlos zurück, und die Altersrüge konnte auf der eigenen Ausgabe niemals anschlagen — ein stiller Verlust, den erst der Blick auf die Kette sichtbar machte. Das Gesetz führt nunmehr seine Fortschreibungen mit: jedes Heft, das auf seinen Wortlaut angewandt worden ist, mit Bezeichnung und Ausfertigungsdatum. Der Ausgeber schreibt sie als „Fortgeschrieben durch: …“ hinter die Standzeile, der Parser liest beides zurück, und der Rundlauf prüft, dass nichts dabei verloren geht. Trifft ein Heft ein zweites Mal auf dieselbe Fassung, so wird das gerügt; abgebrochen wird nicht, denn ein Befehl bleibt anwendbar, auch wo er sinnlos ist, und verworfen wird hier nichts. Damit ein Heft wiedererkennbar ist, muss es sich nennen können. Der Erkenner erhebt dafür das Ausfertigungsdatum aus der Zeile „Vom …“ — aber nur, wenn das ganze Dokument genau eine solche führt. Ein Sammelheft stellt mehrere Verkündungen nebeneinander, und welche von ihnen es ausmacht, sagt es nicht; ein falsch bezeichnetes Heft wäre schlimmer als ein unbezeichnetes. Aus demselben Datum ergibt sich nebenher die Reihenfolge, in der mehrere Hefte eines Laufes anzuwenden sind, und die Fortwirkung der Altersrüge über die Kette hinweg. Geprüft: mvnw verify, 397 Tests (zuvor 388), reuse lint 191/191. Change-Id: Ice3af51eaec777961dafc997e34391d2fa6d6031
Diffstat (limited to 'src')
-rw-r--r--src/main/java/eu/mulk/aendggner/Pipeline.java61
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/DokumentKopf.java34
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java25
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/DokumentErkenner.java51
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/Fortschreibung.java43
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java54
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/Stand.java38
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java21
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeber.java5
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java31
-rw-r--r--src/test/java/eu/mulk/aendggner/EndToEndTest.java33
-rw-r--r--src/test/java/eu/mulk/aendggner/aenderung/parse/DokumentErkennerTest.java46
-rw-r--r--src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeberTest.java43
13 files changed, 445 insertions, 40 deletions
diff --git a/src/main/java/eu/mulk/aendggner/Pipeline.java b/src/main/java/eu/mulk/aendggner/Pipeline.java
index c7b22bf..fee0ce9 100644
--- a/src/main/java/eu/mulk/aendggner/Pipeline.java
+++ b/src/main/java/eu/mulk/aendggner/Pipeline.java
@@ -16,6 +16,7 @@ import eu.mulk.aendggner.aenderung.parse.TextBereiniger;
import eu.mulk.aendggner.anwendung.BefehlAnwender;
import eu.mulk.aendggner.anwendung.Grund;
import eu.mulk.aendggner.anwendung.Nachfassungsabgleich;
+import eu.mulk.aendggner.gesetz.Fortschreibung;
import eu.mulk.aendggner.gesetz.Gesetz;
import eu.mulk.aendggner.gesetz.Superskript;
import eu.mulk.aendggner.gesetz.gii.GiiXmlLoader;
@@ -170,7 +171,9 @@ public final class Pipeline {
var parser = new AenderungsgesetzParser();
var warnungen = new ArrayList<String>();
- var dokumente = wendeAntraegeAn(leseDokumente(patches, extraktor, warnungen), warnungen);
+ var dokumente =
+ ordneNachAusfertigung(
+ wendeAntraegeAn(leseDokumente(patches, extraktor, warnungen), warnungen), warnungen);
var gesetz = altesGesetz;
var protokoll = new ArrayList<BefehlAnwender.AngewandteAenderung>();
@@ -179,6 +182,10 @@ public final class Pipeline {
Inkrafttreten inkrafttreten = null;
for (var dokument : dokumente) {
+ var heft = new Fortschreibung(dokument.kopf().anzeigeName(), dokument.kopf().ausfertigung());
+ if (gesetz.traegt(heft)) {
+ warnungen.add(doppelanwendungsRuege(dokument, heft));
+ }
var parseErgebnis = parser.parse(dokument.text(), gesetz, artikel, entwurfsGrenzen(dokument));
if (parseErgebnis.befehle().isEmpty()) {
warnungen.add(
@@ -191,7 +198,13 @@ public final class Pipeline {
}
var geteilt = teileNachStichtag(parseErgebnis, stichtag, warnungen);
var anwendung = BefehlAnwender.anwenden(gesetz, geteilt.anzuwenden());
- gesetz = anwendung.neu();
+ // Vermerkt wird nur, was auch gewirkt hat: Ein Heft, dessen Befehle sämtlich liegenblieben,
+ // hat den Wortlaut nicht fortgeschrieben und darf ihn deshalb nicht als fortgeschrieben
+ // ausweisen.
+ gesetz =
+ anwendung.anzahlAngewandt() > 0 && !gesetz.traegt(heft)
+ ? anwendung.neu().mitFortschreibung(heft)
+ : anwendung.neu();
protokoll.addAll(anwendung.protokoll());
protokoll.addAll(geteilt.zurueckgestellt());
warnungen.addAll(parseErgebnis.warnungen());
@@ -232,6 +245,50 @@ public final class Pipeline {
abgleich);
}
+ /**
+ * Die Rüge, wenn ein Heft ein zweites Mal auf denselben Wortlaut trifft.
+ *
+ * <p>Ein Änderungsbefehl ist keine Zustandsbeschreibung, sondern eine Anordnung: Wer zweimal
+ * anfügt, fügt zweimal an. Am Wortlaut allein ist das nicht zu erkennen — die Befehle greifen ein
+ * zweites Mal anstandslos —, wohl aber an dem, was die Fassung über sich selbst mitführt.
+ * Abgebrochen wird gleichwohl nicht: Es kann Gründe geben, dasselbe Heft erneut anzuwenden, und
+ * verworfen wird hier nichts (§ 1 Absatz 4 des Handbuchs). Gesagt wird es.
+ */
+ private static String doppelanwendungsRuege(Quelldokument dokument, Fortschreibung heft) {
+ return ("Die Fassung trägt „%s“ bereits; %s wird damit ein zweites Mal angewandt. Ein Befehl"
+ + " ist eine Anordnung und keine Zustandsbeschreibung: Was angefügt wird, wird dann"
+ + " zweimal angefügt. Gemeint ist die Kette wohl mit einem anderen Heft.")
+ .formatted(heft.bezeichnung(), dokument.quelle().name());
+ }
+
+ /**
+ * Bringt die Hefte in die Reihenfolge ihrer Ausfertigung.
+ *
+ * <p>Die Kette wendet Heft auf Heft an; jedes setzt den Stand voraus, den das vorige hinterlassen
+ * hat. Die Reihenfolge der Aufrufargumente ist dafür kein Maßstab, das Ausfertigungsdatum schon.
+ * Umgestellt wird nur, wenn <em>jedes</em> Dokument ein Datum trägt — sonst fehlt der gemeinsame
+ * Maßstab, und geraten wird nicht. Und umgestellt wird nicht stillschweigend.
+ */
+ private static List<Quelldokument> ordneNachAusfertigung(
+ List<Quelldokument> dokumente, List<String> warnungen) {
+ if (dokumente.size() < 2 || dokumente.stream().anyMatch(d -> d.kopf().ausfertigung() == null)) {
+ return dokumente;
+ }
+ var geordnet = new ArrayList<>(dokumente);
+ geordnet.sort(java.util.Comparator.comparing(d -> d.kopf().ausfertigung()));
+ if (geordnet.equals(dokumente)) {
+ return dokumente;
+ }
+ warnungen.add(
+ "Die Änderungsdokumente wurden nach ihrem Ausfertigungsdatum geordnet und in dieser Folge"
+ + " angewandt: "
+ + geordnet.stream()
+ .map(d -> d.quelle().name())
+ .collect(java.util.stream.Collectors.joining(", "))
+ + ". Jedes Heft setzt den Stand voraus, den das vorige hinterlässt.");
+ return List.copyOf(geordnet);
+ }
+
/** Die Befehle eines Dokuments, geschieden nach dem, was am Stichtag schon galt. */
private record Geteilt(
List<eu.mulk.aendggner.aenderung.Aenderungsbefehl> anzuwenden,
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/DokumentKopf.java b/src/main/java/eu/mulk/aendggner/aenderung/DokumentKopf.java
index 9dde246..de607db 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/DokumentKopf.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/DokumentKopf.java
@@ -2,6 +2,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package eu.mulk.aendggner.aenderung;
+import eu.mulk.aendggner.aenderung.parse.DeutschesDatum;
+import java.time.LocalDate;
import java.util.List;
import org.jspecify.annotations.Nullable;
@@ -19,21 +21,45 @@ import org.jspecify.annotations.Nullable;
* null} bei Dokumenten ohne Drucksachenkopf (Gesetzblätter, Referentenentwürfe).
* @param bezugsDrucksachen die Nummern der Drucksachen, auf die sich das Dokument bezieht.
* @param titel eine kurze Bezeichnung für Quellen- und Warnzeilen.
+ * @param ausfertigung das Datum, das die Zeile „Vom …“ nennt, oder {@code null}. Entwürfe tragen
+ * keines, und ein Sammelheft mit mehreren Verkündungen trägt mehrere — dann bleibt es
+ * gleichfalls leer, denn welche Verkündung gemeint ist, steht dort nicht fest. Geraten wird
+ * nicht.
*/
public record DokumentKopf(
DokumentArt art,
@Nullable String eigeneDrucksache,
List<String> bezugsDrucksachen,
- String titel) {
+ String titel,
+ @Nullable LocalDate ausfertigung) {
public DokumentKopf {
bezugsDrucksachen = List.copyOf(bezugsDrucksachen);
}
- /** Die Bezeichnung für die Quellenzeile der Synopse, z.B. „Änderungsantrag Drs. 19/10365“. */
+ public DokumentKopf(
+ DokumentArt art,
+ @Nullable String eigeneDrucksache,
+ List<String> bezugsDrucksachen,
+ String titel) {
+ this(art, eigeneDrucksache, bezugsDrucksachen, titel, null);
+ }
+
+ /**
+ * Die Bezeichnung für die Quellenzeile der Synopse, z.B. „Änderungsantrag Drs. 19/10365“ oder
+ * „Änderungsgesetz vom 22. April 2026“.
+ *
+ * <p>Sie ist zugleich der Schlüssel, an dem die Fassung wiedererkennt, welches Heft sie schon
+ * trägt (siehe {@link eu.mulk.aendggner.gesetz.Fortschreibung}). Deshalb steht hier, was das
+ * Dokument von sich selbst sagt — Drucksachennummer oder Ausfertigungsdatum —, und nicht der
+ * Dateiname, den jeder anders schreibt.
+ */
public String anzeigeName() {
- return eigeneDrucksache == null
+ if (eigeneDrucksache != null) {
+ return art.anzeigeName() + " Drs. " + eigeneDrucksache;
+ }
+ return ausfertigung == null
? art.anzeigeName()
- : art.anzeigeName() + " Drs. " + eigeneDrucksache;
+ : art.anzeigeName() + " vom " + DeutschesDatum.schreibe(ausfertigung);
}
}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
index a01442f..2d6f0d6 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
@@ -8,6 +8,7 @@ import eu.mulk.aendggner.aenderung.Inkrafttreten;
import eu.mulk.aendggner.aenderung.Provenienz;
import eu.mulk.aendggner.aenderung.Stelle;
import eu.mulk.aendggner.gesetz.Gesetz;
+import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -383,8 +384,8 @@ public final class AenderungsgesetzParser {
* geraten wird nicht.
*/
private static Optional<String> standWarnung(String vorspann, Gesetz ziel) {
- var stand = ziel.stand();
- if (stand == null || stand.juengsteAenderung() == null) {
+ var wortlautStand = ziel.wortlautStand();
+ if (wortlautStand == null) {
return Optional.empty();
}
var satzDatum = EINLEITUNGS_DATUM.matcher(vorspann.replaceAll("\\s+", " "));
@@ -392,14 +393,14 @@ public final class AenderungsgesetzParser {
return Optional.empty();
}
var genannt = DeutschesDatum.lies(satzDatum.group(1), satzDatum.group(2), satzDatum.group(3));
- if (genannt == null || !stand.juengsteAenderung().isAfter(genannt)) {
+ if (genannt == null || !wortlautStand.isAfter(genannt)) {
return Optional.empty();
}
return Optional.of(
"Das Stammgesetz ist jünger als das Änderungsgesetz: Sein Wortlaut ist bis zum "
- + DeutschesDatum.schreibe(stand.juengsteAenderung())
+ + DeutschesDatum.schreibe(wortlautStand)
+ " fortgeschrieben („"
- + stand.kommentar()
+ + standHerkunft(ziel, wortlautStand)
+ "“), während der Einleitungssatz die Fassung vom "
+ satzDatum.group(1)
+ ". "
@@ -412,6 +413,20 @@ public final class AenderungsgesetzParser {
}
/**
+ * Woher der Wortlautstand rührt: aus einem Heft, das das Erzeugnis selbst angewandt hat, sonst
+ * aus der Standangabe der Quelle. In der Kette ist die Fassung nämlich jünger als ihre eigene
+ * Standzeile — sie trägt die Hefte, die seither auf sie angewandt worden sind.
+ */
+ private static String standHerkunft(Gesetz ziel, LocalDate wortlautStand) {
+ for (var heft : ziel.fortschreibungen()) {
+ if (wortlautStand.equals(heft.datum())) {
+ return heft.bezeichnung();
+ }
+ }
+ return ziel.stand() != null ? ziel.stand().kommentar() : "";
+ }
+
+ /**
* „… zuletzt durch Artikel 5 des Gesetzes vom 19. Juni 2020 (BGBl. I S. 1385) geändert worden
* ist“ — das Datum der <em>letzten Änderung</em>, nicht das der Ausfertigung. Deshalb muss ihm
* die Änderungsklausel vorausgehen: Der Einleitungssatz nennt zuerst das Ausfertigungsdatum („Das
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/DokumentErkenner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/DokumentErkenner.java
index 0ac3b2f..6f926bf 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/DokumentErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/DokumentErkenner.java
@@ -4,6 +4,7 @@ package eu.mulk.aendggner.aenderung.parse;
import eu.mulk.aendggner.aenderung.DokumentArt;
import eu.mulk.aendggner.aenderung.DokumentKopf;
+import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
@@ -78,6 +79,9 @@ public final class DokumentErkenner {
/** So viele Zeilen darf ein Titel überspannen. */
private static final int TITELZEILEN = 6;
+ /** Die Ausfertigungszeile eines verkündeten Werkes: „Vom 22. April 2026“. */
+ private static final Pattern VOM_ZEILE = Pattern.compile("^Vom " + DeutschesDatum.MUSTER + "$");
+
/**
* @param rohText der unbereinigte Extraktionstext des Dokuments.
*/
@@ -85,7 +89,47 @@ public final class DokumentErkenner {
var zeilen = kopfZeilen(rohText);
var art = bestimmeArt(zeilen, rohText);
return new DokumentKopf(
- art, eigeneDrucksache(zeilen), bezugsDrucksachen(zeilen), titel(zeilen));
+ art,
+ eigeneDrucksache(zeilen),
+ bezugsDrucksachen(zeilen),
+ titel(zeilen),
+ ausfertigung(rohText));
+ }
+
+ /**
+ * Eine Rohtextzeile, wie die Erkennung sie liest: ohne die Zeilenend- und Schriftgrößenmarken aus
+ * dem privaten Unicode-Bereich, die erst der {@link TextBereiniger} auswertet, und ohne
+ * geschützte Leerzeichen, die für {@code \s} nicht als Leerraum zählen. Beides hinge sonst
+ * unsichtbar an Titeln, Nummern und Daten.
+ */
+ private static String normiere(String zeile) {
+ return ZU_LEERRAUM.matcher(zeile).replaceAll(" ").replaceAll("\\s+", " ").strip();
+ }
+
+ /**
+ * Das Ausfertigungsdatum, das die Zeile „Vom …“ nennt.
+ *
+ * <p>Nur wenn das <em>ganze</em> Dokument genau eine solche Zeile führt. Ein Sammelheft stellt
+ * mehrere Verkündungen nebeneinander (so das thüringische GVBl. Nr. 2/2026 mit vieren), und
+ * welche von ihnen das Dokument ausmacht, sagt es nicht; dann bleibt das Datum lieber leer, als
+ * falsch zu sein. Denn an ihm hängt die Wiedererkennung des Heftes, und ein falsch bezeichnetes
+ * Heft wäre schlimmer als ein unbezeichnetes. Gesucht wird deshalb über den vollen Text und nicht
+ * bloß über die {@link #KOPFZEILEN} des Kopfes: Die zweite Verkündung stünde sonst außerhalb des
+ * Blickfeldes, und das Heft trüge den Namen der ersten.
+ */
+ private static @Nullable LocalDate ausfertigung(String rohText) {
+ LocalDate gefunden = null;
+ for (var zeile : rohText.split("\n", -1)) {
+ var m = VOM_ZEILE.matcher(normiere(zeile));
+ if (!m.matches()) {
+ continue;
+ }
+ if (gefunden != null) {
+ return null;
+ }
+ gefunden = DeutschesDatum.lies(m.group(1), m.group(2), m.group(3));
+ }
+ return gefunden;
}
/**
@@ -94,10 +138,7 @@ public final class DokumentErkenner {
private static List<String> kopfZeilen(String rohText) {
var zeilen = new ArrayList<String>();
for (var zeile : rohText.split("\n", -1)) {
- // Der Rohtext trägt noch die Zeilenend- und Schriftgrößenmarken aus dem privaten
- // Unicode-Bereich, die erst der TextBereiniger auswertet, dazu geschützte Leerzeichen, die
- // für \s nicht als Leerraum zählen. Beides hinge sonst unsichtbar an Titeln und Nummern.
- var normiert = ZU_LEERRAUM.matcher(zeile).replaceAll(" ").replaceAll("\\s+", " ").strip();
+ var normiert = normiere(zeile);
if (normiert.isEmpty()) {
continue;
}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Fortschreibung.java b/src/main/java/eu/mulk/aendggner/gesetz/Fortschreibung.java
new file mode 100644
index 0000000..3d2c3b1
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/gesetz/Fortschreibung.java
@@ -0,0 +1,43 @@
+// SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+package eu.mulk.aendggner.gesetz;
+
+import eu.mulk.aendggner.aenderung.parse.DeutschesDatum;
+import java.time.LocalDate;
+import java.util.regex.Pattern;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Ein Änderungsheft, das auf diese Fassung bereits angewandt worden ist.
+ *
+ * <p>Ein Befehl ist keine Zustandsbeschreibung, sondern eine Anordnung: Wer zweimal anfügt, fügt
+ * zweimal an. Dasselbe Heft ein zweites Mal auf seine eigene Ausgabe anzuwenden ist deshalb keine
+ * folgenlose Wiederholung — und dem Wortlaut allein ist das nicht anzusehen. Die Fassung führt
+ * darum mit, was auf ihr schon geschehen ist; im kanonischen Klartext steht das als Zeile
+ * „Fortgeschrieben durch: …“.
+ *
+ * <p>Die Bezeichnung ist zugleich der Schlüssel: Zwei Hefte gelten als dasselbe, wenn sie
+ * gleichlautend bezeichnet sind. Das trägt, weil die Bezeichnung aus dem Dokument selbst stammt
+ * (Art und Ausfertigungsdatum, bei Drucksachen deren Nummer) und nicht aus dem Dateinamen.
+ *
+ * @param bezeichnung wie das Heft sich nennt, z.B. „Änderungsgesetz vom 22. April 2026“.
+ * @param datum sein Ausfertigungsdatum, soweit die Bezeichnung eines nennt. Daran hängt die
+ * Altersrüge: Nach der Fortschreibung ist der Wortlaut so jung wie das jüngste angewandte Heft.
+ */
+public record Fortschreibung(String bezeichnung, @Nullable LocalDate datum) {
+
+ /** „… vom 22. April 2026“ — das Ausfertigungsdatum, wie die Bezeichnung es führt. */
+ private static final Pattern VOM = Pattern.compile("\\bvom " + DeutschesDatum.MUSTER + "\\b");
+
+ /**
+ * Liest eine Bezeichnung, wie der Klartext sie führt, und gewinnt das Datum aus ihr zurück. Die
+ * Bezeichnung bleibt dabei unangetastet — ausgegeben wird, was eingelesen wurde.
+ */
+ public static Fortschreibung aus(String bezeichnung) {
+ var m = VOM.matcher(bezeichnung);
+ if (!m.find()) {
+ return new Fortschreibung(bezeichnung, null);
+ }
+ return new Fortschreibung(bezeichnung, DeutschesDatum.lies(m.group(1), m.group(2), m.group(3)));
+ }
+}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java b/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java
index f7e94c0..c2f5dd8 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java
@@ -2,6 +2,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package eu.mulk.aendggner.gesetz;
+import java.time.LocalDate;
+import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.jspecify.annotations.Nullable;
@@ -13,6 +15,10 @@ import org.jspecify.annotations.Nullable;
* @param stand der Stand der Quelle, soweit sie einen angibt; {@code null} bei handgepflegtem
* Klartext. An ihm hängt die Auskunft, ob das Stammgesetz jünger ist als das Änderungsgesetz,
* das darauf angewandt wird.
+ * @param fortschreibungen die Änderungshefte, die auf diesen Wortlaut bereits angewandt worden sind
+ * — leer bei jeder Fassung, die aus einer fremden Quelle stammt. Gefüllt wird die Liste erst,
+ * wenn das Erzeugnis selbst fortschreibt; sie überdauert die Kette, weil der kanonische
+ * Klartext sie mitführt (siehe {@link Fortschreibung}).
*/
public record Gesetz(
String jurabk,
@@ -20,11 +26,23 @@ public record Gesetz(
String kurzue,
List<Norm> normen,
List<Gliederung> gliederungen,
- @Nullable Stand stand) {
+ @Nullable Stand stand,
+ List<Fortschreibung> fortschreibungen) {
public Gesetz {
normen = List.copyOf(normen);
gliederungen = List.copyOf(gliederungen);
+ fortschreibungen = List.copyOf(fortschreibungen);
+ }
+
+ public Gesetz(
+ String jurabk,
+ String langue,
+ String kurzue,
+ List<Norm> normen,
+ List<Gliederung> gliederungen,
+ @Nullable Stand stand) {
+ this(jurabk, langue, kurzue, normen, gliederungen, stand, List.of());
}
public Gesetz(
@@ -45,14 +63,42 @@ public record Gesetz(
}
public Gesetz mitNormen(List<Norm> neueNormen) {
- return new Gesetz(jurabk, langue, kurzue, neueNormen, gliederungen, stand);
+ return new Gesetz(jurabk, langue, kurzue, neueNormen, gliederungen, stand, fortschreibungen);
}
public Gesetz mitGliederungen(List<Gliederung> neueGliederungen) {
- return new Gesetz(jurabk, langue, kurzue, normen, neueGliederungen, stand);
+ return new Gesetz(jurabk, langue, kurzue, normen, neueGliederungen, stand, fortschreibungen);
}
public Gesetz mitLangue(String neuerLangtitel) {
- return new Gesetz(jurabk, neuerLangtitel, kurzue, normen, gliederungen, stand);
+ return new Gesetz(
+ jurabk, neuerLangtitel, kurzue, normen, gliederungen, stand, fortschreibungen);
+ }
+
+ /** Vermerkt ein angewandtes Heft; die Reihenfolge ist die der Anwendung. */
+ public Gesetz mitFortschreibung(Fortschreibung heft) {
+ var erweitert = new ArrayList<>(fortschreibungen);
+ erweitert.add(heft);
+ return new Gesetz(jurabk, langue, kurzue, normen, gliederungen, stand, erweitert);
+ }
+
+ /** Ob dieses Heft auf den Wortlaut bereits angewandt worden ist. */
+ public boolean traegt(Fortschreibung heft) {
+ return fortschreibungen.stream().anyMatch(f -> f.bezeichnung().equals(heft.bezeichnung()));
+ }
+
+ /**
+ * Wie jung der Wortlaut wirklich ist: das späteste Datum, das entweder die Standangabe der Quelle
+ * oder eines der angewandten Hefte nennt. An ihm hängt die Altersrüge — und zwar auch in der
+ * Kette, denn wer ein Heft anwendet, macht den Wortlaut so jung wie dieses Heft.
+ */
+ public @Nullable LocalDate wortlautStand() {
+ LocalDate juengste = stand != null ? stand.juengsteAenderung() : null;
+ for (var heft : fortschreibungen) {
+ if (heft.datum() != null && (juengste == null || heft.datum().isAfter(juengste))) {
+ juengste = heft.datum();
+ }
+ }
+ return juengste;
}
}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Stand.java b/src/main/java/eu/mulk/aendggner/gesetz/Stand.java
index 7541f5f..92f41be 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/Stand.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/Stand.java
@@ -3,6 +3,7 @@
package eu.mulk.aendggner.gesetz;
import java.time.LocalDate;
+import java.util.regex.Pattern;
import org.jspecify.annotations.Nullable;
/**
@@ -19,4 +20,39 @@ import org.jspecify.annotations.Nullable;
* @param juengsteAenderung das späteste Datum, das irgendeine Standangabe nennt — der wirkliche
* Stand des Wortlauts; {@code null}, wenn keine Angabe ein Datum trägt.
*/
-public record Stand(String kommentar, @Nullable LocalDate juengsteAenderung) {}
+public record Stand(String kommentar, @Nullable LocalDate juengsteAenderung) {
+
+ /** „… v. 19.6.2020 I 1385“ — das Datum, das eine Standangabe nennt. */
+ private static final Pattern STANDDATUM =
+ Pattern.compile("v\\. (\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})");
+
+ /** Eine Standzeile mitsamt dem spätesten Datum, das in ihr steht. */
+ public static Stand aus(String kommentar) {
+ return new Stand(kommentar, juengstesDatum(kommentar));
+ }
+
+ /**
+ * Das späteste Datum, das {@code text} nennt; {@code null}, wenn keines darin steht. Eine
+ * Standangabe nennt mitunter mehrere, und ein verschriebenes Datum („19.5..2020“) kommt vor — es
+ * bleibt außer Betracht, statt den ganzen Stand zu verwerfen.
+ */
+ public static @Nullable LocalDate juengstesDatum(String text) {
+ LocalDate juengste = null;
+ var m = STANDDATUM.matcher(text);
+ while (m.find()) {
+ try {
+ var datum =
+ LocalDate.of(
+ Integer.parseInt(m.group(3)),
+ Integer.parseInt(m.group(2)),
+ Integer.parseInt(m.group(1)));
+ if (juengste == null || datum.isAfter(juengste)) {
+ juengste = datum;
+ }
+ } catch (RuntimeException verschrieben) {
+ // Ein unmögliches Datum bleibt außer Betracht.
+ }
+ }
+ return juengste;
+ }
+}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java
index 35561c6..c0fd0b9 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java
@@ -165,10 +165,6 @@ public final class GiiXmlLoader {
return null;
}
- /** „… v. 19.6.2020 I 1385“ — das Datum, das eine Standangabe nennt. */
- private static final Pattern STANDDATUM =
- Pattern.compile("v\\. (\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})");
-
/**
* Der Stand des Gesetzes: die Standzeile im Wortlaut und das späteste Datum, das
* <em>irgendeine</em> Standangabe nennt. Die Hinweise („Änderung durch Art. 1 G v. 18.11.2020 …
@@ -186,20 +182,9 @@ public final class GiiXmlLoader {
if (zeile == null && "Stand".equals(kindText(angabe, "standtyp"))) {
zeile = kommentar;
}
- var m = STANDDATUM.matcher(kommentar);
- while (m.find()) {
- try {
- var datum =
- LocalDate.of(
- Integer.parseInt(m.group(3)),
- Integer.parseInt(m.group(2)),
- Integer.parseInt(m.group(1)));
- if (juengste == null || datum.isAfter(juengste)) {
- juengste = datum;
- }
- } catch (RuntimeException ignoriert) {
- // Ein verschriebenes Datum („19.5..2020“) kommt vor; es bleibt außer Betracht.
- }
+ var datum = Stand.juengstesDatum(kommentar);
+ if (datum != null && (juengste == null || datum.isAfter(juengste))) {
+ juengste = datum;
}
}
return zeile == null ? null : new Stand(zeile, juengste);
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeber.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeber.java
index 67434ca..3a82945 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeber.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeber.java
@@ -59,6 +59,11 @@ public final class LandesRechtTextAusgeber {
if (gesetz.stand() != null) {
sb.append("Stand: ").append(gesetz.stand().kommentar()).append('\n');
}
+ // Was das Erzeugnis selbst getan hat, steht mit im Kopf: Ohne diese Zeilen wüsste die nächste
+ // Stufe der Kette nicht, welche Hefte der Wortlaut schon trägt (siehe Fortschreibung).
+ for (var heft : gesetz.fortschreibungen()) {
+ sb.append("Fortgeschrieben durch: ").append(heft.bezeichnung()).append('\n');
+ }
var gliederungen = gesetz.gliederungen();
int gliederungsZeiger = 0;
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java
index bef8a2c..7f7e2f4 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java
@@ -3,9 +3,11 @@
package eu.mulk.aendggner.gesetz.land;
import eu.mulk.aendggner.gesetz.Absatz;
+import eu.mulk.aendggner.gesetz.Fortschreibung;
import eu.mulk.aendggner.gesetz.Gesetz;
import eu.mulk.aendggner.gesetz.Gliederung;
import eu.mulk.aendggner.gesetz.Norm;
+import eu.mulk.aendggner.gesetz.Stand;
import eu.mulk.aendggner.gesetz.Superskript;
import java.util.ArrayList;
import java.util.List;
@@ -38,6 +40,13 @@ final class LandesRechtTextParser {
private static final Pattern ABSATZ_MARKER = Pattern.compile("^\\((\\d+[a-z]?)\\)\\s+");
+ /** Die Standangabe der Quelle, wie der {@link LandesRechtTextAusgeber} sie schreibt. */
+ private static final Pattern STAND_ZEILE = Pattern.compile("^Stand: (\\S.*)$");
+
+ /** Ein bereits angewandtes Änderungsheft, gleichfalls aus der Feder des Ausgebers. */
+ private static final Pattern FORTSCHREIBUNGS_ZEILE =
+ Pattern.compile("^Fortgeschrieben durch: (\\S.*)$");
+
// Juris-Abkürzung direkt hinter dem Langtitel. Bayern führt einteilige Kürzel („(BayJG)“), die
// übrigen Länder oft mehrteilige („(GO NRW)“); Nur die Zeile unmittelbar nach dem Titel wird
// geprüft, sodass spätere Fundstellen-Klammern („(BayRS V S. 595)“) nicht getroffen werden.
@@ -189,7 +198,20 @@ final class LandesRechtTextParser {
}
}
// Rest des Titelblocks (Datum, Fundstellen, Vollzitat) bis zur ersten Struktur überspringen.
+ // Zwei Zeilenformen werden dabei aufgenommen statt verworfen: die Standangabe der Quelle und
+ // die Hefte, die das Erzeugnis selbst schon angewandt hat. Beides gibt der Ausgeber aus; würde
+ // es hier fallen, so verlöre die Kette bei jedem Schritt ihr Gedächtnis.
+ Stand stand = null;
+ var fortschreibungen = new ArrayList<Fortschreibung>();
while (i < zeilen.size() && !istStrukturZeile(zeilen, i)) {
+ var kopfzeile = zeilen.get(i).strip();
+ var standZeile = STAND_ZEILE.matcher(kopfzeile);
+ var heftZeile = FORTSCHREIBUNGS_ZEILE.matcher(kopfzeile);
+ if (standZeile.matches()) {
+ stand = Stand.aus(standZeile.group(1).strip());
+ } else if (heftZeile.matches()) {
+ fortschreibungen.add(Fortschreibung.aus(heftZeile.group(1).strip()));
+ }
i++;
}
@@ -347,7 +369,14 @@ final class LandesRechtTextParser {
"Kein „§ N“- oder „Art. N“-Normkopf gefunden — ist das eine konsolidierte Fassung im"
+ " kanonischen Klartextformat?");
}
- return new Gesetz(jurabk != null ? jurabk : langue, langue, kurzue, normen, gliederungen);
+ return new Gesetz(
+ jurabk != null ? jurabk : langue,
+ langue,
+ kurzue,
+ normen,
+ gliederungen,
+ stand,
+ fortschreibungen);
}
/** Eröffnet die Zeile eine Struktureinheit (Gliederung, Inhaltsübersicht oder Normkopf)? */
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
index 8cd867e..109bbf6 100644
--- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java
+++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
@@ -1667,6 +1667,39 @@ class EndToEndTest {
assertThat(ergebnis.html()).contains("keine Änderungsbefehle");
}
+ /**
+ * Die Kette und ihr Gedächtnis. Die eigene Ausgabe ist wieder Eingabe; sie trägt im Kopf, welche
+ * Hefte auf ihr schon vollzogen sind. Trifft dasselbe Heft ein zweites Mal auf sie, so greifen
+ * seine Befehle abermals — ein Befehl ist eine Anordnung und keine Zustandsbeschreibung —, und
+ * der angefügte Absatz stünde zweimal da. Am Wortlaut allein ist das nicht zu erkennen, wohl aber
+ * an dem, was die Fassung über sich selbst mitführt.
+ */
+ @Test
+ void ketteRuegtDasZweimalAngewandteHeft() throws Exception {
+ var xml = SAMPLEDATA.resolve("UWG/BJNR141400004.xml");
+ var pdf = SAMPLEDATA.resolve("UWG/bgbl126s0043_regelungstext.pdf");
+ assumeTrue(Files.exists(xml) && Files.exists(pdf), "UWG-Beispieldaten fehlen");
+
+ var erste = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(xml, List.of(pdf)));
+ assertThat(erste.anzahlAngewandt()).isEqualTo(19);
+ // Das Heft nennt sich nach dem, was im Dokument steht (Ausfertigungsdatum), nicht nach seinem
+ // Dateinamen — sonst hinge die Wiedererkennung an einer Zufälligkeit des Ablageortes.
+ assertThat(erste.neufassung())
+ .containsOnlyOnce("Fortgeschrieben durch: Änderungsgesetz vom 12. Februar 2026");
+
+ var zwischenfassung =
+ new Quelle("UWG-Zwischenfassung.txt", erste.neufassung().getBytes(StandardCharsets.UTF_8));
+ var zweite =
+ Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(zwischenfassung, List.of(Quelle.lies(pdf))));
+
+ assertThat(zweite.html())
+ .contains("Die Fassung trägt „Änderungsgesetz vom 12. Februar 2026“ bereits")
+ .contains("ein zweites Mal angewandt");
+ // Vermerkt bleibt das Heft einmal: Die Liste zählt Hefte, nicht Anwendungen.
+ assertThat(zweite.neufassung())
+ .containsOnlyOnce("Fortgeschrieben durch: Änderungsgesetz vom 12. Februar 2026");
+ }
+
private static Aenderungsbefehl befehlZu(
AenderungsgesetzParser.ParseErgebnis ergebnis, String gliederungsPfad) {
return ergebnis.befehle().stream()
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/DokumentErkennerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/DokumentErkennerTest.java
index aad40d5..4d756a7 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/DokumentErkennerTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/DokumentErkennerTest.java
@@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue;
import eu.mulk.aendggner.aenderung.DokumentArt;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
@@ -45,6 +46,51 @@ class DokumentErkennerTest {
assertThat(kopf.art()).isEqualTo(erwartet);
}
+ /**
+ * Das Ausfertigungsdatum aus der Zeile „Vom …“. An ihm erkennt die Fassung ein Heft wieder, das
+ * auf sie schon angewandt worden ist; deshalb darf es nicht geraten sein. Ein Sammelheft stellt
+ * mehrere Verkündungen nebeneinander (so das thüringische GVBl. Nr. 2/2026 mit vieren), und
+ * welche von ihnen das Dokument ausmacht, sagt sein Kopf nicht — dann bleibt das Datum leer.
+ */
+ @ParameterizedTest
+ @CsvSource({
+ "UWG/bgbl126s0043_regelungstext.pdf, 2026-02-12",
+ "Brandenburg/GVBl-I-2026-12_FraktG-AendG.pdf, 2026-04-22",
+ "NRW/GV-NRW-2026-S202_22-Rundfunkaenderungsgesetz.pdf, 2026-03-24",
+ "Hessen/GVBl-2026-05_11-AendVO-verkehrsrechtl-Zustaendigkeiten.pdf, 2026-01-28",
+ "Thueringen/GVBl-TH-2026-02_KiGaFinanzVO-AendVO-ua.pdf, ",
+ "GEG/BT-Drs-20-6875_Regierungsentwurf.pdf, ",
+ })
+ void liestDasAusfertigungsdatum(String datei, String erwartet) throws Exception {
+ var pfad = SAMPLEDATA.resolve(datei.strip());
+ assumeTrue(Files.exists(pfad), "Beispieldatei fehlt: " + datei);
+
+ var kopf = DokumentErkenner.erkenne(new PatchTextExtraktor().extrahiere(pfad));
+
+ assertThat(kopf.ausfertigung())
+ .isEqualTo(erwartet == null ? null : LocalDate.parse(erwartet.strip()));
+ }
+
+ /**
+ * Woran ein Heft sich nennt: an seiner Drucksachennummer, sonst an seinem Ausfertigungsdatum. Der
+ * Dateiname bleibt außen vor — er ist eine Zufälligkeit des Ablageortes, und an der Bezeichnung
+ * hängt die Wiedererkennung in der Kette.
+ */
+ @Test
+ void bezeichnetSichNachDemDokumentUndNichtNachDerDatei() throws Exception {
+ var gesetzblatt = SAMPLEDATA.resolve("UWG/bgbl126s0043_regelungstext.pdf");
+ var drucksache = SAMPLEDATA.resolve("UWG/BT-Drs-21-1855_Regierungsentwurf.pdf");
+ assumeTrue(Files.exists(gesetzblatt) && Files.exists(drucksache), "UWG-Beispieldaten fehlen");
+
+ assertThat(
+ DokumentErkenner.erkenne(new PatchTextExtraktor().extrahiere(gesetzblatt))
+ .anzeigeName())
+ .isEqualTo("Änderungsgesetz vom 12. Februar 2026");
+ assertThat(
+ DokumentErkenner.erkenne(new PatchTextExtraktor().extrahiere(drucksache)).anzeigeName())
+ .isEqualTo("Gesetzentwurf Drs. 21/1855");
+ }
+
/** Die Drucksachennummern stiften die Verbindung zwischen Antrag und Entwurf. */
@Test
void liestEigeneUndBezogeneDrucksachennummern() throws Exception {
diff --git a/src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeberTest.java b/src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeberTest.java
index e620a03..6d6eab2 100644
--- a/src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeberTest.java
+++ b/src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeberTest.java
@@ -5,12 +5,16 @@ package eu.mulk.aendggner.gesetz.land;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
+import eu.mulk.aendggner.gesetz.Fortschreibung;
import eu.mulk.aendggner.gesetz.Gesetz;
+import eu.mulk.aendggner.gesetz.Stand;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.time.LocalDate;
import java.util.List;
import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
@@ -52,6 +56,8 @@ class LandesRechtTextAusgeberTest {
assertThat(wiederGelesen.kurzue()).isEqualTo(gelesen.kurzue());
assertThat(bezeichnungen(wiederGelesen)).isEqualTo(bezeichnungen(gelesen));
assertThat(wiederGelesen.gliederungen()).isEqualTo(gelesen.gliederungen());
+ assertThat(wiederGelesen.stand()).isEqualTo(gelesen.stand());
+ assertThat(wiederGelesen.fortschreibungen()).isEqualTo(gelesen.fortschreibungen());
for (var soll : gelesen.normen()) {
var ist = wiederGelesen.norm(soll.enbez()).orElseThrow();
@@ -63,6 +69,43 @@ class LandesRechtTextAusgeberTest {
}
}
+ /**
+ * Der Kopf trägt das Gedächtnis der Kette: Standangabe und angewandte Hefte müssen den Rundlauf
+ * überstehen, sonst wüsste die nächste Stufe nicht, was der Wortlaut schon trägt — und dasselbe
+ * Heft ließe sich unbemerkt ein zweites Mal anwenden.
+ */
+ @Test
+ void rundlaufErhaeltStandUndHefte() throws IOException {
+ var datei = SAMPLEDATA.resolve("Brandenburg/FraktG-alt.txt");
+ assumeTrue(Files.exists(datei), "Beispieldaten fehlen");
+ var gelesen =
+ new LandesRechtLoader()
+ .load(datei)
+ .mitFortschreibung(
+ new Fortschreibung("Änderungsgesetz vom 22. April 2026", LocalDate.of(2026, 4, 22)))
+ .mitFortschreibung(new Fortschreibung("Gesetzentwurf Drs. 7/4711", null));
+ var mitStand =
+ new Gesetz(
+ gelesen.jurabk(),
+ gelesen.langue(),
+ gelesen.kurzue(),
+ gelesen.normen(),
+ gelesen.gliederungen(),
+ Stand.aus("Zuletzt geändert durch Art. 2 G v. 15.12.2022 I Nr. 26"),
+ gelesen.fortschreibungen());
+
+ var wiederGelesen = LandesRechtTextParser.parse(LandesRechtTextAusgeber.ausgeben(mitStand));
+
+ assertThat(wiederGelesen.stand()).isEqualTo(mitStand.stand());
+ assertThat(wiederGelesen.stand().juengsteAenderung()).isEqualTo(LocalDate.of(2022, 12, 15));
+ assertThat(wiederGelesen.fortschreibungen()).isEqualTo(mitStand.fortschreibungen());
+ // Der Wortlautstand folgt dem jüngsten Heft, nicht der Standzeile: Wer fortschreibt, macht den
+ // Wortlaut jünger, als die Quelle ihn ausweist.
+ assertThat(wiederGelesen.wortlautStand()).isEqualTo(LocalDate.of(2026, 4, 22));
+ assertThat(wiederGelesen.traegt(new Fortschreibung("Änderungsgesetz vom 22. April 2026", null)))
+ .isTrue();
+ }
+
private static List<String> bezeichnungen(Gesetz gesetz) {
return gesetz.normen().stream().map(n -> n.enbez()).toList();
}