From 2b614b00b5becbdef8a401ec0d4b73da6b9b1bd2 Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Sun, 23 Aug 2026 20:55:14 +0200 Subject: Der Stamm bekommt das Alter, das der Einleitungssatz nennt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die beiden größten Restzahlen des Prüfbestands — GEG einundfünfzig, IfSG siebenundzwanzig manuell zu prüfende Befehle — waren nie gepinnt, und niemand hatte sie je gegen ein Sollergebnis gehalten. Der Verdacht stand im Raum, sie seien kein Mangel des Werkzeugs, sondern ein Altersunterschied: Das gii-XML ist die heutige konsolidierte Fassung, während das Änderungsgesetz von 2023 bzw. 2020 auf die damalige zielt. Der Verdacht trifft zu, aber nicht ganz. Beschafft sind die zeitrichtigen Fassungen aus dem Wayback-Schnappschuss des amtlichen Archivs „xml.zip“ — dasselbe Format, das der Loader ohnehin liest. Maßgeblich für die Wahl ist der Einleitungssatz des Änderungsgesetzes; beide Schnappschüsse tragen genau den dort genannten Stand. Gegen sie gerechnet, blieben zunächst fünf und sieben Reste. Diese zwölf sind erstmals echte Befunde gewesen, und neun von ihnen benannten vier allgemeine Mängel, die alle behoben sind: Eine Wortersetzung griff in längere Wörter hinein und verbrauchte mit „schwerwiegende“ auch „schwerwiegender“; eine eingerückte Aufzählungsmarke galt als Satzende, weshalb ein Absatz mit Aufzählung mehr Sätze zählte als das Gesetzblatt; eine Ordnungszahl vor einem Gliederungswort ebenso; eine Nummer wurde im ganzen Absatz gesucht statt im benannten Satz; und eine Gliederungseinheit, die Rahmen und Befehl beide nennen, suchte sich innerhalb ihrer selbst. Übrig bleiben drei Reste im GEG, jeder im Test einzeln begründet: zweimal dieselbe Kaskade des § 108 — die Aufhebung einer „bisherigen“ Nummer, deren Bezeichnung ein anderer Punkt gerade neu vergibt, und eine Begleitklausel ohne eigene Ortsangabe — und einmal die Überschrift einer Nummer der Anlage 8. Das letzte ist dieselbe offene Modellfrage wie bei Berlins Anlage. Vier gepinnte Zahlen sind dadurch gestiegen; der Grund steht jeweils im Test. Das WDR-Gesetz trägt seither keinen Rest mehr. Geprüft mit „mvnw verify“: 330 Prüfungen, kein Fehlschlag; REUSE 167/167. Co-Authored-By: Claude Opus 5 Change-Id: I338e78ec3d46e0d0d8e57e77831ebae34497ae7b --- .../mulk/aendggner/anwendung/BefehlAnwender.java | 56 ++++++++++++++++++++-- .../anwendung/InhaltsuebersichtAnwender.java | 22 +++++++-- .../eu/mulk/aendggner/anwendung/SatzTeiler.java | 42 ++++++++++++++++ .../mulk/aendggner/anwendung/StellenAufloeser.java | 34 +++++++++---- 4 files changed, 138 insertions(+), 16 deletions(-) (limited to 'src/main/java') diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java index 13a235a..145334d 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -792,7 +792,7 @@ public final class BefehlAnwender { if (brauchtFuge(befehl.alt(), befehl.neu())) { return TextErgebnis.ok(ersetzeMitFuge(text, befehl.alt(), befehl.neu())); } - return TextErgebnis.ok(text.replace(befehl.alt(), befehl.neu())); + return TextErgebnis.ok(ersetzeWortweise(text, befehl.alt(), befehl.neu())); })); } @@ -811,6 +811,17 @@ public final class BefehlAnwender { && (Character.isLetter(neu.codePointAt(0)) || neu.charAt(0) == '('); } + /** Ersetzt jedes Vorkommen, das als Wort steht (siehe {@link #findeVorkommen}). */ + private static String ersetzeWortweise(String text, String alt, String neu) { + var sb = new StringBuilder(); + int von = 0; + for (int idx = findeVorkommen(text, alt, 0); idx >= 0; idx = findeVorkommen(text, alt, von)) { + sb.append(text, von, idx).append(neu); + von = idx + alt.length(); + } + return sb.append(text, von, text.length()).toString(); + } + private static String ersetzeMitFuge(String text, String alt, String neu) { var sb = new StringBuilder(); int von = 0; @@ -840,7 +851,7 @@ public final class BefehlAnwender { return TextErgebnis.fehler( "„" + befehl.woerter() + "“ kommt " + anzahl + "-mal vor."); } - int naht = text.indexOf(befehl.woerter()); + int naht = findeVorkommen(text, befehl.woerter(), 0); return TextErgebnis.ok( heileNaht( text.substring(0, naht) + text.substring(naht + befehl.woerter().length()), @@ -2104,7 +2115,7 @@ public final class BefehlAnwender { if (anzahl > 1) { return new Fundpruefung(-1, "„" + woerter + "“ kommt " + anzahl + "-mal vor; mehrdeutig."); } - return new Fundpruefung(text.indexOf(woerter), null); + return new Fundpruefung(findeVorkommen(text, woerter, 0), null); } private static int zaehleVorkommen(String text, String suchtext) { @@ -2113,13 +2124,50 @@ public final class BefehlAnwender { } int anzahl = 0; int index = 0; - while ((index = text.indexOf(suchtext, index)) >= 0) { + while ((index = findeVorkommen(text, suchtext, index)) >= 0) { anzahl++; index += suchtext.length(); } return anzahl; } + /** + * Das nächste Vorkommen des Suchtextes ab {@code von} — als Wort, nicht als beliebige + * Zeichenfolge. Ein Befehl, der „das Wort ‚schwerwiegende‘“ nennt, meint dieses Wort und nicht + * die ersten dreizehn Buchstaben von „schwerwiegender“; ohne diese Grenze ersetzte die + * Doppelanweisung des § 36 IfSG ihr zweites Wort mit, um es danach nicht mehr zu finden. Die + * Grenze wird nur dort verlangt, wo der Suchtext selbst mit einem Buchstaben oder einer Ziffer + * anfängt bzw. endet: Ein Satzzeichen, eine Klammer oder ein Bindestrich am Rand bringt seine + * Grenze schon mit. + */ + private static int findeVorkommen(String text, String suchtext, int von) { + if (suchtext.isEmpty()) { + return -1; + } + for (int index = text.indexOf(suchtext, von); + index >= 0; + index = text.indexOf(suchtext, index + 1)) { + if (stehtAlsWort(text, index, suchtext)) { + return index; + } + } + return -1; + } + + private static boolean stehtAlsWort(String text, int index, String suchtext) { + int ende = index + suchtext.length(); + if (istWortzeichen(suchtext.charAt(0)) && index > 0 && istWortzeichen(text.charAt(index - 1))) { + return false; + } + return !istWortzeichen(suchtext.charAt(suchtext.length() - 1)) + || ende >= text.length() + || !istWortzeichen(text.charAt(ende)); + } + + private static boolean istWortzeichen(char c) { + return Character.isLetterOrDigit(c); + } + // 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. diff --git a/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java index e1cf16f..5ffe0b7 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java @@ -73,11 +73,25 @@ final class InhaltsuebersichtAnwender { }; } - /** Die adressierte Angabe: die §-/Gliederungs-Komponenten hinter der Inhaltsübersichts-Marke. */ + /** + * Die adressierte Angabe: die §-/Gliederungs-Komponenten hinter der Inhaltsübersichts-Marke. + * + *

Nennt der Rahmen dieselbe Einheit wie der Befehl selbst — „In der Inhaltsübersicht wird … + * Teil 2 wie folgt geändert: … Die Angabe zur Überschrift von Teil 2 Abschnitt 4 wird gestrichen“ + * —, so steht sie zweimal hintereinander in der Stelle. Gemeint ist sie einmal: Das zweite Glied + * suchte sich sonst innerhalb seiner selbst und bliebe unauffindbar. + */ private static List zielKette(Stelle stelle) { - return stelle.komponenten().stream() - .filter(k -> k instanceof Stelle.Paragraph || k instanceof Stelle.Gliederungseinheit) - .toList(); + var kette = new ArrayList(); + for (var k : stelle.komponenten()) { + if (!(k instanceof Stelle.Paragraph || k instanceof Stelle.Gliederungseinheit)) { + continue; + } + if (kette.isEmpty() || !kette.get(kette.size() - 1).equals(k)) { + kette.add(k); + } + } + return List.copyOf(kette); } /** diff --git a/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java b/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java index be0a1f2..0b39962 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java @@ -78,6 +78,30 @@ public final class SatzTeiler { "November", "Dezember"); + /** + * Gliederungswörter, vor denen eine Zahl mit Punkt eine Ordnungszahl ist und kein Satzende: „… + * nach dem 5. Abschnitt des Elften Buches …“. Sie stehen groß und rissen den Satz sonst genau + * dort auseinander, wo er auf eine andere Vorschrift verweist. + */ + private static final Set GLIEDERUNGSWOERTER = + Set.of( + "Abschnitt", + "Abschnitts", + "Abschnitte", + "Abschnitten", + "Teil", + "Teils", + "Teile", + "Teilen", + "Kapitel", + "Kapitels", + "Buch", + "Buches", + "Titel", + "Titels", + "Unterabschnitt", + "Unterabschnitts"); + /** * Kandidat für ein Satzende: Punkt (ggf. gefolgt von schließendem Zitat oder Klammer), dann * Leerraum, dann Großbuchstabe, Zitat, Klammer oder Paragraphenzeichen — Rechtssätze beginnen @@ -99,6 +123,7 @@ public final class SatzTeiler { int punkt = matcher.start(); if (istAbkuerzung(text, punkt) || istDatum(text, punkt, matcher.end()) + || istOrdinalzahl(text, punkt, matcher.end()) || istAufzaehlungsMarke(text, punkt)) { continue; } @@ -181,11 +206,25 @@ public final class SatzTeiler { return MONATE.contains(naechstesWort); } + /** „nach dem 5. Abschnitt“ — Ziffern vor dem Punkt, ein Gliederungswort dahinter. */ + private static boolean istOrdinalzahl(String text, int punktPosition, int naechstesWortPosition) { + if (!wortVor(text, punktPosition).matches("\\d{1,2}")) { + return false; + } + var rest = text.substring(naechstesWortPosition); + return GLIEDERUNGSWOERTER.contains(rest.split("[\\s,.;]", 2)[0]); + } + /** * Punkt einer Aufzählungsmarke am Zeilenanfang („… folgende Aufgaben ⏎ 1. Erlaß von Satzungen * …“). Er beendet keinen Satz: die Glieder einer Aufzählung gehören zum tragenden Satz. Die * Beschränkung auf den Zeilenanfang unterscheidet die Marke von einer Zahl am Satzende („… * beträgt 30. Die Frist …“). + * + *

Die Einrückung, die die Marke trägt, gehört zum Zeilenanfang: Der Klartext- wie der + * XML-Zweig rücken die Glieder ein („⏎␣␣1.␣Personen, die …“), und ohne diese Nachsicht zählte + * jedes Glied als eigener Satz — § 20 Abs. 12 IfSG käme so auf neun Sätze statt der amtlichen + * fünf, und ein Befehl auf „Satz 5“ träfe den falschen. */ private static boolean istAufzaehlungsMarke(String text, int punktPosition) { var wort = wortVor(text, punktPosition); @@ -193,6 +232,9 @@ public final class SatzTeiler { return false; } int anfang = punktPosition - wort.length(); + while (anfang > 0 && (text.charAt(anfang - 1) == ' ' || text.charAt(anfang - 1) == '\t')) { + anfang--; + } return anfang == 0 || text.charAt(anfang - 1) == '\n'; } diff --git a/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java b/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java index bcbc752..b8d22d6 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java @@ -152,7 +152,15 @@ final class StellenAufloeser { */ private static SatzTeiler.@Nullable SatzBereich loeseFeinKomponentenAuf( Stelle stelle, String text) { - SatzTeiler.SatzBereich bereich = null; + // Nennt die Stelle einen Satz und darin eine Nummer („§ 5 Absatz 2 Satz 1 Nummer 3“), so ist + // der Satz der Suchbereich der Nummer. Ohne diese Verengung suchte die Marke „3.“ im ganzen + // Absatz und fand sie auch in der Aufzählung eines anderen Satzes — die Stelle galt dann als + // mehrdeutig und der Befehl blieb liegen. + var satzRahmen = satzRahmen(stelle, text); + if (satzRahmen != null && satzRahmen.bis() == satzRahmen.von()) { + return null; + } + SatzTeiler.SatzBereich bereich = satzRahmen; boolean zeilenKette = false; for (var komponente : stelle.komponenten()) { String labelRegex = @@ -177,13 +185,8 @@ final class StellenAufloeser { if (zeilenKette) { return bereich; } - for (var komponente : stelle.komponenten()) { - if (komponente instanceof Stelle.SatzNr satz) { - int nummer = Integer.parseInt(satz.nummer().replaceAll("[a-z]$", "")); - var saetze = SatzTeiler.teile(text); - var satzBereich = satzBereich(text, nummer, saetze); - return satzBereich == null ? null : halbsatzBereich(text, stelle, satzBereich); - } + if (satzRahmen != null) { + return halbsatzBereich(text, stelle, satzRahmen); } // Halbsatz ohne Satzangabe („in Halbsatz 1“ im Rahmen eines Satzes bzw. Absatzes). if (stelle.komponenten().stream().anyMatch(k -> k instanceof Stelle.HalbsatzNr)) { @@ -192,6 +195,21 @@ final class StellenAufloeser { return null; } + /** + * Der Bereich des von der Stelle benannten Satzes; {@code null}, wenn sie keinen nennt. Ein + * leerer Bereich ({@code von == bis}) bedeutet: Der Satz ist benannt, aber nicht auffindbar. + */ + private static SatzTeiler.@Nullable SatzBereich satzRahmen(Stelle stelle, String text) { + for (var komponente : stelle.komponenten()) { + if (komponente instanceof Stelle.SatzNr satz) { + int nummer = Integer.parseInt(satz.nummer().replaceAll("[a-z]$", "")); + var bereich = satzBereich(text, nummer, SatzTeiler.teile(text)); + return bereich == null ? new SatzTeiler.SatzBereich(0, 0) : bereich; + } + } + return null; + } + /** * Der Bereich des Satzes mit der gegebenen Nummer. Amtlich nummerierte Sätze (bayerisches * Landesrecht) werden nach Satznummer statt Position aufgelöst — nach Streichungen kann die -- cgit v1.2.1