aboutsummaryrefslogtreecommitdiff
path: root/src/main/java
diff options
context:
space:
mode:
authorMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-26 07:39:57 +0200
committerMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-26 07:39:57 +0200
commite0754851af52d64d912d19828cab6044d1520cfc (patch)
treead8a95a76236ddca2c224286d1d26ec6747240f6 /src/main/java
parent63e1283230c5457666de90c9af74657b52579909 (diff)
Die Verweisung wird vollzogen, und die Übersicht wird geprüft
Der verweisende Befehl galt als Grenze: „Die Inhaltsübersicht wird entsprechend der vorstehenden Nummer 8 Buchst. a geändert“ wurde gelesen und liegengelassen. Die Grenze bestand nicht in der Sache. Zu übertragen ist nicht der Wortlaut jenes Punktes, sondern sein Ergebnis — er ändert die Überschrift eines Paragraphen, und die Angabe 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. Ausgeführt wird der Befehl dort, wo die Befehlsliste vorliegt: in der Schleife des Anwenders, nicht in der Weiche. Hinzu tritt eine Prüfung, die es bisher nicht gab. Wer Paragraphen einfügt oder ihre Überschriften neu fasst, muss die Angaben eigens mitändern; unterbleibt das, so bleibt die Übersicht hinter dem Text zurück, ohne dass ein einziger Befehl liegenbliebe. Geprüft wird allein der Unterschied, den der Lauf bewirkt hat — was die Quelle von sich aus ungenau führt, ist ein Befund über die Quelle. Geheilt wird nichts; die Übersicht ist eine Norm wie jede andere. Die Probe hat sich sogleich bewährt und drei Mängel aufgedeckt, die keine Zahl des Protokolls angezeigt hatte: Ein Zitat, das mit einer Gliederungsmarke beginnt, wurde nicht in Zeilen zerlegt, sondern blieb eine einzige; ein Querverweis mitten im Satz („… nach § 71a Projektunterlagen … vorzulegen“) erzeugte einen Paragraphen mit einem halben Satz als Überschrift, weil ein Normkopf nicht an seine Stellung gebunden war; und ein am Spaltenrand umbrochener Titel verlor seinen zweiten Teil, weil eine Zeile, die auf „und“ endet, als vollendet galt. Geprüft: mvnw verify, 406 Tests (zuvor 397), reuse lint 194/194. Das GEG-Heft bleibt bei 119 von 119 angewandten Befehlen; die Probe rügt dort nur noch, was die amtliche Fassung selbst verschieden führt. Change-Id: I24aabefc33406beaf7b1f1fca80d3212a7620f71
Diffstat (limited to 'src/main/java')
-rw-r--r--src/main/java/eu/mulk/aendggner/Pipeline.java4
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/InkrafttretensLeser.java18
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/PunktPfad.java61
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java148
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java93
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtsProbe.java136
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();
+ }
+}