From 63e1283230c5457666de90c9af74657b52579909 Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Wed, 26 Aug 2026 07:19:59 +0200 Subject: =?UTF-8?q?Die=20Fassung=20merkt=20sich,=20welche=20Hefte=20sie=20?= =?UTF-8?q?schon=20tr=C3=A4gt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- FASSUNGEN.txt | 114 +++++++++++++++++++++ README.md | 48 +++++++-- src/main/java/eu/mulk/aendggner/Pipeline.java | 61 ++++++++++- .../eu/mulk/aendggner/aenderung/DokumentKopf.java | 34 +++++- .../aenderung/parse/AenderungsgesetzParser.java | 25 ++++- .../aenderung/parse/DokumentErkenner.java | 51 ++++++++- .../eu/mulk/aendggner/gesetz/Fortschreibung.java | 43 ++++++++ src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java | 54 +++++++++- src/main/java/eu/mulk/aendggner/gesetz/Stand.java | 38 ++++++- .../eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java | 21 +--- .../gesetz/land/LandesRechtTextAusgeber.java | 5 + .../gesetz/land/LandesRechtTextParser.java | 31 +++++- src/test/java/eu/mulk/aendggner/EndToEndTest.java | 33 ++++++ .../aenderung/parse/DokumentErkennerTest.java | 46 +++++++++ .../gesetz/land/LandesRechtTextAusgeberTest.java | 43 ++++++++ 15 files changed, 598 insertions(+), 49 deletions(-) create mode 100644 src/main/java/eu/mulk/aendggner/gesetz/Fortschreibung.java diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt index 376dd30..db80d88 100644 --- a/FASSUNGEN.txt +++ b/FASSUNGEN.txt @@ -20,6 +20,120 @@ Vorbemerkung zur Führung dieses Verzeichnisses gelegte Ausdrucksweise entsprechend. +════════════════════════════════════════════════════════════════════════════════ + 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 “. + +(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(); - 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(); @@ -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. + * + *

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. + * + *

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 jedes Dokument ein Datum trägt — sonst fehlt der gemeinsame + * Maßstab, und geraten wird nicht. Und umgestellt wird nicht stillschweigend. + */ + private static List ordneNachAusfertigung( + List dokumente, List 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 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 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 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“. + * + *

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 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) + ". " @@ -411,6 +412,20 @@ public final class AenderungsgesetzParser { + " ihnen nur mit der zeitrichtigen Fassung des Stammgesetzes."); } + /** + * 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 letzten Änderung, nicht das der Ausfertigung. Deshalb muss ihm 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. + * + *

Nur wenn das ganze 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 kopfZeilen(String rohText) { var zeilen = new ArrayList(); 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 +// 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. + * + *

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: …“. + * + *

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 normen, List gliederungen, - @Nullable Stand stand) { + @Nullable Stand stand, + List fortschreibungen) { public Gesetz { normen = List.copyOf(normen); gliederungen = List.copyOf(gliederungen); + fortschreibungen = List.copyOf(fortschreibungen); + } + + public Gesetz( + String jurabk, + String langue, + String kurzue, + List normen, + List 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 neueNormen) { - return new Gesetz(jurabk, langue, kurzue, neueNormen, gliederungen, stand); + return new Gesetz(jurabk, langue, kurzue, neueNormen, gliederungen, stand, fortschreibungen); } public Gesetz mitGliederungen(List 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 * irgendeine 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(); 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 bezeichnungen(Gesetz gesetz) { return gesetz.normen().stream().map(n -> n.enbez()).toList(); } -- cgit v1.2.1