aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
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();
}