aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--FASSUNGEN.txt114
-rw-r--r--README.md48
-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
15 files changed, 598 insertions, 49 deletions
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt
index 376dd30..db80d88 100644
--- a/FASSUNGEN.txt
+++ b/FASSUNGEN.txt
@@ -21,6 +21,120 @@ Vorbemerkung zur Führung dieses Verzeichnisses
════════════════════════════════════════════════════════════════════════════════
+ Fassung vom 26. August 2026,
+ zuletzt geändert durch die am 26. August 2026 vorgenommenen Änderungen
+════════════════════════════════════════════════════════════════════════════════
+
+Die Kette führte kein Gedächtnis. Wer die eigene Ausgabe des Erzeugnisses als
+Stammgesetz eines weiteren Laufes einspeiste, gab ihr eine Fassung, der nicht
+anzusehen war, welche Hefte auf ihr bereits vollzogen sind; dasselbe Heft ein
+zweites Mal angewandt fügte denselben Absatz ein zweites Mal an. Bei näherer
+Prüfung erwies sich der Mangel als tiefer, als die bisherige Warnung des
+Handbuchs (§ 6a Absatz 3 alter Fassung) besagte: Der Textausgeber schrieb die
+Standangabe der Quelle sehr wohl aus, der Textparser aber verwarf sie mitsamt
+allem, was vor dem ersten Normkopf steht. Die vom Erzeugnis errechnete Fassung
+war mithin standlos, und die Altersrüge (§ 1 Absatz 6 des Handbuchs) konnte auf
+ihr niemals anschlagen.
+
+
+Artikel 1
+Vermerk der angewandten Änderungshefte
+
+(1) Es wird der Begriff der Fortschreibung (Fortschreibung) eingeführt. Sie
+ bezeichnet ein Änderungsheft, das auf einen Wortlaut bereits angewandt worden
+ ist, und besteht aus dessen Bezeichnung und dessen Ausfertigungsdatum.
+
+(2) Das Gesetz (Gesetz) führt fortan die geordnete Liste seiner Fortschreibungen
+ mit. Hinzu treten die Methoden „mitFortschreibung“, „traegt“ und
+ „wortlautStand“; letztere gibt das späteste Datum an, das entweder die
+ Standangabe der Quelle oder eines der angewandten Hefte nennt.
+
+(3) Der Textausgeber (LandesRechtTextAusgeber) schreibt für jede Fortschreibung
+ die Zeile „Fortgeschrieben durch: …“ hinter die Standzeile. Der Textparser
+ (LandesRechtTextParser) nimmt beide Zeilenformen im Titelblock auf, statt sie
+ zu verwerfen. Damit übersteht das Gedächtnis den Rundlauf.
+
+(4) Die Standzeile wird bei dieser Gelegenheit an einer Stelle zusammengeführt:
+ Die Erhebung des jüngsten Datums aus einer Standangabe steht fortan im Stand
+ (Stand.juengstesDatum, Stand.aus) und nicht mehr im Lader des gii-XML, der
+ sie nunmehr von dort bezieht.
+
+
+Artikel 2
+Bezeichnung eines Änderungsdokuments aus ihm selbst
+
+(1) Der Dokumentkopf (DokumentKopf) wird um das Ausfertigungsdatum ergänzt. Der
+ Dokumenterkenner (DokumentErkenner) erhebt es aus der für sich stehenden
+ Zeile „Vom <Datum>“.
+
+(2) Erhoben wird es nur, wenn das gesamte Dokument genau eine solche Zeile führt.
+ Ein Sammelheft stellt mehrere Verkündungen nebeneinander — das thüringische
+ Gesetz- und Verordnungsblatt Nr. 2/2026 deren vier —, und welche von ihnen
+ das Dokument ausmacht, sagt es nicht. Gesucht wird deshalb über den vollen
+ Text und nicht über die ersten einhundertzwanzig Kopfzeilen; anderenfalls
+ bliebe die zweite Verkündung außer Sicht und das Heft trüge den Namen der
+ ersten.
+
+(3) Der Anzeigename (DokumentKopf.anzeigeName) nennt fortan neben der eigenen
+ Drucksachennummer hilfsweise das Ausfertigungsdatum („Änderungsgesetz vom
+ 12. Februar 2026“). Er ist zugleich der Schlüssel, an dem eine Fassung ein
+ Heft wiedererkennt; maßgeblich ist mithin, was im Dokument steht, und nicht,
+ wie die Datei benannt ist.
+
+
+Artikel 3
+Rüge der zweiten Anwendung; Reihenfolge der Hefte
+
+(1) Die Kernpipeline (Pipeline) vermerkt nach jedem Änderungsdokument, dessen
+ Befehle wenigstens einmal gegriffen haben, die zugehörige Fortschreibung am
+ Gesetz. Ein Heft, dessen Befehle sämtlich liegenblieben, hat den Wortlaut
+ nicht fortgeschrieben und wird nicht vermerkt.
+
+(2) Trägt die Fassung das Heft bereits, so ergeht eine Rüge in der Synopse. Der
+ Lauf wird nicht abgebrochen und kein Befehl verworfen (§ 1 Absatz 4 des
+ Handbuchs); vermerkt wird das Heft alsdann kein zweites Mal, denn die Liste
+ zählt Hefte und nicht Anwendungen.
+
+(3) Tragen sämtliche Änderungsdokumente eines Laufes ein Ausfertigungsdatum, so
+ werden sie in dessen Reihenfolge angewandt; jedes Heft setzt den Stand
+ voraus, den das vorige hinterlässt. Fehlt einem das Datum, so verbleibt es
+ bei der Reihenfolge der Aufrufargumente. Eine Umstellung wird in der Synopse
+ genannt.
+
+(4) Die Altersrüge (AenderungsgesetzParser) bemisst sich fortan nach dem
+ Wortlautstand des Gesetzes und nicht mehr allein nach dessen Standzeile. Sie
+ trägt damit auch in der Kette: Wer ein Heft anwendet, macht den Wortlaut so
+ jung wie dieses Heft.
+
+
+Artikel 4
+Änderung des Handbuchs
+
+Das Handbuch (README.md) wird wie folgt geändert:
+
+1. § 5 Absatz 2 wird um eine Nummer 4 ergänzt, die die beiden Kopfzeilen des
+ kanonischen Klartextes benennt.
+
+2. § 6a wird um die Absätze 3 bis 5 ergänzt (Reihenfolge der Hefte, Gedächtnis
+ der Kette nebst Rüge, Fortwirkung der Altersrüge); die bisherigen Absätze 3
+ und 4 werden die Absätze 4 und 6.
+
+3. § 17 Absatz 3 wird dahin ergänzt, dass der Rundlauf auch den Kopf umfasst.
+
+
+Schlussbestimmung
+
+Die vorstehenden Änderungen sind durch die Prüfung sämtlicher Testfälle
+(dreihundertsiebenundneunzig an der Zahl, zuvor dreihundertachtundachtzig) sowie
+durch die vollständige Erstellung (mvnw verify) bestätigt worden. Neu hinzu
+getreten sind der Rundlauf des Kopfes (LandesRechtTextAusgeberTest), die Erhebung
+und die Prüfung des Ausfertigungsdatums an sechs Beispieldokumenten
+(DokumentErkennerTest) sowie die Kettenprüfung am Heft des Dritten Gesetzes zur
+Änderung des Gesetzes gegen den unlauteren Wettbewerb (EndToEndTest). Die
+Auszeichnung nach REUSE ist vollständig (191 von 191 Dateien).
+
+
+════════════════════════════════════════════════════════════════════════════════
Fassung vom 25. August 2026,
zuletzt geändert durch die am 25. August 2026 vorgenommenen Änderungen
════════════════════════════════════════════════════════════════════════════════
diff --git a/README.md b/README.md
index 6e8c180..0b3dbe2 100644
--- a/README.md
+++ b/README.md
@@ -181,6 +181,10 @@ angenommen. Hierbei gilt:
ergibt sich aus der geladenen Stammfassung, nicht aus einer Länderkennung.
3. Das Zitiersigel (§ oder Art.) folgt gleichfalls aus den Normköpfen der
Stammfassung.
+4. Der Kopf darf hinter dem Titelblock die Zeilen „Stand: …“ und
+ „Fortgeschrieben durch: …“ führen. Jene gibt den Stand der Quelle wieder,
+ diese jedes Heft, das auf den Wortlaut bereits angewandt worden ist; beide
+ schreibt `--neufassung`, und beide werden zurückgelesen (§ 6a Absatz 4).
(3) Als Änderungsdokument werden PDF-Dateien des Bundesgesetzblattes, der
Gesetz- und Verordnungsblätter der Länder und der Drucksachen von Bundestag,
@@ -258,18 +262,42 @@ java -jar target/aendggner-0.1.0-SNAPSHOT.jar zwischenfassung.txt heft-2.pdf \
Auf diesem Wege lässt sich eine konsolidierte Fassung aus dem Stammheft und den
Änderungsheften des Gesetzblattes zusammensetzen, wo kein Portal sie fertig liefert
-(§ 13 Absatz 3). Mehrere Hefte in einem Lauf sind ohnehin zulässig; die Kette ist
-für das gedacht, was über einen Lauf hinausreicht.
+(§ 13 Absatz 3). Mehrere Hefte in einem Lauf sind ohnehin zulässig und werden
+gleichfalls nacheinander angewandt, jedes auf das Ergebnis des vorigen; die Kette
+über mehrere Läufe ist für das gedacht, was über einen Lauf hinausreicht.
-(3) Die Kette meint verschiedene Hefte. Dasselbe Heft ein zweites Mal auf seine
+(3) Tragen sämtliche Hefte eines Laufes ein Ausfertigungsdatum, so werden sie in
+dessen Reihenfolge angewandt und nicht in der der Aufrufargumente — jedes Heft setzt
+den Stand voraus, den das vorige hinterlässt. Fehlt einem das Datum, so bleibt es bei
+der Aufrufreihenfolge; geraten wird nicht. Umgestellt wird nie stillschweigend: Die
+Synopse nennt die gewählte Folge.
+
+(4) Die Kette meint verschiedene Hefte. Dasselbe Heft ein zweites Mal auf seine
eigene Ausgabe anzuwenden ist keine Wiederholung ohne Folgen: Ein Befehl ist keine
Zustandsbeschreibung, sondern eine Anordnung, und wer zweimal anfügt, fügt zweimal
-an. Das Erzeugnis führt kein Gedächtnis darüber, was es bereits getan hat — der
-Wortlaut allein sagt es ihm, und der sagt es nur dort, wo die Anordnung einen
-Zieltext voraussetzt, den es nicht mehr gibt. Wer prüfen will, ob eine Fassung ein
-Heft schon trägt, halte sie mit `--nachfassung` (§ 6b) dagegen.
+an. Dem Wortlaut allein ist das nicht anzusehen — die Befehle greifen ein zweites Mal
+anstandslos. Die Fassung führt deshalb mit, welche Hefte auf ihr schon vollzogen
+sind: Der kanonische Klartext trägt dafür im Kopf die Zeilen
+
+```text
+Stand: Zuletzt geändert durch Art. 6 G v. 12.5.2026 I Nr. 139
+Fortgeschrieben durch: Änderungsgesetz vom 12. Februar 2026
+```
+
+und der Lader liest sie zurück. Trifft ein Heft ein zweites Mal auf dieselbe Fassung,
+so wird das gerügt; abgebrochen wird nicht, denn verworfen wird hier nichts
+(§ 1 Absatz 4). Ein Heft nennt sich dabei nach dem, was in ihm selbst steht — nach
+seiner Drucksachennummer, sonst nach seinem Ausfertigungsdatum („Vom 12. Februar
+2026“) —, und nicht nach seinem Dateinamen. Führt ein Sammelheft mehrere
+Verkündungen, so bleibt es unbezeichnet: Welche von ihnen es ausmacht, sagt es nicht,
+und ein falsch bezeichnetes Heft wäre schlimmer als ein unbezeichnetes.
+
+(5) Aus dem Vermerk folgt zugleich, dass die Altersrüge (§ 1 Absatz 6) in der Kette
+weiterträgt: Wer ein Heft anwendet, macht den Wortlaut so jung wie dieses Heft, und
+ein weiteres Heft, das eine ältere Fassung fortschreiben will, wird als solches
+erkannt — auch dann, wenn die Standzeile der Quelle davon noch nichts weiß.
-(4) Ausgegeben wird allein, was das Datenmodell trägt; erfunden wird nichts. Für
+(6) Ausgegeben wird allein, was das Datenmodell trägt; erfunden wird nichts. Für
Bundesrecht ist die Ausgabe deshalb eine getreue Wiedergabe des Wortlauts, aber kein
Rundlauf ins gii-XML: Jenes Format führt einen Fußnotenapparat und Standangaben, die
der Klartext nicht aufnimmt. Was der Klartext trägt, trägt er vollständig — dass der
@@ -866,7 +894,9 @@ kein Selbstzweck: Er deckt gerade die Verluste auf, die ein Ausgeber sonst
stillschweigend einbaut, und hat auf Anhieb zwei Lücken des Lesers zutage
gefördert — die unvollständige Nachfolgerliste der Unter-Überschriften und die
aufgehobene Sammelnorm des Bundesrechts („§§ 17 u. 18 (weggefallen)“), deren
-Kopfzeile zuvor in den Wortlaut der Vornorm gefallen wäre.
+Kopfzeile zuvor in den Wortlaut der Vornorm gefallen wäre. Der Rundlauf umfasst
+auch den Kopf: Standangabe und angewandte Hefte müssen ihn überstehen, sonst
+verlöre die Kette bei jedem Schritt ihr Gedächtnis (§ 6a Absatz 4).
## Lizenz
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();
}