diff options
11 files changed, 722 insertions, 83 deletions
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt index db80d88..f7a760e 100644 --- a/FASSUNGEN.txt +++ b/FASSUNGEN.txt @@ -21,6 +21,116 @@ Vorbemerkung zur Führung dieses Verzeichnisses ════════════════════════════════════════════════════════════════════════════════ + Fassung vom 26. August 2026 (zweite Fassung des Tages), + zuletzt geändert durch die am 26. August 2026 vorgenommenen Änderungen +════════════════════════════════════════════════════════════════════════════════ + +Der verweisende Befehl („Die Inhaltsübersicht wird entsprechend der vorstehenden +Nummer 8 Buchst. a geändert“) wurde bislang gelesen, aber nicht ausgeführt; das +Handbuch wies ihn in § 7 Absatz 2 Nummer 17 als Grenze aus. Die Grenze bestand +nicht in der Sache: Zu übertragen ist nicht der Wortlaut des verwiesenen Punktes, +sondern sein Ergebnis. Jener ändert die Überschrift eines Paragraphen; die Angabe +der Inhaltsübersicht ist alsdann auf den Titel zu setzen, den der Paragraph danach +trägt. + +Hinzu tritt eine Prüfung, die es bisher nicht gab. Ein Änderungsgesetz muss die +Angaben der Inhaltsübersicht eigens mitändern; unterbleibt das oder greifen die +Befehle nicht, so bleibt die Übersicht hinter dem Text zurück, ohne dass ein +einziger Befehl liegenbliebe. Die neue Probe hat auf Anhieb drei Mängel des +Erzeugnisses aufgedeckt, die keine Zahl des Protokolls angezeigt hatte. + + +Artikel 1 +Ausführung des verweisenden Befehls + +(1) Die Lesart der Gliederungspunkte, mit denen ein Änderungsgesetz auf sich + selbst verweist („Nummer 11 Buchstabe a Doppelbuchstabe bb“, abgekürzt „Nr. 8 + Buchst. a“), wird an einer Stelle zusammengefasst (PunktPfad). Der Leser des + Inkrafttretens (InkrafttretensLeser) bezieht sie fortan von dort. + +(2) Der Anwender (BefehlAnwender) führt den verweisenden Befehl in der Schleife + aus, die als einzige die Befehlsliste kennt. Er sucht den verwiesenen Punkt im + selben Artikel — auch unterhalb seiner, wenn der Verweis eine Nummer und nicht + einen Buchstaben nennt —, entnimmt ihm den betroffenen Paragraphen und setzt + dessen Angabe auf die jetzige Überschrift (InhaltsuebersichtAnwender. + fuehreTitelNach). + +(3) Was der Verweis nicht trägt, bleibt liegen und wird benannt: der Punkt, der + sich nicht findet, und der Punkt, der etwas anderes als eine Überschrift + ändert. Die Inhaltsübersicht führt allein Bezeichnung und Überschrift; was im + Absatz eines Paragraphen geschieht, hat in ihr kein Gegenstück. + + +Artikel 2 +Probe auf die Inhaltsübersicht + +(1) Nach vollzogener Anwendung wird die Inhaltsübersicht gegen den Normbestand + gehalten (InhaltsuebersichtsProbe). Gerügt werden die fehlende Angabe zu einer + eingefügten oder geänderten Norm, das Auseinandergehen von Angabe und + Überschrift sowie die verbliebene Angabe zu einer beseitigten Norm. + +(2) Geprüft wird allein der Unterschied, den dieser Lauf bewirkt hat. Was die + Quelle von sich aus ungenau führt, bleibt außer Betracht; es wäre ein Befund + über die Quelle und nicht über den Lauf. Ebenso bleiben die Anlagen außer + Betracht, denn die Inhaltsübersicht führt sie nicht. + +(3) Geheilt wird nichts. Die Rüge nennt überdies den Befund und nicht dessen + Ursache: Die amtliche Fassung des Gebäudeenergiegesetzes selbst schreibt in + der Überschrift des § 71k „Gas“ und in der Angabe dazu „Erdgas“. + + +Artikel 3 +Berichtigung dreier Mängel, welche die Probe aufgedeckt hat + +(1) Die Zerlegung eines Zitats in Angabe-Zeilen (InhaltsuebersichtAnwender) + trennte nur an §-Angaben. Ein Zitat, das mit einer Gliederungsmarke beginnt + und darauf zwanzig Paragraphen aufführt — so das Gebäudeenergiegesetz-Heft von + 2023 mit „Unterabschnitt 4 … § 71 … § 71p …“ —, blieb deshalb eine einzige + Zeile, und jede spätere Angabe zu einem dieser Paragraphen wäre unauffindbar + gewesen. Die Zerlegung erfolgt fortan an allen Zeilenanfängen einer Übersicht; + sie ist zugleich mit derjenigen der Gesamt-Neufassung zusammengeführt, die + dasselbe längst richtig tat. + +(2) Die Zerlegung eines eingefügten Paragraphen-Blocks (BefehlAnwender) trennte an + jedem „§ N“, dem ein großgeschriebenes Wort folgte. Ein Querverweis mitten im + Satz sah damit aus wie eine Überschrift: „… mit Systemen für die + Gebäudeautomatisierung nach § 71a Projektunterlagen in überprüfbarer Form + vorzulegen.“ erzeugte einen Paragraphen „§ 71a“ mit einem halben Satz als + Überschrift. Ein Normkopf muss fortan eine Zeile oder wenigstens einen Satz + eröffnen; der Wortbestand unterscheidet beide nicht, die Stellung tut es. + +(3) Die Überschrift einer zitierten Norm endete bislang an der ersten + großgeschrieben beginnenden Zeile. Ein am Spaltenrand umbrochener Titel verlor + dadurch seinen zweiten Teil („Verordnungsermächtigung zu dem Einsatz von + Kältemitteln in elektrischen Wärmepumpen und“ / „Wärmepumpen-Hybridheizungen“). + Endet eine Zeile auf ein Binde- oder Verhältniswort oder auf einen Trennstrich, + so ist sie fortan nicht zu Ende. + + +Artikel 4 +Änderung des Handbuchs + +Das Handbuch (README.md) wird wie folgt geändert: + +1. Nach § 6b wird der § 6c „Probe auf die Inhaltsübersicht“ eingefügt; die + Inhaltsübersicht des Handbuchs wird entsprechend ergänzt. + +2. § 7 Absatz 2 Nummer 17 wird neu gefasst; die dort ausgewiesene Grenze entfällt. + + +Schlussbestimmung + +Die vorstehenden Änderungen sind durch die Prüfung sämtlicher Testfälle +(vierhundertsechs an der Zahl, zuvor dreihundertsiebenundneunzig) sowie durch die +vollständige Erstellung (mvnw verify) bestätigt worden. Neu hinzu getreten sind +drei Prüfungen des verweisenden Befehls (BefehlAnwenderTest) und sechs der Probe +(InhaltsuebersichtsProbeTest). Das Heft des Gebäudeenergiegesetzes von 2023 wird +weiterhin vollständig angewandt (119 von 119); die Probe rügt dort nur noch, was +die amtliche Fassung selbst verschieden führt (§ 71k, § 71l). Die Auszeichnung +nach REUSE ist vollständig (194 von 194 Dateien). + + +════════════════════════════════════════════════════════════════════════════════ Fassung vom 26. August 2026, zuletzt geändert durch die am 26. August 2026 vorgenommenen Änderungen ════════════════════════════════════════════════════════════════════════════════ @@ -41,6 +41,7 @@ durchgerechneten Beispiele sind der kürzeste Weg zum ersten Ergebnis - [§ 6 Betrieb der Befehlszeilenfassung](#-6-betrieb-der-befehlszeilenfassung) - [§ 6a Die fortgeschriebene Fassung; die Kette](#-6a-die-fortgeschriebene-fassung-die-kette) - [§ 6b Abgleich mit der amtlichen Nachfassung](#-6b-abgleich-mit-der-amtlichen-nachfassung) +- [§ 6c Probe auf die Inhaltsübersicht](#-6c-probe-auf-die-inhaltsübersicht) - [§ 7 Erkannte Änderungsbefehle](#-7-erkannte-änderungsbefehle) - [§ 8 Reihenfolge der Anwendung](#-8-reihenfolge-der-anwendung) - [§ 9 Aufbereitung der Druckwerke](#-9-aufbereitung-der-druckwerke) @@ -332,6 +333,28 @@ Damit messen Werkzeug und Prüfung an einem Maßstab, und die Erschließung eine weiteren Landes braucht keinen eigenen Prüfcode mehr: Stammfassung, Heft, Nachfassung — das Erzeugnis sagt selbst, wo es danebenliegt. +## § 6c Probe auf die Inhaltsübersicht + +(1) 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 (§ 7 Absatz 2 Nummer 7). +Bleiben sie aus oder greifen sie nicht, so bleibt die Übersicht hinter dem Text +zurück — **ohne dass ein einziger Befehl liegenbliebe**. Das zeigt keine Zahl des +Protokolls an. + +(2) Nach vollzogener Anwendung wird die Inhaltsübersicht deshalb gegen den Normbestand +gehalten und jede Abweichung gerügt. Geprüft wird allein der Unterschied: die Normen, +deren Bezeichnung oder Überschrift *dieser Lauf* verändert hat. Was die Quelle von sich +aus ungenau führt, geht die Probe nichts an — das wäre ein Befund über die Quelle und +nicht über den Lauf. + +(3) Geheilt wird nichts. Die Inhaltsübersicht ist eine Norm wie jede andere; sie ohne +Befehl fortzuschreiben hieße, Recht zu erfinden. + +(4) Die Rüge nennt den Befund und nicht dessen Ursache. Es kommt nämlich vor, dass die +amtliche Fassung selbst beides verschieden führt: Das Gebäudeenergiegesetz schreibt in +der Überschrift des § 71k „Gas“ und in der Angabe dazu „Erdgas“. + ## § 7 Erkannte Änderungsbefehle (1) Erkannt werden die gebräuchlichsten Änderungsbefehle des Handbuchs der @@ -378,12 +401,15 @@ Streichen und Umnummerierung. 16. die Neufassung der Gesetzesüberschrift sowie 17. die Verweisung auf einen anderen Punkt desselben Artikels („Die Inhaltsübersicht wird entsprechend der vorstehenden Nummer 8 Buchst. a - geändert“). Sie wird gelesen, aber nicht ausgeführt: Die sinngemäße - Übertragung setzte einen Rückgriff auf die Befehlsliste und eine Umdeutung - des Ziels voraus, die das Erzeugnis nicht leistet. Gelesen zu werden ist - gleichwohl mehr als gar nichts — die Rüge sagt alsdann, dass hier das - Erzeugnis die Grenze zieht, und nicht, dass die Vorlage unverständlich sei - (§ 1 Absatz 5). + geändert“). Übertragen wird nicht der Wortlaut des verwiesenen Punktes, + sondern sein **Ergebnis**: Jener ändert die Überschrift eines Paragraphen, + und die Angabe der Inhaltsübersicht wird auf den Titel gesetzt, den der + Paragraph danach trägt. Das trifft, was „entsprechend“ meint, und erspart es, + jede Befehlsform ein zweites Mal auf dem Zeilenmodell der Übersicht + nachzubilden. Zielt der verwiesene Punkt auf etwas anderes als eine + Überschrift, so bleibt der Befehl liegen, und die Rüge sagt es: Die Übersicht + führt allein Bezeichnung und Überschrift, und was im Absatz eines Paragraphen + geschieht, hat in ihr kein Gegenstück. (3) Landesrechtliche Befehlsformen bestimmt ergänzend § 13. 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(); + } +} diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java index 109bbf6..8c9743d 100644 --- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java +++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java @@ -1927,17 +1927,23 @@ class EndToEndTest { // Kein Befehl des Heftes bleibt mehr ungelesen. assertThat(parseErgebnis.befehle()).noneMatch(b -> b instanceof UnbekannterBefehl); - // Die Verweisung auf einen anderen Punkt desselben Artikels wird gelesen, aber bewusst nicht - // ausgeführt. Der Unterschied ist kein kosmetischer: Die Synopse sagt fortan, dass das - // Erzeugnis hier die Grenze zieht, und nicht, dass die Vorlage unverständlich sei. + // Die Verweisung auf einen anderen Punkt desselben Artikels wird gelesen und ausgeführt: Der + // verwiesene Punkt ändert die Überschrift des § 13, und die Angabe der Inhaltsübersicht wird + // ihr nachgeführt. Hier scheitert das nicht am Erzeugnis, sondern am Gegenstand — diese + // Verordnung führt gar keine Inhaltsübersicht, und die Rüge sagt genau das. var verweisung = befehlAn(parseErgebnis, "12."); assertThat(verweisung).isInstanceOf(VerweisenderBefehl.class); assertThat(((VerweisenderBefehl) verweisung).verweis()).isEqualTo("Nummer 8 Buchst. a"); + assertThat(gesetz.norm("Inhaltsübersicht")).isEmpty(); - var anwendung = BefehlAnwender.anwenden(gesetz, List.of(verweisung)); - assertThat(anwendung.protokoll().get(0).grund()).isEqualTo(Grund.NICHT_UNTERSTUETZT); - assertThat(anwendung.protokoll().get(0).begruendung()) - .contains("verweist auf „Nummer 8 Buchst. a“"); + var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle()); + var protokoll = + anwendung.protokoll().stream() + .filter(a -> a.befehl() == verweisung) + .findFirst() + .orElseThrow(); + assertThat(protokoll.grund()).isEqualTo(Grund.BESTAND_WIDERSPRICHT); + assertThat(protokoll.begruendung()).contains("keine Inhaltsübersicht"); } private static Aenderungsbefehl befehlAn( diff --git a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java index 39c71cc..3ed028b 100644 --- a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java +++ b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java @@ -18,6 +18,7 @@ import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturErsetzung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl; +import eu.mulk.aendggner.aenderung.Aenderungsbefehl.VerweisenderBefehl; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WoerterEinfuegung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker; import eu.mulk.aendggner.aenderung.Provenienz; @@ -1029,6 +1030,81 @@ class BefehlAnwenderTest { .contains("Teil 2 | Verfahren\n§ 3 | Ablauf"); } + // --- Der verweisende Befehl ------------------------------------------------------------------ + + /** + * „Die Inhaltsübersicht wird entsprechend der vorstehenden Nummer 8 Buchst. a geändert.“ Der + * verwiesene Punkt ändert die Überschrift des § 1; die Angabe wird auf den Titel nachgeführt, den + * der Paragraph danach trägt. Übertragen wird also das Ergebnis jenes Punktes und nicht sein + * Wortlaut — das trifft, was „entsprechend“ meint. + */ + @Test + void fuehrtDieInhaltsuebersichtDemVerwiesenenPunktNach() { + var ueberschrift = + new Ersetzung( + new Stelle(List.of(new Stelle.Paragraph("1"), new Stelle.Ueberschrift())), + "Zweck", + "Zweck und Ziel", + false, + false, + new Provenienz("1", "8. a)", "(Test)")); + var verweisung = + new VerweisenderBefehl( + iuStelle(), "Nummer 8 Buchst. a", new Provenienz("1", "12.", "(Test)")); + + var ergebnis = + BefehlAnwender.anwenden(gesetzMitInhaltsuebersicht(), List.of(ueberschrift, verweisung)); + + assertThat(ergebnis.protokoll()).allMatch(a -> a.status() == Status.ANGEWANDT); + assertThat(ergebnis.neu().norm("§ 1").orElseThrow().titel()).isEqualTo("Zweck und Ziel"); + assertThat(absatzText(ergebnis.neu(), "Inhaltsübersicht", 0)) + .contains("§ 1 | Zweck und Ziel") + .doesNotContain("§ 1 | Zweck\n"); + } + + /** + * Was der Verweis nicht trägt, bleibt liegen und wird benannt. Die Inhaltsübersicht führt allein + * Bezeichnung und Überschrift; was im Absatz eines Paragraphen geschieht, hat in ihr kein + * Gegenstück, und es zu erraten wäre schlimmer, als die Grenze zu benennen. + */ + @Test + void ruegtDenVerweisAufEinenPunktOhneGegenstueckInDerUebersicht() { + var imAbsatz = + new Ersetzung( + new Stelle(List.of(new Stelle.Paragraph("1"))), + "Text", + "Wortlaut", + false, + false, + new Provenienz("1", "8. a)", "(Test)")); + var verweisung = + new VerweisenderBefehl( + iuStelle(), "Nummer 8 Buchst. a", new Provenienz("1", "12.", "(Test)")); + + var ergebnis = + BefehlAnwender.anwenden(gesetzMitInhaltsuebersicht(), List.of(imAbsatz, verweisung)); + + var ruege = ergebnis.protokoll().get(1); + assertThat(ruege.status()).isEqualTo(Status.MANUELL_PRUEFEN); + assertThat(ruege.grund()).isEqualTo(Grund.NICHT_UNTERSTUETZT); + assertThat(ruege.begruendung()).contains("ändert keine Überschrift"); + } + + /** Ein Verweis ins Leere ist keine Grenze des Erzeugnisses, sondern ein Befund am Dokument. */ + @Test + void ruegtDenVerweisAufEinenNichtVorhandenenPunkt() { + var verweisung = + new VerweisenderBefehl( + iuStelle(), "Nummer 8 Buchst. a", new Provenienz("1", "12.", "(Test)")); + + var ergebnis = BefehlAnwender.anwenden(gesetzMitInhaltsuebersicht(), List.of(verweisung)); + + var ruege = ergebnis.protokoll().get(0); + assertThat(ruege.status()).isEqualTo(Status.MANUELL_PRUEFEN); + assertThat(ruege.grund()).isEqualTo(Grund.STELLE_NICHT_AUFLOESBAR); + assertThat(ruege.begruendung()).contains("findet sich nicht im selben Artikel"); + } + // --- Gliederungs-Überschriften --------------------------------------------------------------- private static Gesetz gesetzMitGliederungen() { diff --git a/src/test/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtsProbeTest.java b/src/test/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtsProbeTest.java new file mode 100644 index 0000000..742430b --- /dev/null +++ b/src/test/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtsProbeTest.java @@ -0,0 +1,101 @@ +// 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 static org.assertj.core.api.Assertions.assertThat; + +import eu.mulk.aendggner.gesetz.Absatz; +import eu.mulk.aendggner.gesetz.Gesetz; +import eu.mulk.aendggner.gesetz.Norm; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Die Probe schlägt an, wo ein Lauf den Bestand angerührt und die Inhaltsübersicht zurückgelassen + * hat — und nur dort. Was die Quelle von sich aus ungenau führt, geht sie nichts an. + */ +class InhaltsuebersichtsProbeTest { + + private static Gesetz gesetz(String uebersicht, Norm... normen) { + var alle = new ArrayList<Norm>(); + alle.add( + new Norm("Inhaltsübersicht", null, null, List.of(new Absatz(null, uebersicht)), false)); + alle.addAll(List.of(normen)); + return new Gesetz("TestG", "Testgesetz", null, alle); + } + + private static Norm norm(String enbez, String titel) { + return new Norm(enbez, titel, null, List.of(new Absatz(null, "Text.")), false); + } + + private static final String UEBERSICHT = "§ 1 | Zweck\n§ 2 | Begriffe"; + + @Test + void ruegtDieNichtNachgefuehrteAngabe() { + var alt = gesetz(UEBERSICHT, norm("§ 1", "Zweck"), norm("§ 2", "Begriffe")); + var neu = gesetz(UEBERSICHT, norm("§ 1", "Zweck und Ziel"), norm("§ 2", "Begriffe")); + + assertThat(InhaltsuebersichtsProbe.pruefe(alt, neu)) + .singleElement() + .asString() + .contains("§ 1") + .contains("gehen auseinander"); + } + + @Test + void schweigtWennDieAngabeMitgefuehrtWurde() { + var alt = gesetz(UEBERSICHT, norm("§ 1", "Zweck"), norm("§ 2", "Begriffe")); + var neu = + gesetz( + "§ 1 | Zweck und Ziel\n§ 2 | Begriffe", + norm("§ 1", "Zweck und Ziel"), + norm("§ 2", "Begriffe")); + + assertThat(InhaltsuebersichtsProbe.pruefe(alt, neu)).isEmpty(); + } + + @Test + void ruegtDenEingefuegtenParagraphenOhneAngabe() { + var alt = gesetz(UEBERSICHT, norm("§ 1", "Zweck"), norm("§ 2", "Begriffe")); + var neu = + gesetz(UEBERSICHT, norm("§ 1", "Zweck"), norm("§ 1a", "Neues"), norm("§ 2", "Begriffe")); + + assertThat(InhaltsuebersichtsProbe.pruefe(alt, neu)) + .singleElement() + .asString() + .contains("keine Angabe zu § 1a"); + } + + @Test + void ruegtDieAngabeZurBeseitigtenNorm() { + var alt = gesetz(UEBERSICHT, norm("§ 1", "Zweck"), norm("§ 2", "Begriffe")); + var neu = gesetz(UEBERSICHT, norm("§ 1", "Zweck")); + + assertThat(InhaltsuebersichtsProbe.pruefe(alt, neu)) + .singleElement() + .asString() + .contains("weiterhin eine Angabe zu § 2"); + } + + /** + * Eine Anlage steht nicht in der Inhaltsübersicht; sie dort zu vermissen hieße, ihr etwas + * abzuverlangen, was sie nie leistet. + */ + @Test + void schweigtZurGeaendertenAnlage() { + var alt = gesetz(UEBERSICHT, norm("§ 1", "Zweck"), norm("Anlage 8", "Muster")); + var neu = gesetz(UEBERSICHT, norm("§ 1", "Zweck"), norm("Anlage 8", "Anderes Muster")); + + assertThat(InhaltsuebersichtsProbe.pruefe(alt, neu)).isEmpty(); + } + + /** Ohne Inhaltsübersicht gibt es nichts zu prüfen — und nichts zu rügen. */ + @Test + void schweigtOhneInhaltsuebersicht() { + var ohne = new Gesetz("TestG", "Testgesetz", null, List.of(norm("§ 1", "Zweck"))); + var geaendert = new Gesetz("TestG", "Testgesetz", null, List.of(norm("§ 1", "Zweck und Ziel"))); + + assertThat(InhaltsuebersichtsProbe.pruefe(ohne, geaendert)).isEmpty(); + } +} |
