aboutsummaryrefslogtreecommitdiff
path: root/src/main/java
diff options
context:
space:
mode:
authorMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-28 08:13:29 +0200
committerMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-28 08:13:29 +0200
commit0be6e41ca5ae1c16aab308931616f1ec84ea3eba (patch)
treed2d5342f687747b36c0ad00798df34c62ea73a95 /src/main/java
parent3203725daa2c8db9ce25ff4541dd4ea438ac5cd7 (diff)
Der Korpus sagt, woran die Reste im Ganzen liegen
Der Befund über das Erzeugnis ruhte auf handverlesenen Fällen. Rund drei Dutzend Akzeptanzprüfungen sagen, dass diese Hefte aufgehen; sie sagen nicht, woran die Reste im Ganzen liegen — ob an der Befehlssprache eines Landes, an der Aufbereitung des Druckwerks oder am Alter der eingesetzten Stammfassung. Die Auszählung der Gründe beantwortet das für ein Heft; über einen Korpus geführt beantwortet sie es für das Erzeugnis. Der Schalter --korpus arbeitet eine Auftragsliste ab und gibt je Auftrag eine Zeile Kennzahlen aus: erschlossene, angewandte, liegengebliebene und am Stichtag zurückgestellte Befehle, sodann die gleichen, geprüften, fehlenden, überzähligen und abweichenden Normen des Abgleichs, zuletzt die Gründe nach Häufigkeit. Die Liste nennt dieselben Angaben, die auch die Befehlszeile entgegennimmt — ein Auftrag ist nichts anderes als ein Lauf. Mit --synopsen bleiben die Einzelsynopsen samt einer Übersichtsseite stehen; ohne sie wäre der Bericht eine Zahlenreihe, aus der niemand den Grund eines Restes erführe. Eingecheckt ist der Bericht die Grundlinie. --grundlinie hält den Lauf dagegen und rügt allein, was fällt: weniger erschlossene oder angewandte Befehle, weniger gleiche Normen, mehr liegengebliebene, fehlende, überzählige oder abweichende. Ein Fortschritt ist kein Fehler, sondern der Zweck der Arbeit; er verlangt allein, die Grundlinie fortzuschreiben. Der Rückschritt endet mit 3 wie ein nicht aufgehender Abgleich. Ein gescheiterter Auftrag hält den Lauf nicht an, sondern trägt seinen Fehlertext in seiner Zeile — der Grundsatz der Nichtverwerfung gilt auch hier. Der mitgelieferte Korpus umfasst einunddreißig Aufträge und gibt 1413 Befehle her, von denen 1287 angewandt werden; 632 von 641 verglichenen Normen gehen auf. Er reproduziert die Zahlen der bestehenden Akzeptanzfälle (UWG 19/0, GEG 119/119, BayJG 154/154, Berlin 171/171, WDR 101, Bremen 41/37, Hamburg 21/17). Drei Aufträge tragen mit Absicht hohe Reste: Bei den beiden Beschlussempfehlungen und bei Rheinland-Pfalz schreibt die Vorlage eine ältere Fassung fort als die eingesetzte Stammfassung. Sie sind gleichwohl Wächter — steigen ihre Reste, so hat sich etwas verschlechtert. Nebenbei: Die Auszählung der Gründe stand doppelt (Befehlszeile und Synopse) und liegt nun in Pipeline.zaehleGruende; das Ergebnis der Pipeline führt sie mit. Geprüft: mvnw verify (443 Testfälle) und reuse lint (214/214) gehen durch; der Korpuslauf ist Teil der Prüfung. Die Rügeregel ist am ganzen Korpus erprobt: Eine um eins verschärfte Grundlinie führt zur Rüge „UWG-BGBl: angewandte Befehle 19 statt 20“ und zum Rückgabewert 3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Change-Id: I4e10b041b78d95772da1c3ff18ab92b0b45dacaa
Diffstat (limited to 'src/main/java')
-rw-r--r--src/main/java/eu/mulk/aendggner/AendGgner.java90
-rw-r--r--src/main/java/eu/mulk/aendggner/Pipeline.java30
-rw-r--r--src/main/java/eu/mulk/aendggner/bericht/Korpusbericht.java476
3 files changed, 587 insertions, 9 deletions
diff --git a/src/main/java/eu/mulk/aendggner/AendGgner.java b/src/main/java/eu/mulk/aendggner/AendGgner.java
index d089f21..3067a13 100644
--- a/src/main/java/eu/mulk/aendggner/AendGgner.java
+++ b/src/main/java/eu/mulk/aendggner/AendGgner.java
@@ -8,6 +8,7 @@ import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor;
import eu.mulk.aendggner.aenderung.parse.TextBereiniger;
import eu.mulk.aendggner.anwendung.BefehlAnwender;
import eu.mulk.aendggner.anwendung.Grund;
+import eu.mulk.aendggner.bericht.Korpusbericht;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
@@ -15,7 +16,6 @@ import java.nio.file.Path;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
-import java.util.EnumMap;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.logging.LogManager;
@@ -36,6 +36,7 @@ public class AendGgner implements Callable<Integer> {
@Parameters(
index = "0",
+ arity = "0..1",
paramLabel = "<base law>",
description =
"The base law: gii-norm XML from gesetze-im-internet.de, or — for state law — the"
@@ -134,6 +135,28 @@ public class AendGgner implements Callable<Integer> {
+ "exit code 3.")
private String nachfassung;
+ @Option(
+ names = "--korpus",
+ paramLabel = "<file>",
+ description =
+ "Run every job of the given tab-separated job list and write a report of key figures "
+ + "instead of a synopsis. Paths in the list are relative to the list's directory.")
+ private String korpus;
+
+ @Option(
+ names = "--grundlinie",
+ paramLabel = "<file>",
+ description =
+ "With --korpus: hold the run against this earlier report and complain about every figure "
+ + "that has fallen. A regression yields exit code 3.")
+ private String grundlinie;
+
+ @Option(
+ names = "--synopsen",
+ paramLabel = "<dir>",
+ description = "With --korpus: also write each job's synopsis into this directory.")
+ private String synopsen;
+
/** Die angegebenen Eingaben, jede über {@link Bezug} beschafft. */
private static List<Quelle> hole(List<String> angaben) throws IOException, InterruptedException {
var quellen = new ArrayList<Quelle>();
@@ -154,6 +177,15 @@ public class AendGgner implements Callable<Integer> {
log.debugf("Logging configured.");
+ if (korpus != null) {
+ return fuehreKorpusAus();
+ }
+
+ if (baseFile == null) {
+ System.err.println("Fehler: das Stammgesetz muss angegeben werden.");
+ return 1;
+ }
+
if (dumpGesetz) {
var gesetz = Pipeline.ladeStammgesetz(Bezug.hole(baseFile));
System.out.printf(
@@ -305,12 +337,7 @@ public class AendGgner implements Callable<Integer> {
* jedem Befehl darüber.
*/
private static void haeufigkeitDerGruende(BefehlAnwender.AnwendungsErgebnis anwendung) {
- var haeufigkeit = new EnumMap<Grund, Integer>(Grund.class);
- for (var eintrag : anwendung.protokoll()) {
- if (eintrag.status() == BefehlAnwender.Status.MANUELL_PRUEFEN && eintrag.grund() != null) {
- haeufigkeit.merge(eintrag.grund(), 1, Integer::sum);
- }
- }
+ var haeufigkeit = Pipeline.zaehleGruende(anwendung.protokoll());
if (haeufigkeit.isEmpty()) {
return;
}
@@ -320,6 +347,55 @@ public class AendGgner implements Callable<Integer> {
.forEach(e -> System.out.printf(" %4d %s%n", e.getValue(), e.getKey().bezeichnung()));
}
+ /**
+ * Der Massenlauf: die Aufträge der Liste nacheinander, eine Zeile Kennzahlen je Auftrag.
+ *
+ * <p>Der Bericht geht dahin, wohin sonst die Synopse ginge ({@code -o}). Ist eine Grundlinie
+ * angegeben, so wird der Lauf gegen sie gehalten; jede gefallene Kennzahl wird gerügt und der
+ * Lauf endet mit 3 — dieselbe Zahl, mit der ein nicht aufgehender Abgleich endet.
+ */
+ private Integer fuehreKorpusAus() throws IOException {
+ var liste = Path.of(korpus);
+ var wurzel = liste.toAbsolutePath().getParent();
+ var auftraege = Korpusbericht.liesListe(liste);
+ log.infof("Korpuslauf: %d Aufträge aus %s.", auftraege.size(), liste);
+
+ var zeilen =
+ Korpusbericht.fuehreAus(auftraege, wurzel, synopsen == null ? null : Path.of(synopsen));
+ var tsv = Korpusbericht.alsTsv(zeilen);
+ if (output == null || output.equals("-") || output.equals("synopse.html")) {
+ // Die Voreinstellung des Schalters meint die Synopse; ein Bericht gehört dann auf die
+ // Standardausgabe und nicht in eine Datei namens „synopse.html“.
+ System.out.print(tsv);
+ } else {
+ Files.writeString(Path.of(output), tsv, StandardCharsets.UTF_8);
+ System.out.println("Bericht geschrieben: " + output);
+ }
+ if (synopsen != null) {
+ var uebersicht = Path.of(synopsen).resolveSibling("uebersicht.html");
+ Files.writeString(
+ uebersicht,
+ Korpusbericht.alsHtml(zeilen, Path.of(synopsen).getFileName().toString()),
+ StandardCharsets.UTF_8);
+ System.out.println("Übersicht geschrieben: " + uebersicht);
+ }
+ System.out.println(Korpusbericht.summe(zeilen));
+
+ if (grundlinie == null) {
+ return zeilen.stream().anyMatch(z -> z.fehler() != null) ? 3 : 0;
+ }
+ var ruegen =
+ Korpusbericht.gegenGrundlinie(
+ zeilen, Files.readString(Path.of(grundlinie), StandardCharsets.UTF_8));
+ if (ruegen.isEmpty()) {
+ System.out.println("Die Grundlinie ist gehalten.");
+ return 0;
+ }
+ System.out.println("Rückschritte gegenüber der Grundlinie:");
+ ruegen.forEach(r -> System.out.println(" " + r));
+ return 3;
+ }
+
private static String kuerze(String text) {
var einzeilig = text.replaceAll("\\s+", " ");
return einzeilig.length() <= 160 ? einzeilig : einzeilig.substring(0, 157) + "…";
diff --git a/src/main/java/eu/mulk/aendggner/Pipeline.java b/src/main/java/eu/mulk/aendggner/Pipeline.java
index e4ce9fb..483d456 100644
--- a/src/main/java/eu/mulk/aendggner/Pipeline.java
+++ b/src/main/java/eu/mulk/aendggner/Pipeline.java
@@ -30,7 +30,9 @@ import java.io.IOException;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
+import java.util.EnumMap;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import org.jspecify.annotations.Nullable;
@@ -116,6 +118,11 @@ public final class Pipeline {
* @param abgleich der normweise Vergleich mit der amtlichen Nachfassung; {@code null}, wenn der
* Auftrag keine nannte.
*/
+ /**
+ * @param gruende die Auszählung der Gründe, aus denen Befehle liegengeblieben sind. Sie steht
+ * schon in der Synopse; als Zahl herausgegeben trägt sie überdies den Korpusbericht (§ 6d),
+ * der über viele Hefte hinweg zeigt, woran die Reste liegen.
+ */
public record Ergebnis(
String html,
long anzahlAngewandt,
@@ -123,7 +130,25 @@ public final class Pipeline {
int anzahlGeaenderteNormen,
int anzahlProtokollEintraege,
String neufassung,
- @Nullable Nachfassungsabgleich abgleich) {}
+ @Nullable Nachfassungsabgleich abgleich,
+ Map<Grund, Integer> gruende) {
+
+ public Ergebnis {
+ gruende = Map.copyOf(gruende);
+ }
+ }
+
+ /** Die Gründe der liegengebliebenen Befehle, nach Häufigkeit auszählbar. */
+ public static Map<Grund, Integer> zaehleGruende(
+ List<BefehlAnwender.AngewandteAenderung> protokoll) {
+ var haeufigkeit = new EnumMap<Grund, Integer>(Grund.class);
+ for (var eintrag : protokoll) {
+ if (eintrag.status() == BefehlAnwender.Status.MANUELL_PRUEFEN && eintrag.grund() != null) {
+ haeufigkeit.merge(eintrag.grund(), 1, Integer::sum);
+ }
+ }
+ return haeufigkeit;
+ }
/**
* Ein eingespeistes Änderungsdokument samt erkannter Art und aufbereitetem Text.
@@ -257,7 +282,8 @@ public final class Pipeline {
synopse.eintraege().size(),
protokoll.size(),
LandesRechtTextAusgeber.ausgeben(gesetz),
- abgleich);
+ abgleich,
+ zaehleGruende(protokoll));
}
/**
diff --git a/src/main/java/eu/mulk/aendggner/bericht/Korpusbericht.java b/src/main/java/eu/mulk/aendggner/bericht/Korpusbericht.java
new file mode 100644
index 0000000..1daba42
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/bericht/Korpusbericht.java
@@ -0,0 +1,476 @@
+// SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+package eu.mulk.aendggner.bericht;
+
+import eu.mulk.aendggner.Pipeline;
+import eu.mulk.aendggner.Quelle;
+import eu.mulk.aendggner.anwendung.Grund;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Der Massenlauf: viele Hefte in einem Durchgang, eine Zeile Kennzahlen je Heft.
+ *
+ * <p>Wozu: Ein Belegfall sagt, dass <em>dieses</em> Heft aufgeht. Er sagt nicht, woran die Reste im
+ * Ganzen liegen — ob an der Befehlssprache eines Landes, an der Aufbereitung des Druckwerks oder am
+ * Alter der Stammfassung. Die Auszählung der Gründe (§ 1 Absatz 5 des Handbuchs) beantwortet das
+ * für ein Heft; über einen Korpus geführt beantwortet sie es für das Erzeugnis.
+ *
+ * <p>Der Bericht ist zugleich eine Grundlinie: Wird er eingecheckt, so schlägt jeder Lauf an, bei
+ * dem eine Kennzahl <em>fällt</em> — weniger erschlossene Befehle, weniger angewandte, weniger
+ * gleiche Normen. Steigt eine, ist die Grundlinie fortzuschreiben; das ist der Vermerk der Welle.
+ *
+ * <p>Verworfen wird auch hier nichts: Ein Auftrag, der mit einem Fehler abbricht, hinterlässt seine
+ * Zeile samt Fehlertext und hält den Lauf nicht an.
+ */
+public final class Korpusbericht {
+
+ private Korpusbericht() {}
+
+ /** Kein Wert — in der Liste wie im Bericht. */
+ private static final String LEER = "-";
+
+ /**
+ * Ein Auftrag der Liste.
+ *
+ * @param bezeichnung der Name, unter dem der Auftrag im Bericht und in der Grundlinie steht.
+ * @param stamm die Stammfassung, relativ zum Verzeichnis der Liste.
+ * @param hefte die Änderungsdokumente, in der Reihenfolge der Liste.
+ * @param artikel nur diesen Artikel anwenden; {@code null} = alle betroffenen.
+ * @param nachfassung die amtliche Nachfassung; {@code null} = kein Abgleich, dann bleiben dessen
+ * Spalten leer.
+ * @param stichtag die Fassung dieses Tages; {@code null} = alle Befehle anwenden.
+ */
+ public record Auftrag(
+ String bezeichnung,
+ String stamm,
+ List<String> hefte,
+ @Nullable String artikel,
+ @Nullable String nachfassung,
+ @Nullable LocalDate stichtag) {
+
+ public Auftrag {
+ hefte = List.copyOf(hefte);
+ }
+ }
+
+ /**
+ * Die Kennzahlen eines Laufes.
+ *
+ * @param fehler der Grund des Abbruchs; {@code null}, wenn der Auftrag durchgelaufen ist.
+ */
+ public record Zeile(
+ String bezeichnung,
+ int befehle,
+ long angewandt,
+ long manuell,
+ long zurueckgestellt,
+ @Nullable Integer gleich,
+ @Nullable Integer geprueft,
+ @Nullable Integer fehlend,
+ @Nullable Integer ueberzaehlig,
+ @Nullable Integer abweichend,
+ Map<Grund, Integer> gruende,
+ @Nullable String fehler) {
+
+ public Zeile {
+ gruende = Map.copyOf(gruende);
+ }
+ }
+
+ /** Die Spaltenüberschriften des Berichts; sie stehen als Kommentarzeile obenan. */
+ private static final List<String> SPALTEN =
+ List.of(
+ "Bezeichnung",
+ "Befehle",
+ "angewandt",
+ "manuell",
+ "zurückgestellt",
+ "gleich",
+ "geprüft",
+ "fehlend",
+ "überzählig",
+ "abweichend",
+ "Gründe");
+
+ // ---------------------------------------------------------------- Liste lesen
+
+ /**
+ * Liest die Auftragsliste. Sie ist eine Tabulatortabelle mit den Spalten Bezeichnung,
+ * Stammfassung, Hefte (durch Komma getrennt), Artikel, Nachfassung, Stichtag; „-“ steht für
+ * „nicht angegeben“. Zeilen, die mit „#“ beginnen, und Leerzeilen werden übergangen.
+ */
+ public static List<Auftrag> liesListe(Path liste) throws IOException {
+ var auftraege = new ArrayList<Auftrag>();
+ int nummer = 0;
+ for (var zeile : Files.readAllLines(liste, StandardCharsets.UTF_8)) {
+ nummer++;
+ if (zeile.isBlank() || zeile.startsWith("#")) {
+ continue;
+ }
+ var felder = zeile.split("\t");
+ if (felder.length < 3) {
+ throw new IOException(
+ "%s, Zeile %d: Ein Auftrag braucht wenigstens Bezeichnung, Stammfassung und Heft."
+ .formatted(liste, nummer));
+ }
+ auftraege.add(
+ new Auftrag(
+ felder[0].strip(),
+ felder[1].strip(),
+ List.of(felder[2].strip().split("\\s*,\\s*")),
+ feld(felder, 3),
+ feld(felder, 4),
+ feld(felder, 5) == null ? null : LocalDate.parse(feld(felder, 5))));
+ }
+ return auftraege;
+ }
+
+ private static @Nullable String feld(String[] felder, int index) {
+ if (index >= felder.length) {
+ return null;
+ }
+ var wert = felder[index].strip();
+ return wert.isEmpty() || wert.equals(LEER) ? null : wert;
+ }
+
+ // ---------------------------------------------------------------- Lauf
+
+ /**
+ * Führt alle Aufträge aus.
+ *
+ * @param wurzel das Verzeichnis, auf das sich die Pfade der Liste beziehen.
+ * @param synopsen wohin die Einzelsynopsen zu legen sind; {@code null} = keine ablegen.
+ */
+ public static List<Zeile> fuehreAus(
+ List<Auftrag> auftraege, Path wurzel, @Nullable Path synopsen) {
+ var zeilen = new ArrayList<Zeile>(auftraege.size());
+ for (var auftrag : auftraege) {
+ zeilen.add(fuehreAus(auftrag, wurzel, synopsen));
+ }
+ return zeilen;
+ }
+
+ public static Zeile fuehreAus(Auftrag auftrag, Path wurzel, @Nullable Path synopsen) {
+ try {
+ var hefte = new ArrayList<Quelle>(auftrag.hefte().size());
+ for (var heft : auftrag.hefte()) {
+ hefte.add(Quelle.lies(wurzel.resolve(heft)));
+ }
+ var ergebnis =
+ Pipeline.erzeugeSynopse(
+ new Pipeline.Auftrag(
+ Quelle.lies(wurzel.resolve(auftrag.stamm())),
+ hefte,
+ auftrag.artikel(),
+ false,
+ auftrag.stichtag(),
+ auftrag.nachfassung() == null
+ ? null
+ : Quelle.lies(wurzel.resolve(auftrag.nachfassung()))));
+ if (synopsen != null) {
+ Files.createDirectories(synopsen);
+ Files.writeString(
+ synopsen.resolve(dateiname(auftrag.bezeichnung()) + ".html"),
+ ergebnis.html(),
+ StandardCharsets.UTF_8);
+ }
+ var abgleich = ergebnis.abgleich();
+ return new Zeile(
+ auftrag.bezeichnung(),
+ ergebnis.anzahlProtokollEintraege(),
+ ergebnis.anzahlAngewandt(),
+ ergebnis.anzahlManuell(),
+ // Was am Stichtag noch nicht galt, ist weder angewandt noch liegengeblieben.
+ ergebnis.anzahlProtokollEintraege()
+ - ergebnis.anzahlAngewandt()
+ - ergebnis.anzahlManuell(),
+ abgleich == null ? null : abgleich.gleich(),
+ abgleich == null ? null : abgleich.geprueft(),
+ abgleich == null ? null : abgleich.fehlende().size(),
+ abgleich == null ? null : abgleich.ueberzaehlige().size(),
+ abgleich == null ? null : abgleich.abweichungen().size(),
+ ergebnis.gruende(),
+ null);
+ } catch (Exception e) {
+ // Ein gescheiterter Auftrag hält den Lauf nicht an; sein Fehler steht in seiner Zeile.
+ return new Zeile(
+ auftrag.bezeichnung(),
+ 0,
+ 0,
+ 0,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ Map.of(),
+ e.getClass().getSimpleName() + ": " + e.getMessage());
+ }
+ }
+
+ private static String dateiname(String bezeichnung) {
+ return bezeichnung.replaceAll("[^\\p{L}\\p{N}]+", "-");
+ }
+
+ // ---------------------------------------------------------------- Ausgabe
+
+ /** Der Bericht als Tabulatortabelle — die Form, in der er auch als Grundlinie liegt. */
+ public static String alsTsv(List<Zeile> zeilen) {
+ var sb = new StringBuilder("# ").append(String.join("\t", SPALTEN)).append('\n');
+ for (var zeile : zeilen) {
+ sb.append(zeile.bezeichnung())
+ .append('\t')
+ .append(zeile.befehle())
+ .append('\t')
+ .append(zeile.angewandt())
+ .append('\t')
+ .append(zeile.manuell())
+ .append('\t')
+ .append(zeile.zurueckgestellt())
+ .append('\t')
+ .append(zahl(zeile.gleich()))
+ .append('\t')
+ .append(zahl(zeile.geprueft()))
+ .append('\t')
+ .append(zahl(zeile.fehlend()))
+ .append('\t')
+ .append(zahl(zeile.ueberzaehlig()))
+ .append('\t')
+ .append(zahl(zeile.abweichend()))
+ .append('\t')
+ .append(gruendeText(zeile.gruende()));
+ if (zeile.fehler() != null) {
+ sb.append('\t').append(zeile.fehler().replace('\t', ' ').replace('\n', ' '));
+ }
+ sb.append('\n');
+ }
+ return sb.toString();
+ }
+
+ private static String zahl(@Nullable Integer wert) {
+ return wert == null ? LEER : String.valueOf(wert);
+ }
+
+ /** „Zieltext nicht vorhanden:3; Befehl nicht erkannt:1“ — in der Reihenfolge der Gründe. */
+ static String gruendeText(Map<Grund, Integer> gruende) {
+ if (gruende.isEmpty()) {
+ return LEER;
+ }
+ var geordnet = new EnumMap<>(gruende);
+ var sb = new StringBuilder();
+ for (var eintrag : geordnet.entrySet()) {
+ if (sb.length() > 0) {
+ sb.append("; ");
+ }
+ sb.append(eintrag.getKey().bezeichnung()).append(':').append(eintrag.getValue());
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Der Bericht als Übersichtsseite. Sie ist kein Ersatz für die Tabulatortabelle, sondern deren
+ * Lesefassung: Jede Zeile verweist auf die Synopse ihres Auftrags, sodass sich ein Rest an seinem
+ * eigenen Dokument nachschlagen lässt.
+ *
+ * @param verzeichnis das Verzeichnis der Einzelsynopsen, relativ zur Übersicht; {@code null} =
+ * ohne Verweise.
+ */
+ public static String alsHtml(List<Zeile> zeilen, @Nullable String verzeichnis) {
+ var sb = new StringBuilder();
+ sb.append("<!DOCTYPE html>\n<html lang=\"de\"><head><meta charset=\"utf-8\">\n")
+ .append("<title>Korpusbericht — ÄndGgner</title>\n")
+ .append("<style>\n")
+ .append("body { font-family: system-ui, sans-serif; margin: 2rem; max-width: 70rem; }\n")
+ .append("table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }\n")
+ .append("th, td { border: 1px solid #999; padding: 0.25rem 0.5rem; text-align: right; }\n")
+ .append("th:first-child, td:first-child { text-align: left; }\n")
+ .append("td.gruende { text-align: left; font-size: 0.8rem; }\n")
+ .append("tr.rest td { background: #fff6f2; }\n")
+ .append("caption { text-align: left; padding-bottom: 0.5rem; }\n")
+ .append("</style></head><body>\n")
+ .append("<h1>Korpusbericht</h1>\n<p>")
+ .append(esc(summe(zeilen)))
+ .append("</p>\n<table><thead><tr>");
+ for (var spalte : SPALTEN) {
+ sb.append("<th>").append(esc(spalte)).append("</th>");
+ }
+ sb.append("</tr></thead><tbody>\n");
+ for (var zeile : zeilen) {
+ sb.append(zeile.manuell() > 0 || zeile.fehler() != null ? "<tr class=\"rest\">" : "<tr>");
+ sb.append("<td>");
+ if (verzeichnis == null) {
+ sb.append(esc(zeile.bezeichnung()));
+ } else {
+ sb.append("<a href=\"")
+ .append(esc(verzeichnis))
+ .append('/')
+ .append(esc(dateiname(zeile.bezeichnung())))
+ .append(".html\">")
+ .append(esc(zeile.bezeichnung()))
+ .append("</a>");
+ }
+ sb.append("</td>");
+ for (var wert :
+ List.of(
+ String.valueOf(zeile.befehle()),
+ String.valueOf(zeile.angewandt()),
+ String.valueOf(zeile.manuell()),
+ String.valueOf(zeile.zurueckgestellt()),
+ zahl(zeile.gleich()),
+ zahl(zeile.geprueft()),
+ zahl(zeile.fehlend()),
+ zahl(zeile.ueberzaehlig()),
+ zahl(zeile.abweichend()))) {
+ sb.append("<td>").append(esc(wert)).append("</td>");
+ }
+ sb.append("<td class=\"gruende\">")
+ .append(esc(zeile.fehler() != null ? zeile.fehler() : gruendeText(zeile.gruende())))
+ .append("</td></tr>\n");
+ }
+ return sb.append("</tbody></table>\n</body></html>\n").toString();
+ }
+
+ private static String esc(String text) {
+ return text.replace("&", "&amp;")
+ .replace("<", "&lt;")
+ .replace(">", "&gt;")
+ .replace("\"", "&quot;");
+ }
+
+ /** Die Summe über den Korpus — die Zahl, die eine Welle vor und nach sich nennt. */
+ public static String summe(List<Zeile> zeilen) {
+ long befehle = zeilen.stream().mapToLong(Zeile::befehle).sum();
+ long angewandt = zeilen.stream().mapToLong(Zeile::angewandt).sum();
+ long manuell = zeilen.stream().mapToLong(Zeile::manuell).sum();
+ long gleich = zeilen.stream().filter(z -> z.gleich() != null).mapToLong(Zeile::gleich).sum();
+ long geprueft =
+ zeilen.stream().filter(z -> z.geprueft() != null).mapToLong(Zeile::geprueft).sum();
+ long gescheitert = zeilen.stream().filter(z -> z.fehler() != null).count();
+ var sb =
+ new StringBuilder(
+ "%d Aufträge, %d Befehle, %d angewandt, %d manuell; %d von %d Normen gleich"
+ .formatted(zeilen.size(), befehle, angewandt, manuell, gleich, geprueft));
+ if (gescheitert > 0) {
+ sb.append("; %d Aufträge gescheitert".formatted(gescheitert));
+ }
+ return sb.toString();
+ }
+
+ // ---------------------------------------------------------------- Grundlinie
+
+ /**
+ * Hält den Lauf gegen die Grundlinie und meldet jede Kennzahl, die <em>gefallen</em> ist.
+ *
+ * <p>Nur der Rückschritt wird gerügt. Ein Fortschritt ist kein Fehler, sondern der Zweck der
+ * Arbeit; er verlangt allein, die Grundlinie fortzuschreiben.
+ *
+ * @return die Rügen; leer, wenn nichts gefallen ist.
+ */
+ public static List<String> gegenGrundlinie(List<Zeile> lauf, String grundlinie) {
+ var alt = liesGrundlinie(grundlinie);
+ var ruegen = new ArrayList<String>();
+ for (var zeile : lauf) {
+ if (zeile.fehler() != null) {
+ ruegen.add("%s: gescheitert — %s".formatted(zeile.bezeichnung(), zeile.fehler()));
+ continue;
+ }
+ var frueher = alt.get(zeile.bezeichnung());
+ if (frueher == null) {
+ // Ein neuer Auftrag ist kein Rückschritt; die Grundlinie ist bloß noch nicht
+ // fortgeschrieben.
+ continue;
+ }
+ pruefe(
+ ruegen, zeile.bezeichnung(), "erschlossene Befehle", zeile.befehle(), frueher[1], true);
+ pruefe(
+ ruegen, zeile.bezeichnung(), "angewandte Befehle", zeile.angewandt(), frueher[2], true);
+ pruefe(
+ ruegen,
+ zeile.bezeichnung(),
+ "liegengebliebene Befehle",
+ zeile.manuell(),
+ frueher[3],
+ false);
+ pruefe(ruegen, zeile.bezeichnung(), "gleiche Normen", wert(zeile.gleich()), frueher[5], true);
+ pruefe(
+ ruegen, zeile.bezeichnung(), "fehlende Normen", wert(zeile.fehlend()), frueher[7], false);
+ pruefe(
+ ruegen,
+ zeile.bezeichnung(),
+ "überzählige Normen",
+ wert(zeile.ueberzaehlig()),
+ frueher[8],
+ false);
+ pruefe(
+ ruegen,
+ zeile.bezeichnung(),
+ "abweichende Normen",
+ wert(zeile.abweichend()),
+ frueher[9],
+ false);
+ }
+ for (var bezeichnung : alt.keySet()) {
+ if (lauf.stream().noneMatch(z -> z.bezeichnung().equals(bezeichnung))) {
+ ruegen.add(
+ "%s: steht in der Grundlinie, ist im Lauf aber nicht vorgekommen."
+ .formatted(bezeichnung));
+ }
+ }
+ return ruegen;
+ }
+
+ private static long wert(@Nullable Integer zahl) {
+ return zahl == null ? Long.MIN_VALUE : zahl;
+ }
+
+ /**
+ * @param hoeherIstBesser ob ein Fallen der Zahl den Rückschritt bedeutet (angewandte Befehle,
+ * gleiche Normen) oder ihr Steigen (liegengebliebene, abweichende).
+ */
+ private static void pruefe(
+ List<String> ruegen,
+ String bezeichnung,
+ String kennzahl,
+ long jetzt,
+ String frueherText,
+ boolean hoeherIstBesser) {
+ if (frueherText.equals(LEER) || jetzt == Long.MIN_VALUE) {
+ return;
+ }
+ long frueher;
+ try {
+ frueher = Long.parseLong(frueherText);
+ } catch (NumberFormatException e) {
+ return;
+ }
+ boolean rueckschritt = hoeherIstBesser ? jetzt < frueher : jetzt > frueher;
+ if (rueckschritt) {
+ ruegen.add("%s: %s %d statt %d.".formatted(bezeichnung, kennzahl, jetzt, frueher));
+ }
+ }
+
+ private static Map<String, String[]> liesGrundlinie(String tsv) {
+ var zeilen = new LinkedHashMap<String, String[]>();
+ for (var zeile : tsv.split("\n")) {
+ if (zeile.isBlank() || zeile.startsWith("#")) {
+ continue;
+ }
+ var felder = zeile.split("\t");
+ if (felder.length >= 10) {
+ zeilen.put(felder[0].strip(), felder);
+ }
+ }
+ return zeilen;
+ }
+}