aboutsummaryrefslogtreecommitdiff
path: root/src/main/java
diff options
context:
space:
mode:
authorMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-23 20:55:14 +0200
committerMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-23 20:55:14 +0200
commit2b614b00b5becbdef8a401ec0d4b73da6b9b1bd2 (patch)
tree7fb5d28774e30a3111345787dcc799d5be82b2c0 /src/main/java
parent5203e9407979dbb920bc796ee457ce7c12911ef9 (diff)
Der Stamm bekommt das Alter, das der Einleitungssatz nennt
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 <noreply@anthropic.com> Change-Id: I338e78ec3d46e0d0d8e57e77831ebae34497ae7b
Diffstat (limited to 'src/main/java')
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java56
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java22
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java42
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java34
4 files changed, 138 insertions, 16 deletions
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 <em>Wort</em>, 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.
+ *
+ * <p>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<Stelle.Komponente> zielKette(Stelle stelle) {
- return stelle.komponenten().stream()
- .filter(k -> k instanceof Stelle.Paragraph || k instanceof Stelle.Gliederungseinheit)
- .toList();
+ var kette = new ArrayList<Stelle.Komponente>();
+ 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
@@ -79,6 +79,30 @@ public final class SatzTeiler {
"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<String> 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
* häufig mit einem Verweis („… zusammengefasst werden. § 27 Absatz 2 … bleibt unberührt.“).
@@ -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 …“).
+ *
+ * <p>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)) {
@@ -193,6 +196,21 @@ final class StellenAufloeser {
}
/**
+ * 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
* Zählung von der Position abweichen. Trägt mindestens ein Satz eine Nummer, entscheidet allein