diff options
| author | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-08-24 20:34:59 +0200 |
|---|---|---|
| committer | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-08-24 20:34:59 +0200 |
| commit | b682c225ab39859b6488ad2c327c6a67bbaf5b50 (patch) | |
| tree | 55a8b8f3414eff9f2c0d5172a6b0f85412fca8ed /src/main/java/eu | |
| parent | 3d40c583308d3f26501a53547e6294dc2db20854 (diff) | |
Ein Gesetz tritt nicht notwendig auf einen Schlag in Kraft
Das Dritte UWG-Änderungsgesetz lässt seinen Artikel 1 Nummer 2 Buchstabe c
am 19. Juni 2026 wirksam werden und alles Übrige erst am 27. September; das
Infektionsschutz-Änderungsgesetz von 2020 ändert dasselbe Stammgesetz in zwei
Artikeln zu zwei Zeitpunkten. Wer alle Befehle zugleich anwendet, erhält dort
eine Fassung, die an keinem einzigen Tag gegolten hat. Genau das tat ÄndGgner
bisher, und zwar schweigend — die auffälligste Stelle, an der ein Werkzeug, das
nie stillschweigend verwerfen will, doch etwas verschwieg.
Der Schlussartikel wird jetzt gelesen. Die Artikelwahl erfasst ihn nicht, denn er
trägt weder Änderungsformel noch Stammgesetz; maßgeblich ist der erste Artikel
hinter dem letzten ändernden, damit in einem Gesetzblatt mit mehreren Gesetzen
jedes seine eigene Schlussvorschrift bekommt. Erkannt werden die Grundregel, der
Vorbehalt zugunsten späterer Absätze, die abweichende Anordnung für Artikel,
Nummern, Buchstaben und Doppelbuchstaben sowie die Rückwirkung („mit Wirkung
vom“). Nennt der Wortlaut kein Datum, sondern die Verkündung, so wird keines
erfunden: Der Verkündungstag steht nicht im Gesetzestext.
Zugeordnet wird über die Herkunft des Befehls — Artikel und Gliederungspfad —,
und es gewinnt die besonderste Anordnung. Mit --stichtag (im Vordruck: „Stichtag“)
ergibt sich die an jenem Tag geltende Fassung; was noch nicht galt, bleibt
unangewandt und steht für sich, nicht unter den Gründen des Scheiterns. Ohne
Stichtag bleibt es beim vollen Bestand, aber die Staffelung wird gerügt.
Geprüft: 348 Tests (339 + 6 Einheitstests des Lesers + 3 Belegfälle), REUSE
180/180. UWG 1/19 Befehle am 19. Juni gegen 19/19 am 27. September, GEG 115 gegen
116, IfSG 65 gegen 75 — jeweils ohne Zuwachs an manuellen Resten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I887f233c72a4e49b69f9ede3154dcadd704fb0c3
Diffstat (limited to 'src/main/java/eu')
11 files changed, 605 insertions, 50 deletions
diff --git a/src/main/java/eu/mulk/aendggner/AendGgner.java b/src/main/java/eu/mulk/aendggner/AendGgner.java index 58b0d50..fbe80fd 100644 --- a/src/main/java/eu/mulk/aendggner/AendGgner.java +++ b/src/main/java/eu/mulk/aendggner/AendGgner.java @@ -12,6 +12,8 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.EnumMap; import java.util.List; @@ -103,6 +105,14 @@ public class AendGgner implements Callable<Integer> { + "(default: all articles whose introduction names the base law).") private String artikel; + @Option( + names = "--stichtag", + paramLabel = "<JJJJ-MM-TT>", + description = + "Produce the version in force on this date. Commands of the amendment act that had not " + + "yet entered into force are listed separately instead of being applied.") + private String stichtag; + public static void main(String... args) { int exitCode = new CommandLine(new AendGgner()).execute(args); System.exit(exitCode); @@ -199,7 +209,18 @@ public class AendGgner implements Callable<Integer> { return 1; } - var ergebnis = Pipeline.erzeugeSynopse(baseFile, patches, artikel, vollstaendig); + LocalDate tag = null; + if (stichtag != null) { + try { + tag = LocalDate.parse(stichtag); + } catch (DateTimeParseException e) { + System.err.println( + "Fehler: „" + stichtag + "“ ist kein Datum in der Form JJJJ-MM-TT (z.B. 2024-01-01)."); + return 1; + } + } + + var ergebnis = Pipeline.erzeugeSynopse(baseFile, patches, artikel, vollstaendig, tag); if (output.equals("-")) { System.out.println(ergebnis.html()); diff --git a/src/main/java/eu/mulk/aendggner/Pipeline.java b/src/main/java/eu/mulk/aendggner/Pipeline.java index 7949e7f..abb19d5 100644 --- a/src/main/java/eu/mulk/aendggner/Pipeline.java +++ b/src/main/java/eu/mulk/aendggner/Pipeline.java @@ -4,14 +4,17 @@ package eu.mulk.aendggner; import eu.mulk.aendggner.aenderung.DokumentArt; import eu.mulk.aendggner.aenderung.DokumentKopf; +import eu.mulk.aendggner.aenderung.Inkrafttreten; import eu.mulk.aendggner.aenderung.parse.AenderungsantragParser; import eu.mulk.aendggner.aenderung.parse.AenderungsgesetzParser; +import eu.mulk.aendggner.aenderung.parse.DeutschesDatum; import eu.mulk.aendggner.aenderung.parse.DokumentErkenner; import eu.mulk.aendggner.aenderung.parse.EntwurfsPatcher; import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor; import eu.mulk.aendggner.aenderung.parse.SuperskriptModus; import eu.mulk.aendggner.aenderung.parse.TextBereiniger; import eu.mulk.aendggner.anwendung.BefehlAnwender; +import eu.mulk.aendggner.anwendung.Grund; import eu.mulk.aendggner.gesetz.Gesetz; import eu.mulk.aendggner.gesetz.Superskript; import eu.mulk.aendggner.gesetz.gii.GiiXmlLoader; @@ -19,8 +22,10 @@ import eu.mulk.aendggner.gesetz.land.LandesRechtLoader; import eu.mulk.aendggner.synopse.HtmlRenderer; import eu.mulk.aendggner.synopse.SynopseBuilder; import java.nio.file.Path; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; +import java.util.Set; import org.jspecify.annotations.Nullable; /** @@ -85,16 +90,41 @@ public final class Pipeline { /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */ public static Ergebnis erzeugeSynopse( Path baseFile, List<Path> patches, String artikel, boolean vollstaendig) throws Exception { + return erzeugeSynopse(baseFile, patches, artikel, vollstaendig, null); + } + + public static Ergebnis erzeugeSynopse( + Path baseFile, + List<Path> patches, + String artikel, + boolean vollstaendig, + @Nullable LocalDate stichtag) + throws Exception { var patchQuellen = new ArrayList<Quelle>(); for (var patch : patches) { patchQuellen.add(Quelle.lies(patch)); } - return erzeugeSynopse(Quelle.lies(baseFile), patchQuellen, artikel, vollstaendig); + return erzeugeSynopse(Quelle.lies(baseFile), patchQuellen, artikel, vollstaendig, stichtag); } public static Ergebnis erzeugeSynopse( Quelle baseFile, List<Quelle> patches, String artikel, boolean vollstaendig) throws Exception { + return erzeugeSynopse(baseFile, patches, artikel, vollstaendig, null); + } + + /** + * @param stichtag die Fassung dieses Tages erzeugen: Befehle, die an ihm noch nicht in Kraft + * waren, bleiben unangewandt und werden gesondert ausgewiesen. {@code null} = alle Befehle + * anwenden (dann warnt die Synopse, wenn das Gesetz gestaffelt in Kraft tritt). + */ + public static Ergebnis erzeugeSynopse( + Quelle baseFile, + List<Quelle> patches, + String artikel, + boolean vollstaendig, + @Nullable LocalDate stichtag) + throws Exception { var altesGesetz = ladeStammgesetz(baseFile); var extraktor = new PatchTextExtraktor(superskriptModus(altesGesetz)); var parser = new AenderungsgesetzParser(); @@ -106,6 +136,7 @@ public final class Pipeline { var protokoll = new ArrayList<BefehlAnwender.AngewandteAenderung>(); var quellen = new ArrayList<String>(); boolean entwurfsfassung = false; + Inkrafttreten inkrafttreten = null; for (var dokument : dokumente) { var parseErgebnis = parser.parse(dokument.text(), gesetz, artikel, entwurfsGrenzen(dokument)); @@ -115,16 +146,26 @@ public final class Pipeline { .formatted( dokument.quelle().name(), dokument.kopf().anzeigeName(), gesetz.jurabk())); } - var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle()); + if (parseErgebnis.inkrafttreten() != null) { + inkrafttreten = parseErgebnis.inkrafttreten(); + } + var geteilt = teileNachStichtag(parseErgebnis, stichtag, warnungen); + var anwendung = BefehlAnwender.anwenden(gesetz, geteilt.anzuwenden()); gesetz = anwendung.neu(); protokoll.addAll(anwendung.protokoll()); + protokoll.addAll(geteilt.zurueckgestellt()); warnungen.addAll(parseErgebnis.warnungen()); entwurfsfassung |= dokument.kopf().art().istEntwurfsfassung(); quellen.add(dokument.quellenAngabe(parseErgebnis.artikel())); } + if (stichtag == null && inkrafttreten != null && inkrafttreten.gestaffelt()) { + warnungen.add(staffelungsWarnung(inkrafttreten)); + } var gesamtErgebnis = new BefehlAnwender.AnwendungsErgebnis(gesetz, protokoll); - var synopse = SynopseBuilder.baue(altesGesetz, gesamtErgebnis, warnungen, vollstaendig); + var synopse = + SynopseBuilder.baue( + altesGesetz, gesamtErgebnis, warnungen, vollstaendig, inkrafttreten, stichtag); var quellenZeile = baseFile.name() + " + " + String.join(" + ", quellen); var html = HtmlRenderer.rendere(synopse, quellenZeile, entwurfsfassung); @@ -136,6 +177,88 @@ public final class Pipeline { protokoll.size()); } + /** Die Befehle eines Dokuments, geschieden nach dem, was am Stichtag schon galt. */ + private record Geteilt( + List<eu.mulk.aendggner.aenderung.Aenderungsbefehl> anzuwenden, + List<BefehlAnwender.AngewandteAenderung> zurueckgestellt) {} + + /** + * Scheidet die Befehle nach dem Stichtag. Ohne Stichtag bleibt alles beisammen — dann ist die + * Fassung die des vollständigen Inkrafttretens, worauf die Staffelungswarnung hinweist. + * + * <p>Eine Anordnung ohne bestimmbares Datum („am Tag nach der Verkündung“ — der Verkündungstag + * steht nicht im Gesetzestext) gilt als am Stichtag bereits wirksam; geraten wird nicht, aber es + * wird gesagt. + */ + private static Geteilt teileNachStichtag( + AenderungsgesetzParser.ParseErgebnis parseErgebnis, + @Nullable LocalDate stichtag, + List<String> warnungen) { + var befehle = parseErgebnis.befehle(); + var inkrafttreten = parseErgebnis.inkrafttreten(); + if (stichtag == null || befehle.isEmpty()) { + return new Geteilt(befehle, List.of()); + } + if (inkrafttreten == null) { + warnungen.add( + "Es wurde ein Stichtag gewählt, das Änderungsdokument trägt aber keine lesbare" + + " Inkrafttretens-Vorschrift; angewandt wurden deshalb alle Befehle."); + return new Geteilt(befehle, List.of()); + } + var anzuwenden = new ArrayList<eu.mulk.aendggner.aenderung.Aenderungsbefehl>(); + var zurueckgestellt = new ArrayList<BefehlAnwender.AngewandteAenderung>(); + boolean unbestimmt = false; + for (var befehl : befehle) { + var regel = inkrafttreten.fuer(befehl.provenienz()).orElse(null); + if (regel == null || regel.datum() == null) { + unbestimmt |= regel != null; + anzuwenden.add(befehl); + continue; + } + if (regel.datum().isAfter(stichtag)) { + zurueckgestellt.add( + new BefehlAnwender.AngewandteAenderung( + befehl, + BefehlAnwender.Status.NICHT_IN_KRAFT, + "Tritt erst am " + + DeutschesDatum.schreibe(regel.datum()) + + " in Kraft („" + + regel.wortlaut() + + "“).", + Set.of(), + Grund.NOCH_NICHT_IN_KRAFT)); + continue; + } + anzuwenden.add(befehl); + } + if (unbestimmt) { + warnungen.add( + "Eine Inkrafttretens-Anordnung nennt kein bestimmtes Datum, sondern knüpft an die" + + " Verkündung an; der Verkündungstag steht nicht im Gesetzestext. Die betroffenen" + + " Befehle wurden als am Stichtag bereits geltend behandelt."); + } + return new Geteilt(List.copyOf(anzuwenden), List.copyOf(zurueckgestellt)); + } + + /** + * Wird ein Änderungsgesetz gestaffelt wirksam, so ist die auf einen Schlag gerechnete Fassung + * eine, die an keinem Tag gegolten hat. Das darf nicht unausgesprochen bleiben. + */ + private static String staffelungsWarnung(Inkrafttreten inkrafttreten) { + var sb = new StringBuilder("Das Änderungsgesetz tritt gestaffelt in Kraft"); + inkrafttreten + .grundregel() + .ifPresent(r -> sb.append(" — im Grundsatz: „").append(r.wortlaut()).append("“")); + for (var sonder : inkrafttreten.sonderregeln()) { + sb.append("; abweichend: „").append(sonder.wortlaut()).append("“"); + } + return sb.append( + ". Die gezeigte Fassung wendet alle Befehle an und gilt daher erst, wenn das Gesetz" + + " vollständig in Kraft getreten ist; die Fassung eines bestimmten Tages ergibt" + + " sich nur mit einem Stichtag.") + .toString(); + } + /** * Liest die Änderungsdokumente ein, bestimmt ihre Art und sortiert die aus, aus denen keine * Synopse zu gewinnen ist. Verworfen wird nichts stillschweigend: Jedes ausgesonderte Dokument diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Inkrafttreten.java b/src/main/java/eu/mulk/aendggner/aenderung/Inkrafttreten.java new file mode 100644 index 0000000..0dfe754 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/Inkrafttreten.java @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: 2020 Matthias Andreas Benkard <code@mail.matthias.benkard.de> +// 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.ArrayList; +import java.util.List; +import java.util.Optional; +import org.jspecify.annotations.Nullable; + +/** + * Was der Schlussartikel eines Änderungsgesetzes über sein Inkrafttreten sagt. + * + * <p>Ein Änderungsgesetz tritt nicht notwendig auf einen Schlag in Kraft. Das UWG-Änderungsgesetz + * von 2026 etwa lässt seinen Artikel 1 Nummer 2 Buchstabe c am 19. Juni 2026 wirksam werden und + * alles Übrige erst am 27. September 2026 — wer alle Befehle zugleich anwendet, erhält eine + * Fassung, die an keinem einzigen Tag gegolten hat. Deshalb trägt jeder Befehl seinen Stichtag. + * + * <p>Erfunden wird nichts: Nennt der Wortlaut kein bestimmtes Datum („am Tag nach der Verkündung“ — + * der Verkündungstag steht nicht im Gesetzestext), so bleibt {@link Regel#datum()} leer und es + * bleibt beim Wortlaut. + */ +public record Inkrafttreten(List<Regel> regeln) { + + /** + * Eine Inkrafttretens-Anordnung. + * + * @param datum der Tag, an dem sie wirkt; {@code null}, wenn der Wortlaut keinen nennt. + * @param wortlaut der Satz, aus dem sie stammt — er bleibt maßgeblich. + * @param giltFuer die Punkte, die sie erfasst; leer heißt: das ganze Gesetz (Grundregel). + */ + public record Regel(@Nullable LocalDate datum, String wortlaut, List<Punktbezug> giltFuer) { + + public boolean istGrundregel() { + return giltFuer.isEmpty(); + } + + /** „am 1. Oktober 2024“ bzw. der Wortlaut, wo kein Datum bestimmbar ist. */ + public String anzeige() { + return datum == null ? wortlaut : DeutschesDatum.schreibe(datum); + } + } + + /** + * Ein Verweis des Schlussartikels auf einen Punkt des eigenen Gesetzes: „Artikel 1 Nummer 11 + * Buchstabe a Doppelbuchstabe bb“. + * + * @param artikel die Artikel- bzw. Paragraphennummer, wie {@link Provenienz#artikel()} sie führt. + * @param pfadPraefix der Anfang des Gliederungspfades („11. a) bb)“); leer heißt: der ganze + * Artikel. + */ + public record Punktbezug(String artikel, String pfadPraefix) { + + boolean trifft(Provenienz provenienz) { + if (!artikel.equals(provenienz.artikel())) { + return false; + } + var pfad = provenienz.gliederungsPfad(); + return pfadPraefix.isEmpty() + || pfad.equals(pfadPraefix) + || pfad.startsWith(pfadPraefix + " "); + } + + /** Je länger der Pfad, desto besonderer der Bezug. */ + int besonderheit() { + return pfadPraefix.length(); + } + } + + /** + * Die Regel, die für diesen Befehl gilt: die besonderste, die ihn erfasst, sonst die Grundregel. + */ + public Optional<Regel> fuer(Provenienz provenienz) { + Regel beste = null; + int besteBesonderheit = -1; + for (var regel : regeln) { + for (var bezug : regel.giltFuer()) { + if (bezug.trifft(provenienz) && bezug.besonderheit() > besteBesonderheit) { + beste = regel; + besteBesonderheit = bezug.besonderheit(); + } + } + } + return beste != null ? Optional.of(beste) : grundregel(); + } + + public Optional<Regel> grundregel() { + return regeln.stream().filter(Regel::istGrundregel).findFirst(); + } + + /** Die Sonderregeln, in der Reihenfolge des Schlussartikels. */ + public List<Regel> sonderregeln() { + var sonder = new ArrayList<Regel>(); + for (var regel : regeln) { + if (!regel.istGrundregel()) { + sonder.add(regel); + } + } + return List.copyOf(sonder); + } + + /** + * Wahr, wenn das Gesetz an mehr als einem Tag wirksam wird — nur dann ist die auf einen Schlag + * gerechnete Fassung eine, die es so nie gab. + */ + public boolean gestaffelt() { + return !sonderregeln().isEmpty(); + } +} 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 06186c7..7cced90 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java @@ -4,14 +4,12 @@ package eu.mulk.aendggner.aenderung.parse; import eu.mulk.aendggner.aenderung.Aenderungsbefehl; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl; +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.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; -import java.util.Locale; import java.util.Optional; import java.util.regex.Pattern; import org.jboss.logging.Logger; @@ -37,8 +35,21 @@ public final class AenderungsgesetzParser { private static final Pattern PARAGRAPH_UEBERSCHRIFT_AUSSEN = Pattern.compile("^§\\s*(\\d+[a-z]?)$"); + /** + * @param inkrafttreten was der Schlussartikel über das Inkrafttreten sagt; {@code null}, wenn das + * Dokument keinen lesbaren trägt (Entwürfe ohne Schlussartikel, Auszüge). + */ public record ParseErgebnis( - List<Aenderungsbefehl> befehle, List<String> artikel, List<String> warnungen) {} + List<Aenderungsbefehl> befehle, + List<String> artikel, + List<String> warnungen, + @Nullable Inkrafttreten inkrafttreten) { + + public ParseErgebnis( + List<Aenderungsbefehl> befehle, List<String> artikel, List<String> warnungen) { + this(befehle, artikel, warnungen, null); + } + } /** * @param text der bereinigte Lineartext des Änderungsgesetzes. @@ -70,8 +81,10 @@ public final class AenderungsgesetzParser { var befehle = new ArrayList<Aenderungsbefehl>(); var betroffeneArtikel = new ArrayList<String>(); var warnungen = new ArrayList<>(zitate.warnungen()); + int letzterBetroffen = -1; - for (var artikel : artikelBloecke) { + for (int artikelIndex = 0; artikelIndex < artikelBloecke.size(); artikelIndex++) { + var artikel = artikelBloecke.get(artikelIndex); var relevant = artikelFilter != null ? artikel.label.equals(artikelFilter) @@ -95,6 +108,7 @@ public final class AenderungsgesetzParser { + " Namen des Stammgesetzes)."); } betroffeneArtikel.add(artikel.label); + letzterBetroffen = artikelIndex; var scan = GliederungsScanner.scanne(artikel.zeilen); if (scan.punkte().isEmpty()) { @@ -114,7 +128,14 @@ public final class AenderungsgesetzParser { } } - return new ParseErgebnis(befehle, betroffeneArtikel, warnungen); + // Der Schlussartikel steht hinter dem letzten ändernden Artikel desselben Gesetzes. + var inkrafttreten = + letzterBetroffen < 0 + ? null + : InkrafttretensLeser.waehle( + artikelBloecke.stream().map(b -> String.join("\n", b.zeilen())).toList(), + letzterBetroffen + 1); + return new ParseErgebnis(befehle, betroffeneArtikel, warnungen, inkrafttreten); } private static final String AENDERUNGSFORMEL = "wird wie folgt geändert:"; @@ -370,14 +391,13 @@ public final class AenderungsgesetzParser { if (!satzDatum.find()) { return Optional.empty(); } - var genannt = - datum(satzDatum.group(3), MONATSNAMEN.indexOf(satzDatum.group(2)) + 1, satzDatum.group(1)); + var genannt = DeutschesDatum.lies(satzDatum.group(1), satzDatum.group(2), satzDatum.group(3)); if (genannt == null || !stand.juengsteAenderung().isAfter(genannt)) { return Optional.empty(); } return Optional.of( "Das Stammgesetz ist jünger als das Änderungsgesetz: Sein Wortlaut ist bis zum " - + stand.juengsteAenderung().format(DATUM) + + DeutschesDatum.schreibe(stand.juengsteAenderung()) + " fortgeschrieben („" + stand.kommentar() + "“), während der Einleitungssatz die Fassung vom " @@ -391,9 +411,6 @@ public final class AenderungsgesetzParser { + " ihnen nur mit der zeitrichtigen Fassung des Stammgesetzes."); } - private static final DateTimeFormatter DATUM = - DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.GERMAN); - /** * „… 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 @@ -402,34 +419,7 @@ public final class AenderungsgesetzParser { * geänderten Gesetz eine Warnung. */ private static final Pattern EINLEITUNGS_DATUM = - Pattern.compile( - "durch (?:Artikel|Art\\.)[^.]{0,80}?vom (\\d{1,2})\\. (\\p{Lu}\\p{L}+) (\\d{4})"); - - private static final List<String> MONATSNAMEN = - List.of( - "Januar", - "Februar", - "März", - "April", - "Mai", - "Juni", - "Juli", - "August", - "September", - "Oktober", - "November", - "Dezember"); - - private static @Nullable LocalDate datum(String jahr, int monat, String tag) { - if (monat < 1 || monat > 12) { - return null; - } - try { - return LocalDate.of(Integer.parseInt(jahr), monat, Integer.parseInt(tag)); - } catch (RuntimeException e) { - return null; - } - } + Pattern.compile("durch (?:Artikel|Art\\.)[^.]{0,80}?vom " + DeutschesDatum.MUSTER); private static final List<String> STAMM_SUFFIXE = List.of("es", "er", "en", "em", "e", "s", "n"); diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/DeutschesDatum.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/DeutschesDatum.java new file mode 100644 index 0000000..6ecb161 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/DeutschesDatum.java @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2020 Matthias Andreas Benkard <code@mail.matthias.benkard.de> +// SPDX-License-Identifier: AGPL-3.0-or-later +package eu.mulk.aendggner.aenderung.parse; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Locale; +import org.jspecify.annotations.Nullable; + +/** + * Ausgeschriebene Daten, wie Gesetzestexte sie führen: „19. Juni 2020“. + * + * <p>Gebraucht an zwei Stellen, die nichts miteinander zu tun haben — die Altersrüge liest das + * Datum aus dem Einleitungssatz des Änderungsgesetzes, der {@link InkrafttretensLeser} liest es aus + * dessen Schlussartikel. Damit beide dieselbe Schreibweise verstehen, steht die Erkennung hier. + */ +public final class DeutschesDatum { + + /** Tag, Monatsname, Jahr — als Gruppen 1 bis 3. Ohne Anker; zum Einbetten gedacht. */ + public static final String MUSTER = "(\\d{1,2})\\. (\\p{Lu}\\p{L}+) (\\d{4})"; + + private static final DateTimeFormatter AUSGABE = + DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.GERMAN); + + private static final List<String> MONATSNAMEN = + List.of( + "Januar", + "Februar", + "März", + "April", + "Mai", + "Juni", + "Juli", + "August", + "September", + "Oktober", + "November", + "Dezember"); + + private DeutschesDatum() {} + + /** „19“, „Juni“, „2020“ → 2020-06-19; {@code null} bei unbekanntem Monat oder unmöglichem Tag. */ + public static @Nullable LocalDate lies(String tag, String monatsname, String jahr) { + int monat = MONATSNAMEN.indexOf(monatsname) + 1; + if (monat < 1) { + return null; + } + try { + return LocalDate.of(Integer.parseInt(jahr), monat, Integer.parseInt(tag)); + } catch (RuntimeException e) { + return null; + } + } + + /** 2020-06-19 → „19. Juni 2020“. */ + public static String schreibe(LocalDate datum) { + return datum.format(AUSGABE); + } +} diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/InkrafttretensLeser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/InkrafttretensLeser.java new file mode 100644 index 0000000..2892466 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/InkrafttretensLeser.java @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: 2020 Matthias Andreas Benkard <code@mail.matthias.benkard.de> +// SPDX-License-Identifier: AGPL-3.0-or-later +package eu.mulk.aendggner.aenderung.parse; + +import eu.mulk.aendggner.aenderung.Inkrafttreten; +import eu.mulk.aendggner.aenderung.Inkrafttreten.Punktbezug; +import eu.mulk.aendggner.aenderung.Inkrafttreten.Regel; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** + * Liest den Schlussartikel eines Änderungsgesetzes — den, der das Inkrafttreten regelt. + * + * <p>Er wird von der Artikelwahl des {@link AenderungsgesetzParser} nicht erfasst, denn er trägt + * keine Änderungsformel und nennt kein Stammgesetz. Gelesen werden muss er trotzdem: Ohne ihn weiß + * niemand, ab wann die errechnete Fassung gilt, und bei gestaffeltem Inkrafttreten gilt sie + * womöglich nie im ganzen. + */ +public final class InkrafttretensLeser { + + // Absatzmarke „(1) “ am Anfang oder nach Leerraum. + private static final Pattern ABSATZ = Pattern.compile("(?:^|(?<=\\s))\\((\\d+[a-z]?)\\)\\s+"); + + // „… tritt/treten … in Kraft.“ — das Außerkrafttreten bleibt außer Betracht (es sagt nichts + // darüber, wann die Änderung wirkt), deshalb der Ausschluss von „außer“. + private static final Pattern IN_KRAFT = + Pattern.compile("\\b(?:tritt|treten)\\b.{0,400}?(?<!außer )\\bin Kraft\\b", Pattern.DOTALL); + + private static final Pattern SATZ_ENDE = Pattern.compile("(?<!außer )\\bin Kraft\\b\\s*\\.?"); + + /** „am 1. Oktober 2024“, „mit Wirkung vom 18. November 2020“. */ + private static final Pattern DATUM = Pattern.compile("\\b(?:am|vom)\\s+" + DeutschesDatum.MUSTER); + + /** + * „Artikel 1 Nummer 11 Buchstabe a Doppelbuchstabe bb“ — ein Verweis des Schlussartikels auf + * einen Punkt des eigenen Gesetzes. Bayerische Änderungsgesetze gliedern sich in Paragraphen; + * deren Schlussvorschrift verweist entsprechend („§ 3 Nr. 2“). + */ + private static final Pattern PUNKTBEZUG = + Pattern.compile( + "(?:Artikel|Art\\.|§)\\s*(\\d+[a-z]?)" + + "(?:\\s+(?:Nummer|Nr\\.)\\s+(\\d+[a-z]?))?" + + "(?:\\s+Buchstabe\\s+(\\p{Ll})\\b)?" + + "(?:\\s+Doppelbuchstabe\\s+(\\p{Ll}{2})\\b)?"); + + private static final Pattern AENDERUNGSFORMEL = Pattern.compile("wird wie folgt geändert"); + + private InkrafttretensLeser() {} + + /** + * Sucht unter den Artikelblöcken den Schlussartikel und liest ihn. + * + * <p>Maßgeblich ist der <em>erste</em> Block ab {@code abIndex}, der vom Inkrafttreten handelt + * und selbst nichts ändert. Der Index ist der des letzten Artikels, der das Stammgesetz betrifft: + * Ein Gesetzblatt enthält mehrere Gesetze mit je eigener Schlussvorschrift, und maßgeblich ist + * die, die auf das eigene Gesetz folgt — nicht die eines fremden weiter vorn. + * + * @return {@code null}, wenn kein solcher Block da ist oder er keine lesbare Anordnung enthält. + */ + public static @Nullable Inkrafttreten waehle(List<String> blockTexte, int abIndex) { + for (int i = Math.max(abIndex, 0); i < blockTexte.size(); i++) { + var text = blockTexte.get(i); + if (AENDERUNGSFORMEL.matcher(text).find()) { + continue; + } + var gelesen = lies(text); + if (gelesen != null) { + return gelesen; + } + } + return null; + } + + /** Liest die Anordnungen eines einzelnen Blocks; {@code null}, wenn er keine trägt. */ + public static @Nullable Inkrafttreten lies(String blockText) { + var text = blockText.replaceAll("\\s+", " ").strip(); + var regeln = new ArrayList<Regel>(); + for (var absatz : teileInAbsaetze(text)) { + var regel = liesAbsatz(absatz); + if (regel != null) { + regeln.add(regel); + } + } + return regeln.isEmpty() ? null : new Inkrafttreten(List.copyOf(regeln)); + } + + /** + * Zerlegt den Schlussartikel an seinen Absatzmarken. Ohne Marken ist der ganze Text ein Absatz + * („Dieses Gesetz tritt am Tage nach seiner Verkündung in Kraft.“). + */ + private static List<String> teileInAbsaetze(String text) { + var marken = ABSATZ.matcher(text); + var grenzen = new ArrayList<Integer>(); + while (marken.find()) { + grenzen.add(marken.start()); + } + if (grenzen.isEmpty()) { + return List.of(text); + } + var absaetze = new ArrayList<String>(grenzen.size()); + for (int i = 0; i < grenzen.size(); i++) { + int ende = i + 1 < grenzen.size() ? grenzen.get(i + 1) : text.length(); + absaetze.add(text.substring(grenzen.get(i), ende)); + } + return absaetze; + } + + private static @Nullable Regel liesAbsatz(String absatz) { + if (!IN_KRAFT.matcher(absatz).find()) { + return null; + } + // Der Anordnungssatz endet bei „in Kraft“; was danach steht, gehört nicht mehr dazu (im GEG + // klebt der Schlusssatz über die Rechte des Bundesrates unmittelbar an ihm). + var ende = SATZ_ENDE.matcher(absatz); + var satz = ende.find() ? absatz.substring(0, ende.end()).strip() : absatz.strip(); + // Die Absatzmarke selbst gehört nicht zum Wortlaut der Anordnung. + var wortlaut = ABSATZ.matcher(satz).replaceFirst("").strip(); + + var datumTreffer = DATUM.matcher(satz); + var datum = + datumTreffer.find() + ? DeutschesDatum.lies( + datumTreffer.group(1), datumTreffer.group(2), datumTreffer.group(3)) + : null; + + return new Regel(datum, wortlaut, bezuege(satz)); + } + + /** + * Die Punkte, die der Satz benennt. Nennt er keine, so ist es die Grundregel („Dieses Gesetz + * tritt … in Kraft“) — der Vorbehalt zugunsten späterer Absätze („vorbehaltlich des Absatzes 2“) + * verweist auf den Schlussartikel selbst und ist kein Punktbezug. + */ + private static List<Punktbezug> bezuege(String satz) { + var bezuege = new ArrayList<Punktbezug>(); + var treffer = PUNKTBEZUG.matcher(satz); + while (treffer.find()) { + var pfad = new StringBuilder(); + if (treffer.group(2) != null) { + pfad.append(treffer.group(2)).append('.'); + if (treffer.group(3) != null) { + pfad.append(' ').append(treffer.group(3)).append(')'); + if (treffer.group(4) != null) { + pfad.append(' ').append(treffer.group(4)).append(')'); + } + } + } + bezuege.add(new Punktbezug(treffer.group(1), pfad.toString())); + } + return List.copyOf(bezuege); + } +} diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java index 677b0ee..cccba63 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -46,7 +46,9 @@ public final class BefehlAnwender { public enum Status { ANGEWANDT, - MANUELL_PRUEFEN + MANUELL_PRUEFEN, + /** Der Befehl gehört zu einem Teil des Änderungsgesetzes, der am Stichtag noch nicht galt. */ + NICHT_IN_KRAFT } /** diff --git a/src/main/java/eu/mulk/aendggner/anwendung/Grund.java b/src/main/java/eu/mulk/aendggner/anwendung/Grund.java index 065567c..d463846 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/Grund.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/Grund.java @@ -41,7 +41,10 @@ public enum Grund { NICHT_UNTERSTUETZT("Nicht unterstützt"), /** Die Anwendung ist mit einem Fehler abgebrochen. */ - FEHLGESCHLAGEN("Anwendung fehlgeschlagen"); + FEHLGESCHLAGEN("Anwendung fehlgeschlagen"), + + /** Der Befehl war am gewählten Stichtag noch nicht in Kraft; angewandt wurde er deshalb nicht. */ + NOCH_NICHT_IN_KRAFT("Noch nicht in Kraft"); private final String bezeichnung; diff --git a/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java b/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java index 9b8f406..d2586c2 100644 --- a/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java +++ b/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java @@ -3,6 +3,7 @@ package eu.mulk.aendggner.synopse; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl; +import eu.mulk.aendggner.aenderung.parse.DeutschesDatum; import eu.mulk.aendggner.anwendung.BefehlAnwender; import eu.mulk.aendggner.anwendung.Grund; import eu.mulk.aendggner.gesetz.Norm; @@ -98,6 +99,19 @@ public final class HtmlRenderer { + " geänderte Normen, " + synopse.manuellZuPruefen().size() + " manuell zu prüfende Befehle"); + if (synopse.inkrafttreten() != null) { + var grundregel = synopse.inkrafttreten().grundregel(); + if (grundregel.isPresent()) { + vorspannZeile( + sb, + "Inkrafttreten", + grundregel.get().anzeige() + + (synopse.inkrafttreten().gestaffelt() ? " (gestaffelt, siehe unten)" : "")); + } + } + if (synopse.stichtag() != null) { + vorspannZeile(sb, "Stichtag", DeutschesDatum.schreibe(synopse.stichtag())); + } vorspannZeile(sb, "Geltung", entwurfsfassung ? "nichtamtlich, Entwurfsstand" : "nichtamtlich"); sb.append("</dl>\n"); } @@ -219,7 +233,9 @@ public final class HtmlRenderer { } private static void rendereManuellZuPruefen(StringBuilder sb, Synopse synopse) { - if (synopse.manuellZuPruefen().isEmpty() && synopse.warnungen().isEmpty()) { + if (synopse.manuellZuPruefen().isEmpty() + && synopse.warnungen().isEmpty() + && synopse.nichtInKraft().isEmpty()) { return; } sb.append("<section class=\"manuell\">\n<h2>Manuell prüfen</h2>\n"); @@ -264,6 +280,23 @@ public final class HtmlRenderer { sb.append("</ol>\n"); } } + if (!synopse.nichtInKraft().isEmpty()) { + // Nicht angewandt, aber auch nicht zu beanstanden: Diese Befehle galten am Stichtag noch + // nicht. Sie stehen deshalb für sich und nicht unter den Gründen des Scheiterns. + sb.append("<h3>Am Stichtag noch nicht in Kraft <span class=\"anzahl\">") + .append(synopse.nichtInKraft().size()) + .append("</span></h3>\n<ol>\n"); + for (var eintrag : synopse.nichtInKraft()) { + sb.append("<li><strong>") + .append(esc(eintrag.befehl().provenienz().anzeigeText())) + .append("</strong> — ") + .append(esc(eintrag.begruendung())) + .append("<br><span class=\"originaltext\">") + .append(esc(eintrag.befehl().provenienz().originalText())) + .append("</span></li>\n"); + } + sb.append("</ol>\n"); + } sb.append("</section>\n"); } diff --git a/src/main/java/eu/mulk/aendggner/synopse/Synopse.java b/src/main/java/eu/mulk/aendggner/synopse/Synopse.java index 489366e..91f5482 100644 --- a/src/main/java/eu/mulk/aendggner/synopse/Synopse.java +++ b/src/main/java/eu/mulk/aendggner/synopse/Synopse.java @@ -2,21 +2,53 @@ // SPDX-License-Identifier: AGPL-3.0-or-later package eu.mulk.aendggner.synopse; +import eu.mulk.aendggner.aenderung.Inkrafttreten; import eu.mulk.aendggner.anwendung.BefehlAnwender.AngewandteAenderung; import eu.mulk.aendggner.gesetz.Gesetz; import eu.mulk.aendggner.gesetz.Gliederung; import eu.mulk.aendggner.gesetz.Norm; +import java.time.LocalDate; import java.util.List; import org.jspecify.annotations.Nullable; -/** Eine Gegenüberstellung alter und neuer Fassung, normweise. */ +/** + * Eine Gegenüberstellung alter und neuer Fassung, normweise. + * + * @param inkrafttreten was der Schlussartikel des Änderungsgesetzes anordnet; {@code null}, wo + * keiner gelesen werden konnte. + * @param stichtag der Tag, dessen Fassung gezeigt wird; {@code null} heißt: alle Befehle, ohne + * Rücksicht auf das Inkrafttreten. + * @param nichtInKraft die Befehle, die am Stichtag noch nicht galten und deshalb unterblieben. + */ public record Synopse( Gesetz alt, Gesetz neu, List<Eintrag> eintraege, List<GliederungsAenderung> gliederungsAenderungen, List<AngewandteAenderung> manuellZuPruefen, - List<String> warnungen) { + List<String> warnungen, + @Nullable Inkrafttreten inkrafttreten, + @Nullable LocalDate stichtag, + List<AngewandteAenderung> nichtInKraft) { + + public Synopse( + Gesetz alt, + Gesetz neu, + List<Eintrag> eintraege, + List<GliederungsAenderung> gliederungsAenderungen, + List<AngewandteAenderung> manuellZuPruefen, + List<String> warnungen) { + this( + alt, + neu, + eintraege, + gliederungsAenderungen, + manuellZuPruefen, + warnungen, + null, + null, + List.of()); + } /** Eine geänderte Gliederungs-Überschrift (Teil/Abschnitt/…); {@code alt == null} bei neuen. */ public record GliederungsAenderung(@Nullable Gliederung alt, Gliederung neu) {} diff --git a/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java b/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java index bfe7df3..5dd65cd 100644 --- a/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java +++ b/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java @@ -2,11 +2,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later package eu.mulk.aendggner.synopse; +import eu.mulk.aendggner.aenderung.Inkrafttreten; import eu.mulk.aendggner.anwendung.BefehlAnwender.AnwendungsErgebnis; import eu.mulk.aendggner.anwendung.BefehlAnwender.Status; import eu.mulk.aendggner.gesetz.Gesetz; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; +import org.jspecify.annotations.Nullable; /** Paart die Normen alter und neuer Fassung zu Synopse-Einträgen. */ public final class SynopseBuilder { @@ -21,6 +24,20 @@ public final class SynopseBuilder { */ public static Synopse baue( Gesetz alt, AnwendungsErgebnis anwendung, List<String> parseWarnungen, boolean vollstaendig) { + return baue(alt, anwendung, parseWarnungen, vollstaendig, null, null); + } + + /** + * @param inkrafttreten was der Schlussartikel des Änderungsgesetzes anordnet, sofern gelesen. + * @param stichtag der Tag, dessen Fassung gezeigt wird; {@code null} = ohne Rücksicht darauf. + */ + public static Synopse baue( + Gesetz alt, + AnwendungsErgebnis anwendung, + List<String> parseWarnungen, + boolean vollstaendig, + @Nullable Inkrafttreten inkrafttreten, + @Nullable LocalDate stichtag) { var neu = anwendung.neu(); var eintraege = new ArrayList<Synopse.Eintrag>(); @@ -51,9 +68,19 @@ public final class SynopseBuilder { var manuell = anwendung.protokoll().stream().filter(a -> a.status() == Status.MANUELL_PRUEFEN).toList(); + var nichtInKraft = + anwendung.protokoll().stream().filter(a -> a.status() == Status.NICHT_IN_KRAFT).toList(); return new Synopse( - alt, neu, eintraege, gliederungsAenderungen(alt, neu), manuell, parseWarnungen); + alt, + neu, + eintraege, + gliederungsAenderungen(alt, neu), + manuell, + parseWarnungen, + inkrafttreten, + stichtag, + nichtInKraft); } /** |
