diff options
Diffstat (limited to 'src/main/java')
6 files changed, 390 insertions, 70 deletions
diff --git a/src/main/java/eu/mulk/aendggner/Pipeline.java b/src/main/java/eu/mulk/aendggner/Pipeline.java index fee0ce9..0fdfa91 100644 --- a/src/main/java/eu/mulk/aendggner/Pipeline.java +++ b/src/main/java/eu/mulk/aendggner/Pipeline.java @@ -15,6 +15,7 @@ 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.anwendung.InhaltsuebersichtsProbe; import eu.mulk.aendggner.anwendung.Nachfassungsabgleich; import eu.mulk.aendggner.gesetz.Fortschreibung; import eu.mulk.aendggner.gesetz.Gesetz; @@ -215,6 +216,9 @@ public final class Pipeline { if (stichtag == null && inkrafttreten != null && inkrafttreten.gestaffelt()) { warnungen.add(staffelungsWarnung(inkrafttreten)); } + // Erst nach allen Heften: Ob die Inhaltsübersicht dem Text folgt, steht am Ende fest und nicht + // nach jedem einzelnen Schritt. + warnungen.addAll(InhaltsuebersichtsProbe.pruefe(altesGesetz, gesetz)); var gesamtErgebnis = new BefehlAnwender.AnwendungsErgebnis(gesetz, protokoll); var synopse = SynopseBuilder.baue( diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/InkrafttretensLeser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/InkrafttretensLeser.java index ee62205..966917b 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/InkrafttretensLeser.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/InkrafttretensLeser.java @@ -39,11 +39,7 @@ public final class InkrafttretensLeser { * 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)?"); + Pattern.compile("(?:Artikel|Art\\.|§)\\s*(\\d+[a-z]?)" + PunktPfad.MUSTER); private static final Pattern VERB = Pattern.compile("\\b(?:tritt|treten)\\b"); @@ -164,17 +160,7 @@ public final class InkrafttretensLeser { 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())); + bezuege.add(new Punktbezug(treffer.group(1), PunktPfad.baue(treffer, 1))); } return List.copyOf(bezuege); } diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/PunktPfad.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/PunktPfad.java new file mode 100644 index 0000000..17792e2 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/PunktPfad.java @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de> +// SPDX-License-Identifier: AGPL-3.0-or-later +package eu.mulk.aendggner.aenderung.parse; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Die Gliederungspunkte, mit denen ein Änderungsgesetz auf sich selbst verweist: „Nummer 11 + * Buchstabe a Doppelbuchstabe bb“, abgekürzt auch „Nr. 8 Buchst. a“. + * + * <p>Zwei Stellen brauchen dieselbe Lesart, und beide meinen dasselbe Gebilde: der Schlussartikel, + * der das Inkrafttreten eines einzelnen Punktes anordnet ({@link InkrafttretensLeser}), und der + * verweisende Befehl, der einen anderen Punkt desselben Artikels sinngemäß übernimmt ({@code + * Aenderungsbefehl.VerweisenderBefehl}). Geschrieben wird der Pfad so, wie {@link + * eu.mulk.aendggner.aenderung.Provenienz#gliederungsPfad()} ihn führt — „8. a) bb)“ —, denn an ihm + * wird verglichen. + */ +public final class PunktPfad { + + /** + * Nummer, Buchstabe und Doppelbuchstabe als Gruppen 1 bis 3. Ohne Anker; zum Einbetten gedacht, + * weshalb die Zählung der Gruppen beim Einbetten um den vorangehenden Teil zu versetzen ist. + */ + public static final String MUSTER = + "(?:\\s*(?:Nummer|Nr\\.)\\s+(\\d+[a-z]?))?" + + "(?:\\s*(?:Buchstabe|Buchst\\.)\\s+(\\p{Ll})\\b)?" + + "(?:\\s*(?:Doppelbuchstabe|Doppelbuchst\\.)\\s+(\\p{Ll}{2})\\b)?"; + + private static final Pattern GANZ = Pattern.compile("^" + MUSTER + "\\s*$"); + + private PunktPfad() {} + + /** + * Der Pfad in der Schreibweise der Provenienz; leer, wenn der Text keinen Punkt nennt. Leer heißt + * dabei: der ganze Artikel — nicht etwa „nichts gefunden“; wer das unterscheiden muss, prüfe den + * Text zuvor. + */ + public static String aus(String text) { + var m = GANZ.matcher(text.strip()); + return m.matches() ? baue(m, 0) : ""; + } + + /** + * Baut den Pfad aus einem Treffer, dessen Gruppen {@code versatz + 1} bis {@code versatz + 3} die + * Nummer, den Buchstaben und den Doppelbuchstaben tragen. + */ + public static String baue(Matcher treffer, int versatz) { + var pfad = new StringBuilder(); + if (treffer.group(versatz + 1) != null) { + pfad.append(treffer.group(versatz + 1)).append('.'); + if (treffer.group(versatz + 2) != null) { + pfad.append(' ').append(treffer.group(versatz + 2)).append(')'); + if (treffer.group(versatz + 3) != null) { + pfad.append(' ').append(treffer.group(versatz + 3)).append(')'); + } + } + } + return pfad.toString(); + } +} diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java index 8d9b7e7..2dfb7db 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -25,6 +25,7 @@ import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautVoranstellung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautZuAbsatz; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautZuSatz; import eu.mulk.aendggner.aenderung.Stelle; +import eu.mulk.aendggner.aenderung.parse.PunktPfad; import eu.mulk.aendggner.gesetz.Absatz; import eu.mulk.aendggner.gesetz.Gesetz; import eu.mulk.aendggner.gesetz.Gliederung; @@ -109,6 +110,14 @@ public final class BefehlAnwender { ergebnisse[index] = angewandt(schritt.teil(), "(Gesetzesüberschrift)"); continue; } + // Der verweisende Befehl braucht die Befehlsliste — er nimmt einen anderen Punkt desselben + // Artikels in Bezug —, und die hat nur diese Schleife. Er steht deshalb hier und nicht in der + // Weiche. Vorausgesetzt ist, dass der verwiesene Punkt bereits vollzogen ist; das trägt die + // Schritt-Ordnung, denn ein Verweis auf einen „vorstehenden“ Punkt steht hinter ihm. + if (schritt.ganzerBefehl() && schritt.teil() instanceof VerweisenderBefehl v) { + ergebnisse[index] = fuehreVerweisungAus(normen, befehle, v); + continue; + } var ergebnis = wendeAn(normen, gliederungen, schritt.teil()); if (ergebnis.status() == Status.MANUELL_PRUEFEN && ergebnis.grund() == Grund.MEHRDEUTIG @@ -658,6 +667,103 @@ public final class BefehlAnwender { }; } + /** + * Führt den verweisenden Befehl aus: „Die Inhaltsübersicht wird entsprechend der vorstehenden + * Nummer 8 Buchst. a geändert.“ + * + * <p>Ausgeführt wird nicht der Wortlaut des verwiesenen Punktes, sondern sein Ergebnis. Jener + * ändert die Überschrift eines Paragraphen; die Angabe der Inhaltsübersicht wird alsdann auf den + * Titel gesetzt, den der Paragraph nunmehr trägt. Das ist genau, was „entsprechend“ meint, und es + * erspart, jede Befehlsform ein zweites Mal auf dem Zeilenmodell nachzubilden. + * + * <p>Was der Verweis nicht trägt, bleibt liegen und wird benannt: ein Punkt, der sich nicht + * findet, und ein Punkt, der etwas anderes als eine Überschrift ändert. Für letzteren wäre die + * Übertragung zu erraten — die Inhaltsübersicht führt allein Bezeichnung und Überschrift, und was + * im Absatz eines Paragraphen geschieht, hat in ihr kein Gegenstück. + */ + private static AngewandteAenderung fuehreVerweisungAus( + List<Norm> normen, List<Aenderungsbefehl> befehle, VerweisenderBefehl verweisung) { + var pfad = PunktPfad.aus(verweisung.verweis()); + if (pfad.isEmpty()) { + return manuell( + verweisung, + Grund.NICHT_UNTERSTUETZT, + "Der Verweis auf „" + + verweisung.verweis() + + "“ nennt keinen Gliederungspunkt, dem sich ein Befehl zuordnen ließe."); + } + var verwiesene = new ArrayList<Aenderungsbefehl>(); + for (var befehl : befehle) { + if (trifftPfad(befehl, verweisung, pfad)) { + if (befehl instanceof Sammelbefehl s) { + verwiesene.addAll(s.teilbefehle()); + } else { + verwiesene.add(befehl); + } + } + } + if (verwiesene.isEmpty()) { + return manuell( + verweisung, + Grund.STELLE_NICHT_AUFLOESBAR, + "Der verwiesene Punkt „" + + verweisung.verweis() + + "“ findet sich nicht im selben Artikel."); + } + var betroffen = new LinkedHashSet<String>(); + for (var befehl : verwiesene) { + if (!befehl.stelle().betrifftUeberschrift()) { + return manuell( + verweisung, + Grund.NICHT_UNTERSTUETZT, + "Der verwiesene Punkt „" + + verweisung.verweis() + + "“ ändert keine Überschrift; nur deren Änderung hat in der Inhaltsübersicht ein" + + " Gegenstück."); + } + var paragraph = befehl.stelle().paragraph().orElse(null); + if (paragraph == null) { + return manuell( + verweisung, + Grund.STELLE_NICHT_AUFLOESBAR, + "Der verwiesene Punkt „" + verweisung.verweis() + "“ nennt keinen Paragraphen."); + } + int index = StellenAufloeser.normIndex(gesetzAus(normen), paragraph.enbez()); + if (index < 0) { + return manuell( + verweisung, + Grund.BESTAND_WIDERSPRICHT, + "Das Gesetz führt keinen " + paragraph.enbez() + "."); + } + var titel = normen.get(index).titel(); + if (titel == null || titel.isBlank()) { + return manuell( + verweisung, + Grund.BESTAND_WIDERSPRICHT, + paragraph.enbez() + " trägt keine Überschrift, die nachzuführen wäre."); + } + var ergebnis = + InhaltsuebersichtAnwender.fuehreTitelNach(normen, verweisung, paragraph, titel); + if (ergebnis.status() != Status.ANGEWANDT) { + return ergebnis; + } + betroffen.add(paragraph.enbez()); + } + return new AngewandteAenderung( + verweisung, Status.ANGEWANDT, "", new LinkedHashSet<>(List.of("Inhaltsübersicht")), null); + } + + /** Ob der Befehl an dem Punkt steht, auf den die Verweisung zielt (oder unterhalb seiner). */ + private static boolean trifftPfad( + Aenderungsbefehl befehl, VerweisenderBefehl verweisung, String pfad) { + var herkunft = befehl.provenienz(); + if (!herkunft.artikel().equals(verweisung.provenienz().artikel())) { + return false; + } + var eigener = herkunft.gliederungsPfad(); + return eigener.equals(pfad) || eigener.startsWith(pfad + " "); + } + private static boolean istNurUeberschrift(Stelle stelle) { return stelle.komponenten().size() == 1 && stelle.komponenten().get(0) instanceof Stelle.Ueberschrift; @@ -668,19 +774,17 @@ public final class BefehlAnwender { if (befehl instanceof UnbekannterBefehl) { return manuell(befehl, Grund.NICHT_ERKANNT, "Befehl nicht erkannt."); } - // Gelesen, aber bewusst nicht ausgeführt. Die Weiche steht vor der Inhaltsübersichts-Weiche, - // damit die Rüge den Verweis nennt und nicht bloß die allgemeine Grenze jener Norm: Die - // sinngemäße Übertragung eines anderen Punktes setzte einen Rückgriff auf die Befehlsliste und - // eine Umdeutung des Ziels voraus („Überschrift des § 13“ → Titelspalte der Übersichtszeile), - // die dieser Anwender nicht leistet. Sie zu erraten wäre schlimmer, als sie zu benennen. + // Ausgeführt wird der verweisende Befehl in der Schleife von anwenden(), die allein die + // Befehlsliste kennt. Hierher gerät er nur als Teil eines Sammelbefehls oder wenn ihn jemand + // einzeln anwendet; dann fehlt der Bezugspunkt, und das ist zu sagen. if (befehl instanceof VerweisenderBefehl v) { return manuell( befehl, - Grund.NICHT_UNTERSTUETZT, + Grund.STELLE_NICHT_AUFLOESBAR, "Der Befehl verweist auf „" + v.verweis() - + "“ desselben Artikels; die sinngemäße Übertragung auf die Inhaltsübersicht ist" - + " nicht umgesetzt."); + + "“ desselben Artikels; die übrigen Befehle des Artikels liegen an dieser Stelle" + + " nicht vor."); } // Sammelbefehle vor den Spezialweichen dispatchen (jeder Teil wird einzeln geroutet). if (befehl instanceof Sammelbefehl s) { @@ -2711,12 +2815,21 @@ public final class BefehlAnwender { // Eine §-Überschrift beginnt mit „§ N“, gefolgt von einem großgeschriebenen Titelwort — im // Gegensatz zu Querverweisen wie „§ 71 Absatz 1“ oder „§§ 42 bis 45“. Die Negativliste schließt // die Untergliederungs- und Verbindungswörter aus, sodass an solchen Stellen nicht getrennt wird. + // Ein Normkopf steht am Anfang einer Zeile oder wenigstens am Anfang eines Satzes. Ohne diese + // Bedingung zerschnitte ein Querverweis mitten im Satz den Block: „… mit Systemen für die + // Gebäudeautomatisierung nach § 71a Projektunterlagen in überprüfbarer Form vorzulegen.“ trägt + // hinter dem Verweis ein großgeschriebenes Wort und sähe damit aus wie eine Überschrift. Der + // Wortbestand unterscheidet beide nicht — die Stellung tut es. Der Satzanfang muss neben dem + // Zeilenanfang gelten, weil ein Zitat auch flach ankommen kann („§ 1a Erstes Neu (1) Inhalt + // eins. § 1b Zweites Neu …“). private static final Pattern PARAGRAPH_UEBERSCHRIFT = Pattern.compile( - "(?=(?:§|Art\\.)\\s*\\d+[a-z]?\\s+" + "(?:^[ \\t]*|(?<=[.:!?] )|(?<=[.:!?]“ ))" + + "(?=(?:§|Art\\.)\\s*\\d+[a-z]?(?:[ \\t]*$|\\s+" + "(?!Absatz|Absätze|Abs|Satz|Sätze|Nummer|Nummern|Nr|Buchstabe|Buchstaben|Buchst" + "|und|bis|oder|sowie|des|der|dieses|genannten)" - + "\\p{Lu})"); + + "\\p{Lu}))", + Pattern.MULTILINE); /** * Zerlegt einen Zitatblock mehrerer Paragraphen an den §-Überschriften (nicht an Querverweisen) @@ -2831,12 +2944,25 @@ public final class BefehlAnwender { int i = von + 1; while (i < zeilen.size() && !zeilen.get(i).isEmpty() - && Character.isLowerCase(zeilen.get(i).codePointAt(0))) { + && (Character.isLowerCase(zeilen.get(i).codePointAt(0)) + || UNVOLLENDET.matcher(zeilen.get(i - 1)).find())) { i++; } return i; } + /** + * Eine Zeile, die so endet, ist nicht zu Ende: Kein Normtitel schließt mit einem Binde- oder + * Verhältniswort. Die Großschreibung der Folgezeile besagt dann nichts — „… in elektrischen + * Wärmepumpen und“ / „Wärmepumpen-Hybridheizungen“ ist eine Überschrift, die der Satz am + * Spaltenrand umbrochen hat, und keine zwei Sachen. + */ + private static final Pattern UNVOLLENDET = + Pattern.compile( + "(?:\\b(?:und|oder|sowie|mit|von|vom|zu|zur|zum|in|im|an|am|auf|für|bei|beim|über|unter" + + "|nach|aus|durch|gegen|ohne|des|der|die|das|dem|den|eines|einer|einem|einen)|-)$", + Pattern.CASE_INSENSITIVE); + /** Zerlegt zitierten Text in Absätze anhand der „(n)“-Marker. */ static List<Absatz> parseAbsaetze(String zitat) { var text = zitat.strip(); diff --git a/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java index 9284160..ecf1bdd 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java @@ -105,6 +105,22 @@ final class InhaltsuebersichtAnwender { } /** + * Führt die Angabe einer Norm ihrer Überschrift nach: Die Zeile der Inhaltsübersicht, die den + * Paragraphen führt, wird auf den jetzigen Titel gesetzt. + * + * <p>Das ist die Ausführung des verweisenden Befehls („Die Inhaltsübersicht wird entsprechend der + * vorstehenden Nummer 8 Buchst. a geändert“). Übertragen wird nicht der Wortlaut jenes Punktes, + * sondern sein Ergebnis: Was die Überschrift nach seiner Anwendung besagt, besagt fortan auch die + * Angabe. Der Umweg über das Ergebnis erspart es, jede Befehlsform ein zweites Mal auf dem + * Zeilenmodell nachzubilden — und er trifft genau das, was der Verweis meint. + */ + static AngewandteAenderung fuehreTitelNach( + List<Norm> normen, Aenderungsbefehl befehl, Stelle.Paragraph paragraph, String neuerTitel) { + var ziel = List.<Stelle.Komponente>of(paragraph); + return ersetzeZeilen(normen, befehl, ziel, ziel, paragraph.enbez() + " " + neuerTitel); + } + + /** * Ersetzt die Zeilen von {@code von} bis {@code bis} durch die Angaben des Zitats (oder nichts). */ private static AngewandteAenderung ersetzeZeilen( @@ -308,23 +324,7 @@ final class InhaltsuebersichtAnwender { "Das Zitat der neuen Inhaltsübersicht enthält Befehlstext — vermutlich ist ein" + " Anführungszeichen unbalanciert; bitte manuell prüfen."); } - var zeilen = new ArrayList<String>(); - for (var stueck : UEBERSICHT_MARKE.split(flach)) { - var s = stueck.strip(); - if (s.isEmpty()) { - continue; - } - var m = - Pattern.compile( - "^((?:Teil|Abschnitt|Unterabschnitt|Kapitel|Buch)\\s+\\d+[a-z]?|Anhang" - + "|(?:§|Art\\.)\\s*\\d+[a-z]*)\\s*(.*)$") - .matcher(s); - if (m.matches() && !m.group(2).isEmpty()) { - zeilen.add(m.group(1) + " | " + m.group(2)); - } else { - zeilen.add(s); - } - } + var zeilen = uebersichtsZeilen(flach, ""); if (zeilen.size() < 2) { return manuell( befehl, Grund.ZITAT_UNBRAUCHBAR, "Das Zitat enthält keine erkennbare Inhaltsübersicht."); @@ -348,40 +348,47 @@ final class InhaltsuebersichtAnwender { + "|(?<!\\S)Anhang(?!\\S))"); /** - * Zerlegt das Zitat in Angabe-Zeilen: bei §-Angaben eine Zeile je Paragraph (im Zeilenformat der - * Inhaltsübersicht, „§ N | Titel“), sonst eine einzelne Zeile. + * Zerlegt das Zitat in Angabe-Zeilen im Zeilenmodell der Inhaltsübersicht („§ N | Titel“). + * + * <p>Getrennt wird an <em>allen</em> Zeilenanfängen, die eine Übersicht kennt — an §-Angaben wie + * an Gliederungsmarken. Beides ist nötig: Ein Zitat, das mit „Unterabschnitt 4 …“ beginnt und + * darauf zwanzig Paragraphen aufführt, bliebe sonst eine einzige Zeile, und jede spätere Angabe + * zu einem dieser Paragraphen fände sie nicht mehr. (Das GEG-Heft von 2023 tut genau das.) */ private static List<String> angabenZeilen(String zitat, String einrueckung) { - var flach = zitat.strip().replaceAll("\\s+", " "); - var zeilen = new ArrayList<String>(); - if (flach.startsWith("§") || flach.startsWith("Art.")) { - for (var stueck : PARAGRAPH_ANGABE.split(flach)) { - var s = stueck.strip(); - if (s.isEmpty()) { - continue; - } - var m = Pattern.compile("^((?:§|Art\\.)\\s*\\d+[a-z]*)\\s*(.*)$").matcher(s); - if (m.matches() && !m.group(2).isEmpty()) { - zeilen.add(einrueckung + m.group(1) + " | " + m.group(2)); - } else { - zeilen.add(einrueckung + s); - } - } - } + var zeilen = uebersichtsZeilen(zitat.strip().replaceAll("\\s+", " "), einrueckung); if (zeilen.isEmpty()) { - zeilen.add(einrueckung + flach); + zeilen.add(einrueckung + zitat.strip().replaceAll("\\s+", " ")); } return zeilen; } - // Trennt einen Block mehrerer §-Angaben an den §-Anfängen; Querverweise („… zu § 3 Absatz 3“) - // trennen nicht (nach ihnen folgt ein Kleinwort oder eine Strukturangabe statt eines Titels). - private static final Pattern PARAGRAPH_ANGABE = + /** Der Kopf einer Übersichtszeile: die Bezeichnung, hinter der der Titel steht. */ + private static final Pattern ZEILEN_KOPF = Pattern.compile( - "(?=(?:§|Art\\.)\\s*\\d+[a-z]?\\s+" - + "(?!Absatz|Absätze|Abs|Satz|Sätze|Nummer|Nummern|Nr|Buchstabe|Buchstaben" - + "|und|bis|oder|sowie|des|der|dieses)" - + "(?:\\(|\\p{Lu}))"); + "^((?:Teil|Abschnitt|Unterabschnitt|Kapitel|Buch)\\s+\\d+[a-z]?|Anhang" + + "|(?:§|Art\\.)\\s*\\d+[a-z]*)\\s*(.*)$"); + + /** + * Der flache Zitattext, zerlegt in Übersichtszeilen. Die Trennung besorgt {@link + * #UEBERSICHT_MARKE}; wo eine Bezeichnung einen Titel bei sich führt, tritt der Strich zwischen + * beide. + */ + private static List<String> uebersichtsZeilen(String flach, String einrueckung) { + var zeilen = new ArrayList<String>(); + for (var stueck : UEBERSICHT_MARKE.split(flach)) { + var s = stueck.strip(); + if (s.isEmpty()) { + continue; + } + var m = ZEILEN_KOPF.matcher(s); + zeilen.add( + m.matches() && !m.group(2).isEmpty() + ? einrueckung + m.group(1) + " | " + m.group(2) + : einrueckung + s); + } + return zeilen; + } private static List<String> zeilenVon(Norm norm, int absatzIndex) { return norm.absaetze().get(absatzIndex).text().lines().toList(); diff --git a/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtsProbe.java b/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtsProbe.java new file mode 100644 index 0000000..54ba54f --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtsProbe.java @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de> +// SPDX-License-Identifier: AGPL-3.0-or-later +package eu.mulk.aendggner.anwendung; + +import eu.mulk.aendggner.gesetz.Gesetz; +import eu.mulk.aendggner.gesetz.Norm; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** + * Hält die Inhaltsübersicht gegen den Bestand der Normen — aber nur dort, wo dieser Lauf ihn + * angerührt hat. + * + * <p>Ein Änderungsgesetz, das Paragraphen einfügt, aufhebt, umnummeriert oder ihre Überschriften + * neu fasst, muss die Angaben der Inhaltsübersicht eigens mitändern; das Handbuch der + * Rechtsförmlichkeit verlangt dafür eigene Befehle. Bleiben sie aus oder greifen sie nicht, so + * bleibt die Übersicht hinter dem Text zurück, ohne dass ein einziger Befehl liegenbliebe — ein + * Fehler, den keine Zahl des Protokolls anzeigt. + * + * <p>Geprüft wird deshalb <em>der Unterschied</em>: allein die Normen, deren Bezeichnung oder + * Überschrift dieser Lauf verändert hat. Die Übersicht einer fremden Quelle mag im Übrigen ungenau + * sein — das ist ein Befund über die Quelle und nicht über den Lauf, und ihn hier zu rügen hieße, + * den Leser mit Fremdem zu behelligen. + * + * <p>Geheilt wird nichts. Die Übersicht ist eine Norm wie jede andere; sie ohne Befehl + * fortzuschreiben hieße, Recht zu erfinden. + */ +public final class InhaltsuebersichtsProbe { + + private static final String ENBEZ = "Inhaltsübersicht"; + + /** Eine Norm, die in der Übersicht ein Gegenstück haben kann: der Paragraph, der Artikel. */ + private static final Pattern EIGENE_NORM = Pattern.compile("^(?:§|Art\\.)\\s*\\d+[a-z]?$"); + + /** Eine Angabezeile im Zeilenmodell der Übersicht: „§ 5 | Titel“. */ + private static final Pattern ANGABE = + Pattern.compile("^\\s*((?:§|Art\\.)\\s*\\d+[a-z]?)\\s*\\|\\s*(.*)$"); + + private InhaltsuebersichtsProbe() {} + + /** + * Die Rügen, die der Vergleich ergibt; leer, wenn alles stimmt oder das Gesetz keine + * Inhaltsübersicht führt. + */ + public static List<String> pruefe(Gesetz alt, Gesetz neu) { + var uebersicht = neu.norm(ENBEZ).orElse(null); + if (uebersicht == null) { + return List.of(); + } + var angaben = angaben(uebersicht); + var vorher = titelJeNorm(alt); + var ruegen = new ArrayList<String>(); + for (var norm : neu.normen()) { + if (norm.weggefallen() || !EIGENE_NORM.matcher(norm.enbez()).matches()) { + // Die Übersicht führt Paragraphen; Anlagen, ihre Nummern und die Übersicht selbst stehen + // nicht in ihr. Sie hier zu vermissen hieße, ihr etwas abzuverlangen, was sie nie leistet. + continue; + } + var altTitel = vorher.get(norm.enbez()); + boolean neuHinzu = !vorher.containsKey(norm.enbez()); + boolean titelGeaendert = altTitel != null && !gleich(altTitel, norm.titel()); + if (!neuHinzu && !titelGeaendert) { + continue; + } + if (!angaben.containsKey(norm.enbez())) { + ruegen.add( + "Die Inhaltsübersicht führt keine Angabe zu " + + norm.enbez() + + ", obgleich dieser Lauf die Norm " + + (neuHinzu ? "eingefügt" : "geändert") + + " hat. Ein Angabe-Befehl, der das nachholte, ist nicht ersichtlich."); + continue; + } + if (!gleich(angaben.get(norm.enbez()), norm.titel())) { + // Gesagt wird der Befund, nicht seine Ursache: Es kann am fehlenden Angabe-Befehl liegen — + // es kommt aber auch vor, dass die amtliche Fassung selbst beide verschieden führt (so das + // GEG bei § 71k: „Erdgas“ in der Übersicht, „Gas“ in der Überschrift). + ruegen.add( + "Die Angabe zu " + + norm.enbez() + + " und ihre Überschrift gehen auseinander: „" + + angaben.get(norm.enbez()) + + "“ gegen „" + + (norm.titel() == null ? "" : norm.titel()) + + "“. Dieser Lauf hat die Überschrift geändert, die Angabe nicht."); + } + } + for (var enbez : vorher.keySet()) { + if (EIGENE_NORM.matcher(enbez).matches() + && neu.norm(enbez).isEmpty() + && angaben.containsKey(enbez)) { + ruegen.add( + "Die Inhaltsübersicht führt weiterhin eine Angabe zu " + + enbez + + ", obgleich dieser Lauf die Norm beseitigt hat."); + } + } + return List.copyOf(ruegen); + } + + /** Die Angaben der Übersicht, Bezeichnung auf Titel. */ + private static Map<String, String> angaben(Norm uebersicht) { + var angaben = new LinkedHashMap<String, String>(); + for (var absatz : uebersicht.absaetze()) { + for (var zeile : absatz.text().lines().toList()) { + var m = ANGABE.matcher(zeile); + if (m.matches()) { + angaben.putIfAbsent(m.group(1).replaceAll("\\s+", " ").strip(), m.group(2).strip()); + } + } + } + return angaben; + } + + private static Map<String, String> titelJeNorm(Gesetz gesetz) { + var titel = new LinkedHashMap<String, String>(); + for (var norm : gesetz.normen()) { + if (!norm.enbez().equals(ENBEZ)) { + titel.put(norm.enbez(), norm.titel() == null ? "" : norm.titel()); + } + } + return titel; + } + + private static boolean gleich(@Nullable String a, @Nullable String b) { + return normiere(a).equals(normiere(b)); + } + + private static String normiere(@Nullable String text) { + return text == null ? "" : text.replaceAll("\\s+", " ").strip(); + } +} |
