aboutsummaryrefslogtreecommitdiff
path: root/src/main/java
diff options
context:
space:
mode:
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();
+ }
+}