From 2ada4600cbb8dbd0dc8ac2fa0537f6853ba62359 Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Sun, 23 Aug 2026 21:52:20 +0200 Subject: =?UTF-8?q?Die=20Nummer=20einer=20Anlage=20ist=20eine=20Norm,=20ke?= =?UTF-8?q?in=20Aufz=C3=A4hlungsglied?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Zuständigkeitskatalog des Berliner Sicherheits- und Ordnungsgesetzes zählt seine Einheiten nicht als Aufzählungsglieder, sondern führt sie als „Nummer 6“, „Nummer 23“, „Nummer 31“ — jede mit eigener Absatzzählung, jede im Portal eine eigene Einzelnorm. Der Stellenauflöser suchte dagegen eine Marke „6.“ im Wortlaut der Anlage und fand dort vielerlei; von sechs Befehlen des Artikels 1 wurden zwei angewandt. Sie werden nunmehr geführt, wie sie sind: als eigene Normen, deren Bezeichnung die Anlage voranstellt. Damit trägt die vorhandene Absatz-Maschinerie ihre Befehle unverändert, und es braucht keinen neuen Rang im Modell. Wo ein Gesetz solche Normen nicht führt — im gii-XML des Bundes stehen die Nummern einer Anlage als Marken im Wortlaut —, bleibt es beim bisherigen Weg. Dabei kamen drei Mängel ans Licht. Ob die feinste Komponente einer Fundstelle ein Absatz ist, entschied das bloße Vorkommen einer feineren Komponente; die Nummer einer Anlage steht aber vor der Absatzangabe und gehört zur Bezeichnung der Norm. Die Umnummerierung eines solchen Absatzes galt deshalb als die einer Aufzählungseinheit, tauschte eine Marke, die es nicht gab, hieß angewandt und bewirkte nichts. Ein Absatz, der „vor den Wörtern …“ eingefügt wird, wurde eine Zeile im Nachbarabsatz statt eines Absatzes. Und die Bezeichnung eines so eingefügten Absatzes verlor die Nummer der Anlage, weshalb die Schritt-Ordnung nicht sah, dass eine Umnummerierung ihm den Platz erst räumt. Beigelegt sind die beiden Fassungen des ASOG, über die Browsersteuerung beschafft. Der Auszug ist dort bei fünfzigtausend Zeichen gekappt und verschluckt einzelne Leerzeichen; base64-kodierte Stücke kamen zeichengenau herüber. Fünf der sechs Befehle werden angewandt, und 168 der 171 Normen gleichen danach der amtlichen Nachfassung. Die drei Abweichungen sind benannt und liegen nicht in der Anwendung — eine von ihnen belegt erstmals, was die geometrische Lesereihenfolge kosten würde: Der Titelblock des Änderungsgesetzes steht im Inhaltsstrom mitten in einem Zitat. Geprüft mit „mvnw verify“: 333 Prüfungen, kein Fehlschlag; keine gepinnte Zahl eines anderen Belegfalls hat sich geändert. REUSE 171/171. Co-Authored-By: Claude Opus 5 Change-Id: I24b94cb96e6728b1ff1b1fcb5bb4b8433594e240 --- .../java/eu/mulk/aendggner/aenderung/Stelle.java | 13 ++ .../mulk/aendggner/anwendung/BefehlAnwender.java | 159 +++++++++++++++++++-- .../mulk/aendggner/anwendung/StellenAufloeser.java | 14 ++ .../gesetz/land/LandesRechtTextParser.java | 33 ++++- 4 files changed, 204 insertions(+), 15 deletions(-) (limited to 'src/main') diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java b/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java index 73d067f..121c9bf 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java @@ -96,6 +96,19 @@ public record Stelle(List komponenten) { .findFirst(); } + /** Die erste Nummernangabe („Nummer 6“), falls vorhanden. */ + public Optional nummer() { + return komponenten.stream() + .filter(NummerNr.class::isInstance) + .map(NummerNr.class::cast) + .findFirst(); + } + + /** Dieselbe Stelle ohne die genannte Komponente — für ein Glied, das anderswo aufging. */ + public Stelle ohne(Komponente komponente) { + return new Stelle(komponenten.stream().filter(k -> !k.equals(komponente)).toList()); + } + public Optional absatz() { return komponenten.stream() .filter(AbsatzNr.class::isInstance) diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java index cefb285..486f77b 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -474,7 +474,19 @@ public final class BefehlAnwender { case BUCHSTABE -> new Stelle.BuchstabeNr(bezeichnung); }; var komponenten = new ArrayList<>(s.stelle().komponenten()); + // Die letzte Komponente weicht, wenn sie gleich fein oder feiner ist als die neue („In § 5 + // Absatz 2 wird folgender Absatz 3 eingefügt“ meint § 5 Absatz 3, nicht § 5 Absatz 2 Absatz 3). + // Die Nummer einer Anlage weicht nicht: Sie gehört zur Bezeichnung der Norm und nicht zu ihrem + // Inneren, und ohne sie träfe der neue Absatz eine andere Norm als der Befehl. + boolean anlagenNummer = + s.stelle().anlagenEnbez().isPresent() + && !komponenten.isEmpty() + && s.stelle() + .nummer() + .filter(komponenten.get(komponenten.size() - 1)::equals) + .isPresent(); if (!komponenten.isEmpty() + && !anlagenNummer && rang(komponenten.get(komponenten.size() - 1)) >= rang(komponente)) { komponenten.remove(komponenten.size() - 1); } @@ -965,7 +977,7 @@ public final class BefehlAnwender { return angewandt(befehl, norm.enbez()); } - if (nurNorm(stelle)) { + if (nurNorm(normen, stelle)) { var aufloesung = loeseNormAuf(normen, stelle); if (aufloesung.fehler() != null) { return manuell(befehl, aufloesung.grund(), aufloesung.fehler()); @@ -1338,8 +1350,95 @@ public final class BefehlAnwender { * der Befehl nennt, ist dabei ohne Belang (in einer Anlage stehen Absätze und Nummern als Zeilen * eines Textes). */ + /** + * „Vor den Wörtern ‚Aus dem Bereich Verkehr:‘ wird folgender Absatz 5 eingefügt“ — ein + * Absatz, der an einem Wortlaut ausgerichtet ist. Er wird ein Absatz und keine Zeile im + * Nachbarabsatz: Der tragende Absatz wird an der Marke geteilt, und was hinter ihr stand, setzt + * den neuen Absatz fort — so steht es hernach auch in der amtlichen Nachfassung, denn ein + * Zwischentitel ohne eigene Absatzbezeichnung schließt sich stets dem vorangehenden an. + * + *

Steht die Marke am Anfang des Absatzes, so wird nicht geteilt: Der neue Absatz tritt dann + * schlicht vor ihn. + */ + private static AngewandteAenderung wendeAbsatzWortankerEinfuegungAn( + List normen, StrukturEinfuegung befehl, String woerter) { + var aufloesung = loeseNormAuf(normen, befehl.stelle()); + if (aufloesung.fehler() != null) { + return manuell(befehl, aufloesung.grund(), aufloesung.fehler()); + } + var norm = normen.get(aufloesung.normIndex()); + var absaetze = new ArrayList<>(norm.absaetze()); + int treffer = -1; + for (int i = 0; i < absaetze.size(); i++) { + int anzahl = zaehleVorkommen(absaetze.get(i).text(), woerter); + if (anzahl > 1) { + return manuell( + befehl, Grund.MEHRDEUTIG, "„" + woerter + "“ kommt mehrfach vor; mehrdeutig."); + } + if (anzahl == 1) { + if (treffer >= 0) { + return manuell( + befehl, + Grund.MEHRDEUTIG, + "„" + woerter + "“ kommt in mehreren Absätzen vor; mehrdeutig."); + } + treffer = i; + } + } + if (treffer < 0) { + return manuell( + befehl, Grund.ZIELTEXT_FEHLT, "„" + woerter + "“ kommt im Zieltext nicht vor."); + } + var alt = absaetze.get(treffer); + int marke = findeVorkommen(alt.text(), woerter, 0); + int schnitt = + befehl.vorher() + ? alt.text().lastIndexOf('\n', marke) + 1 + : naechsterZeilenAnfang(alt.text(), marke); + var vorn = alt.text().substring(0, schnitt).stripTrailing(); + var hinten = alt.text().substring(schnitt).strip(); + + var neue = parseAbsaetze(befehl.text()); + if (neue.isEmpty()) { + return manuell(befehl, Grund.ZITAT_UNBRAUCHBAR, "Im Zitat wurde kein Absatz erkannt."); + } + var erster = neue.get(0); + var nummer = erster.nummer() != null ? erster.nummer() : befehl.bezeichnung(); + var text = hinten.isEmpty() ? erster.text() : erster.text() + "\n" + hinten; + + if (vorn.isEmpty()) { + // Die Marke eröffnet den Absatz: nichts zu teilen, der neue tritt davor. + absaetze.add(treffer, new Absatz(nummer, erster.text())); + } else { + absaetze.set(treffer, alt.mitText(vorn)); + absaetze.add(treffer + 1, new Absatz(nummer, text)); + } + for (int k = 1; k < neue.size(); k++) { + absaetze.add(treffer + 1 + k, neue.get(k)); + } + normen.set(aufloesung.normIndex(), norm.mitAbsaetzen(absaetze)); + return angewandt(befehl, norm.enbez()); + } + + private static int naechsterZeilenAnfang(String text, int position) { + int umbruch = text.indexOf('\n', position); + return umbruch < 0 ? text.length() : umbruch + 1; + } + private static AngewandteAenderung wendeWortankerEinfuegungAn( List normen, StrukturEinfuegung befehl, WortAnker anker) { + // Ein ganzer Absatz wird ein Absatz, keine Zeile in einem anderen. + if (befehl.ebene() == Ebene.ABSATZ) { + var woerter = + switch (anker) { + case WortAnker.NachWoertern nach -> nach.woerter(); + case WortAnker.VorWoertern vor -> vor.woerter(); + default -> null; + }; + if (woerter != null) { + return wendeAbsatzWortankerEinfuegungAn(normen, befehl, woerter); + } + } return bearbeiteText( normen, befehl, @@ -1505,7 +1604,7 @@ public final class BefehlAnwender { befehl, Grund.STELLE_NICHT_AUFLOESBAR, "Absatz (" + nummer + ") nicht gefunden."); } - if (nurNorm(stelle)) { + if (nurNorm(normen, stelle)) { var aufloesung = loeseNormAuf(normen, stelle); if (aufloesung.fehler() != null) { return manuell(befehl, aufloesung.grund(), aufloesung.fehler()); @@ -2114,6 +2213,14 @@ public final class BefehlAnwender { enbez = stelle.paragraph().get().enbez(); } else if (stelle.anlagenEnbez().isPresent()) { enbez = stelle.anlagenEnbez().get(); + // Wie im StellenAufloeser: Die Nummer einer Anlage kann eine eigene Norm sein. + var nummer = stelle.nummer(); + if (nummer.isPresent() + && StellenAufloeser.normIndex( + gesetzAus(normen), enbez + " Nummer " + nummer.get().nummer()) + >= 0) { + enbez = enbez + " Nummer " + nummer.get().nummer(); + } } else { return new NormAufloesung( -1, @@ -2134,20 +2241,37 @@ public final class BefehlAnwender { } /** Wahr, wenn die Stelle als Ganzes eine Norm meint: ein einzelner § oder ein Anhang/Anlage. */ - private static boolean nurNorm(Stelle stelle) { - return stelle.komponenten().size() == 1 + private static boolean nurNorm(List normen, Stelle stelle) { + if (stelle.komponenten().size() == 1 && (stelle.komponenten().get(0) instanceof Stelle.Paragraph - || stelle.anlagenEnbez().isPresent()); + || stelle.anlagenEnbez().isPresent())) { + return true; + } + // „Nummer 31 wird wie folgt gefasst“ innerhalb einer Anlage: Trägt das Gesetz die Nummer als + // eigene Norm, so bezeichnet die Stelle diese ganz und nicht einen Teil der Anlage. + return stelle.komponenten().size() == 2 + && stelle.anlagenEnbez().isPresent() + && stelle.nummer().isPresent() + && StellenAufloeser.normIndex( + gesetzAus(normen), + stelle.anlagenEnbez().get() + " Nummer " + stelle.nummer().get().nummer()) + >= 0; } + /** + * Die feinste Komponente der Stelle ist ein Absatz — es geht also um den Absatz selbst und nicht + * um einen Satz, eine Nummer oder einen Buchstaben in ihm. + * + *

Maßgeblich ist die letzte Komponente, nicht das bloße Vorkommen einer feineren: Die + * Nummer einer Anlage steht vor der Absatzangabe und gehört zur Bezeichnung der Norm („Anlage + * Nummer 23 Absatz 9“), nicht zu ihrem Inneren. Wer sie mitzählte, hielte die Umnummerierung + * eines solchen Absatzes für die einer Aufzählungseinheit und tauschte eine Marke „23.“ im Text — + * die dort nicht steht. Der Befehl galt dann als angewandt und bewirkte nichts. + */ private static boolean feinsteIstAbsatz(Stelle stelle) { - return stelle.komponenten().stream() - .noneMatch( - k -> - k instanceof Stelle.SatzNr - || k instanceof Stelle.HalbsatzNr - || k instanceof Stelle.NummerNr - || k instanceof Stelle.BuchstabeNr); + var komponenten = stelle.komponenten(); + return !komponenten.isEmpty() + && komponenten.get(komponenten.size() - 1) instanceof Stelle.AbsatzNr; } /** @@ -2335,6 +2459,17 @@ public final class BefehlAnwender { var text = zitat.strip(); String titel = vorlage.titel(); + // Das Zitat einer neugefassten Anlagen-Nummer wiederholt deren Bezeichnung als erste Zeile + // („Nummer 31“ / „Landesamt …“ / „Zu den Ordnungsaufgaben …“). Sie gehört nicht in den + // Wortlaut — ebenso wenig, wie die Bezeichnung eines Paragraphen in seinen Text gehört. + var eigeneBezeichnung = enbez.replaceFirst("^.*?\\b(Nummer \\d+[a-z]?)$", "$1"); + if (!eigeneBezeichnung.equals(enbez)) { + var erste = text.lines().findFirst().orElse("").strip(); + if (erste.equals(eigeneBezeichnung)) { + text = text.substring(text.indexOf('\n') + 1).strip(); + } + } + var absatzStart = ABSATZ_MARKER.matcher(text); int erster = absatzStart.find() ? absatzStart.start() : -1; diff --git a/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java b/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java index fbc3f65..0bb2d91 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java @@ -49,6 +49,20 @@ final class StellenAufloeser { enbez = stelle.paragraph().get().enbez(); } else if (stelle.anlagenEnbez().isPresent()) { enbez = stelle.anlagenEnbez().get(); + // Die Nummern einer Anlage können eigene Einheiten mit eigener Absatzzählung sein — so führt + // sie das Landesrechtsportal, und so sprechen die Befehle sie an („Nach Nummer 6 Absatz 2 + // wird folgender Absatz 2a eingefügt“). Trägt das Gesetz eine solche Norm, ist sie gemeint; + // die Nummer ist damit verbraucht und wird nicht zusätzlich als Marke im Text gesucht. + // Fehlt sie, bleibt es beim bisherigen Weg (im gii-XML stehen die Nummern einer Anlage als + // Aufzählungsmarken in deren Wortlaut, etwa in Anlage 8 des Gebäudeenergiegesetzes). + var nummer = stelle.nummer(); + if (nummer.isPresent()) { + var alsNorm = enbez + " Nummer " + nummer.get().nummer(); + if (normIndex(gesetz, alsNorm) >= 0) { + enbez = alsNorm; + stelle = stelle.ohne(nummer.get()); + } + } } else { return new Ergebnis.NichtGefunden( "Stelle nennt keinen Paragraphen: " + stelle.anzeigeText(), diff --git a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java index d6a1d2e..ae1e861 100644 --- a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java +++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java @@ -124,6 +124,17 @@ final class LandesRechtTextParser { // findet entsprechend Anwendung.“) nicht fälschlich als Normkopf „§ 7“ gelesen. + "((?:\\p{Lu}|\\().*[^.]))?\\s*$"); + /** + * Der Kopf einer Nummer innerhalb einer Anlage („Nummer 6“). Die Nummern eines + * Zuständigkeitskatalogs sind keine Aufzählungsglieder, sondern eigene Einheiten mit eigener + * Absatzzählung — das Landesrechtsportal führt jede als eigene Einzelnorm, und die + * Änderungsbefehle sprechen sie so an („Nach Nummer 6 Absatz 2 wird folgender Absatz 2a + * eingefügt“). Sie werden deshalb als eigene Normen geführt, deren Bezeichnung die Anlage + * voranstellt: „Anlage Nummer 6“. + */ + private static final Pattern ANLAGEN_NUMMER_KOPF = + Pattern.compile("^(?:Nummer|Nr\\.)\\s+(\\d+[a-z]?)\\s*$"); + private static final Pattern WEGGEFALLEN_TITEL = Pattern.compile("^\\((?:aufgehoben|weggefallen)\\)$"); @@ -177,6 +188,7 @@ final class LandesRechtTextParser { // Überschriften („Erster Abschnitt“, „Nummer 6“) gliedern nicht das Gesetz, und ein Paragraph, // der in ihnen zitiert wird, eröffnet keine Norm. Nur eine weitere Anlage tut das. boolean imAnlagenteil = false; + String anlagenEnbez = null; String normEnbez = null; String normTitel = null; @@ -214,8 +226,16 @@ final class LandesRechtTextParser { && normKopf.matches() && istNeuerNormKopf(normKopf.group(2), letzteNormNummer); boolean neueAnlage = zeile != null && anlagenKopf.matches(); - - if (zeile == null || uebersicht || gliederungsZeile || neueNorm || neueAnlage) { + var anlagenNummer = + zeile != null && imAnlagenteil ? ANLAGEN_NUMMER_KOPF.matcher(zeile) : null; + boolean neueAnlagenNummer = anlagenNummer != null && anlagenNummer.matches(); + + if (zeile == null + || uebersicht + || gliederungsZeile + || neueNorm + || neueAnlage + || neueAnlagenNummer) { // Laufende Norm abschließen. if (normEnbez != null) { normen.add(baueNorm(normEnbez, normTitel, aktuelleGliederung, normZeilen)); @@ -261,12 +281,19 @@ final class LandesRechtTextParser { gliederungen.add(aktuelleGliederung); } else if (neueAnlage) { imAnlagenteil = true; - normEnbez = + anlagenEnbez = anlagenKopf.group(2) == null ? anlagenKopf.group(1) : anlagenKopf.group(1) + " " + anlagenKopf.group(2); + normEnbez = anlagenEnbez; normTitel = null; aktuelleGliederung = null; + } else if (neueAnlagenNummer) { + // „Anlage Nummer 6“ — die Anlage selbst bleibt als Norm vor ihnen stehen; trägt sie + // keinen eigenen Wortlaut, so ist sie eben leer. + normEnbez = + (anlagenEnbez != null ? anlagenEnbez + " " : "") + "Nummer " + anlagenNummer.group(1); + normTitel = null; } else if (neueNorm) { normEnbez = normKopf.group(1) + " " + normKopf.group(2); normTitel = normKopf.group(3) != null ? normKopf.group(3).strip() : null; -- cgit v1.2.1