session-orchestrator 3.21.0 → 3.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/040-discovery.mdc +6 -6
  6. package/.cursor/rules/050-plan.mdc +8 -8
  7. package/CHANGELOG.md +101 -0
  8. package/README.md +10 -10
  9. package/agents/memory-proposal-collector.md +6 -4
  10. package/commands/eli5.md +33 -0
  11. package/commands/release.md +5 -3
  12. package/commands/test.md +2 -2
  13. package/docs/components.md +6 -5
  14. package/docs/scope-collision-guard.md +3 -3
  15. package/docs/session-config-reference.md +31 -8
  16. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  17. package/hooks/hooks-codex.json +1 -1
  18. package/hooks/hooks.json +11 -1
  19. package/hooks/on-session-end.mjs +24 -92
  20. package/hooks/on-session-start.mjs +195 -104
  21. package/hooks/pre-auq-clarity.mjs +787 -0
  22. package/hooks/pre-bash-issue-budget.mjs +17 -18
  23. package/package.json +3 -1
  24. package/pi/prompts/eli5.md +12 -0
  25. package/scripts/auq-audit.mjs +825 -0
  26. package/scripts/autopilot.mjs +7 -8
  27. package/scripts/lib/auq/clarity.mjs +1314 -0
  28. package/scripts/lib/auq/parse.mjs +1006 -0
  29. package/scripts/lib/auq/schema.mjs +1457 -0
  30. package/scripts/lib/ci-status-banner.mjs +63 -57
  31. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  32. package/scripts/lib/config/vault-integration.mjs +12 -1
  33. package/scripts/lib/dispatcher/rank.mjs +4 -7
  34. package/scripts/lib/gates/gate-full.mjs +3 -3
  35. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  36. package/scripts/lib/io.mjs +239 -0
  37. package/scripts/lib/issue-budget.mjs +63 -9
  38. package/scripts/lib/owner-interview.mjs +78 -32
  39. package/scripts/lib/peer-discovery.mjs +73 -22
  40. package/scripts/lib/project-hygiene.mjs +64 -4
  41. package/scripts/lib/reconcile/renderer.mjs +17 -4
  42. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  43. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  44. package/scripts/lib/resource-probe.mjs +18 -2
  45. package/scripts/lib/spiral-carryover.mjs +23 -2
  46. package/scripts/lib/state-md/mission-status.mjs +147 -50
  47. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  48. package/scripts/lib/validate/check-hooks-symmetry.mjs +30 -0
  49. package/scripts/lib/validate/check-rules.mjs +153 -9
  50. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  51. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  52. package/scripts/lib/vcs-repo-spec.mjs +182 -13
  53. package/scripts/lib/wave-resource-gate.mjs +67 -73
  54. package/scripts/materialize-wave-scope.mjs +281 -0
  55. package/scripts/release.mjs +443 -122
  56. package/scripts/run-quality-gate.mjs +14 -0
  57. package/scripts/validate-plugin.mjs +3 -0
  58. package/scripts/validate-wave-scope.mjs +6 -1
  59. package/scripts/vault-backfill.mjs +32 -5
  60. package/skills/_shared/parallel-aware-auq.md +30 -24
  61. package/skills/_shared/parallel-aware-preamble.md +31 -2
  62. package/skills/_shared/state-ownership.md +32 -6
  63. package/skills/bootstrap/SKILL.md +2 -1
  64. package/skills/brainstorm/SKILL.md +18 -18
  65. package/skills/brainstorm/soul.md +12 -0
  66. package/skills/discovery/SKILL.md +28 -24
  67. package/skills/eli5/SKILL.md +43 -0
  68. package/skills/evolve/SKILL.md +8 -9
  69. package/skills/gitlab-ops/SKILL.md +30 -26
  70. package/skills/grill/SKILL.md +6 -6
  71. package/skills/grill/soul.md +16 -0
  72. package/skills/memory-cleanup/SKILL.md +2 -2
  73. package/skills/npm-publish/SKILL.md +4 -4
  74. package/skills/peekaboo-driver/SKILL.md +3 -3
  75. package/skills/plan/SKILL.md +18 -16
  76. package/skills/plan/mode-feature.md +1 -1
  77. package/skills/plan/mode-new.md +35 -23
  78. package/skills/plan/soul.md +12 -0
  79. package/skills/reconcile/SKILL.md +3 -3
  80. package/skills/session-end/SKILL.md +53 -20
  81. package/skills/session-end/phase-3-6-tail.md +37 -2
  82. package/skills/session-start/SKILL.md +69 -35
  83. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  84. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  85. package/skills/session-start/soul.md +110 -0
  86. package/skills/test-runner/SKILL.md +2 -2
  87. package/skills/using-orchestrator/SKILL.md +1 -1
  88. package/skills/wave-executor/wave-loop.md +27 -5
  89. package/skills/write-executable-plan/SKILL.md +6 -6
  90. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  91. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  92. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  93. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  94. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  95. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  96. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  97. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  98. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  99. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  100. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  101. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  102. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  103. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  104. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  105. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  106. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  107. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  108. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  109. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  110. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  111. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  112. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  113. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  114. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  115. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  116. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  117. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -0,0 +1,1457 @@
1
+ /**
2
+ * auq/schema.mjs — THE frozen contract for the AUQ-Klarheitsmessung (#1107).
3
+ *
4
+ * ## Warum diese Datei existiert
5
+ *
6
+ * Der Operator hat gemeldet, dass die Fragen, die dieses System ihm über
7
+ * `AskUserQuestion` stellt, zu technisch, zu lang und nicht entscheidbar sind.
8
+ * Welle 2 baut dagegen ein DETERMINISTISCHES Messprogramm — reines Node, kein
9
+ * LLM —, das jede Frage-Vorlage im Repo benotet. Drei Module bauen gleichzeitig
10
+ * dagegen. Was hier nicht steht, erfinden sie sich einzeln und widersprüchlich;
11
+ * was hier steht, ist eingefroren.
12
+ *
13
+ * Dieses Modul ist REINE DATEN: Konstanten, Typ-Dokumentation, Fabriken,
14
+ * Validierung. Kein Datei-IO, kein Parsen, keine Bewertungslogik. Die drei
15
+ * Grenzen sind absichtlich scharf — ein Schema, das mitbewertet, wird zum
16
+ * zweiten Ort, an dem eine Schwelle steht.
17
+ *
18
+ * ## Die drei Verträge, gegen die gebaut wird
19
+ *
20
+ * `scripts/lib/auq/parse.mjs` — findet die Vorlagen, misst nichts:
21
+ * parseFile({ file, content }) -> { blocks: AuqBlock[], warnings: string[] }
22
+ * parseRepo({ repoRoot, files }) -> { blocks: AuqBlock[], corpus, warnings }
23
+ * Baut jeden Datensatz über `makeOption` / `makeQuestion` / `makeBlock`,
24
+ * damit Vorgabewerte (`multiSelect: false`, `preview: null`) an EINER Stelle
25
+ * stehen. `corpus` kommt aus `emptyCorpus()` und wird hochgezählt.
26
+ *
27
+ * `scripts/lib/auq/clarity.mjs` — bewertet, parst nicht:
28
+ * scoreQuestion(question) -> AuqScore
29
+ * scoreBlocks(blocks) -> AuqScore[]
30
+ * Liest ALLE Schwellen aus `CRITERIA` / `THRESHOLDS` / `HURDLES`, baut jeden
31
+ * Befund über `makeFinding`, jedes Ergebnis über `makeScore`. Eine Zahl, die
32
+ * in clarity.mjs als Literal steht, ist ein Vertragsbruch.
33
+ * Punkteformel (hier festgelegt, dort ausgeführt):
34
+ *
35
+ * points = 100 − Σ CRITERIA[k].weight für jedes k, das für DIESE Frage
36
+ * mindestens einen `fail`-Befund hat
37
+ *
38
+ * Jedes Kriterium zieht höchstens EINMAL ab, egal wie viele Optionen es
39
+ * reißen — sonst bestraft dieselbe Schwäche eine Vier-Options-Frage doppelt
40
+ * so hart wie eine Zwei-Options-Frage. `warn`-Befunde ziehen NICHTS ab; sie
41
+ * sind Hinweise, keine Strafe. Ergebnis auf [0, 100] geklemmt.
42
+ *
43
+ * `scripts/auq-audit.mjs` (CLI) — fügt zusammen, misst nichts:
44
+ * buildReport({ blocks, scores, corpus, head, dirty }) -> AuqReport
45
+ * Prüft die eigene Ausgabe mit `validateReport` VOR dem Schreiben und gibt
46
+ * den `--json`-Umschlag über `writeStdoutLineSync` aus (`scripts/lib/io.mjs`),
47
+ * nie über `console.log` — stdout ist auf einer Pipe asynchron, und alles
48
+ * jenseits von ~64 KiB verwirft `process.exit()`.
49
+ *
50
+ * ## Zwei Messfallen, die hier strukturell zugemauert sind
51
+ *
52
+ * 1. CODEPOINTS, NICHT BYTES. `Buffer.byteLength('Evolve — Review')` ist 17,
53
+ * die Kopfzeile hat aber 15 Zeichen — bytebasiert gemessen reißt sie eine
54
+ * 12er-Grenze, die sie gar nicht reißt. Alle drei Module zählen über
55
+ * `codepointLength()`; `.length` ist ebenfalls falsch, sobald ein Emoji
56
+ * (astrales Zeichen) im Text steht.
57
+ *
58
+ * 2. OPTIONEN ZÄHLT MAN PRO FRAGE, NICHT PRO BLOCK. Blockweise gezählt scheint
59
+ * `skills/plan/SKILL.md:137` mit 14 Optionen gegen H2 zu verstoßen; es ist
60
+ * aber ein legaler Vierfragen-Block (4 Fragen × 3–4 Optionen). `AuqBlock`
61
+ * trägt darum 1–4 `AuqQuestion`, und H2 gilt je `AuqQuestion`.
62
+ *
63
+ * ## Der Schutz, der niemals wegoptimiert werden darf
64
+ *
65
+ * `skills/session-start/soul.md` § "Never traded for brevity" schützt fünf
66
+ * Klassen (Eingabevalidierung, Fehlerbehandlung/-meldungen, Sicherheitsbefunde
67
+ * und Bestätigungen zerstörender Aktionen, Zugänglichkeit der Ausgabe, alles
68
+ * ausdrücklich Verlangte). Ein Text, der auf `SAFETY_PATTERN` passt, ist von
69
+ * ALLEN Längenkriterien befreit (`LENGTH_CRITERIA` = K1, K6) und trägt im
70
+ * Befund `exempt: 'length'` — damit ein späterer automatischer Kürzungsschritt
71
+ * ihn nie stillschweigend aufgreifen kann. K3/K4/K5/K7/K8 gelten weiter: die
72
+ * FÜGEN Information HINZU und entfernen nie welche.
73
+ *
74
+ * Gemessen am 2026-08-22: die 9 sicherheitsrelevanten Beschreibungen im Repo
75
+ * haben Median 83 und Maximum 99 Zeichen, liegen also alle unter der
76
+ * WARN-Grenze von 120. Die Kollision tritt heute NICHT auf — die Ausnahme ist
77
+ * Vorsorge, kein Pflaster.
78
+ *
79
+ * Querverweise:
80
+ * - `.claude/rules/ask-via-tool.md` (AUQ-001..005 — die Regel, die gemessen wird)
81
+ * - `skills/session-start/soul.md` § "Never traded for brevity"
82
+ * - `scripts/lib/tests-src-ratio.mjs` (Form eines Mess-Moduls mit --json-Umschlag)
83
+ * - Issue #1107
84
+ */
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Umschlag-Version + geschlossene Aufzählungen
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /** Schema-Marke im `--json`-Umschlag. Bricht der Vertrag, steigt die Zahl. */
91
+ export const SCHEMA_VERSION = 'auq-clarity/1';
92
+
93
+ /**
94
+ * Herkunftsklassen des Korpus. Ein Datensatz weiß, WOHER er kommt, weil eine
95
+ * Prosa-Erwähnung einer Frage anders zu werten ist als eine echte Vorlage.
96
+ */
97
+ export const POPULATIONS = Object.freeze([
98
+ 'A', // ausgeführter Tool-Aufruf in einem Skill-Körper
99
+ 'B', // Vorlage in `commands/` oder `agents/`
100
+ 'C-mdc', // Beispiel in einer Regel-/Doku-Datei
101
+ 'C-mjs', // Vorlage in einem `.mjs`-Modul (String-Literal)
102
+ 'C-prose', // in Fließtext beschriebene Frage, kein Tool-Aufruf
103
+ 'C-hybrid', // Mischform: Vorlage im Fließtext eingebettet
104
+ ]);
105
+
106
+ /** Vorlage (wird wirklich gestellt) vs. Illustration (zeigt nur die Form). */
107
+ export const KINDS = Object.freeze(['template', 'illustration']);
108
+
109
+ /** Wie der Text im Quelltext eingefasst ist — entscheidet über die Auszugsform. */
110
+ export const QUOTINGS = Object.freeze(['double', 'backtick', 'prose']);
111
+
112
+ /** Worauf sich ein Befund bezieht. */
113
+ export const TARGETS = Object.freeze(['question', 'option', 'header']);
114
+
115
+ /** Zwei Stufen, mehr nicht. `fail` zieht Punkte, `warn` nie. */
116
+ export const SEVERITIES = Object.freeze(['fail', 'warn']);
117
+
118
+ /** Wofür ein Kriterium überhaupt zuständig ist. */
119
+ export const SCOPES = Object.freeze(['question', 'option', 'block']);
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Die Kriterien-Registry
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /**
126
+ * Die acht Kriterien, aus einer Discovery-Analyse am echten Korpus abgeleitet.
127
+ *
128
+ * `weight` ist der Punktabzug bei mindestens einem `fail`-Befund. Die Summe der
129
+ * Gewichte ist 100 (K2 wiegt 0 — reiner Hinweis; K5 ist keine Punktsache,
130
+ * sondern die harte Hürde H1). Diese Summe ist eine Invariante: fällt sie
131
+ * auseinander, verlieren die Notenbänder ihren Bezug.
132
+ *
133
+ * `severity` nennt die HÖCHSTE Stufe, die das Kriterium erreichen kann — K2
134
+ * kann nie `fail` werden, egal wie lang der Satz ist.
135
+ *
136
+ * @type {Readonly<Record<string, Readonly<{id:string,title:string,weight:number,severity:'fail'|'warn',appliesTo:'question'|'option'|'block',hurdle:string|null,measures:string}>>>}
137
+ */
138
+ export const CRITERIA = Object.freeze({
139
+ K1: Object.freeze({
140
+ id: 'K1',
141
+ title: 'Payload-Form',
142
+ weight: 20,
143
+ severity: 'fail',
144
+ appliesTo: 'question',
145
+ hurdle: null,
146
+ measures:
147
+ 'Zeichen im Fragetext, eingebettete Zeilenumbrüche, Code-Block, Tabellenzeile — ' +
148
+ 'die Frage ist eine Frage, kein Bericht.',
149
+ }),
150
+ K2: Object.freeze({
151
+ id: 'K2',
152
+ title: 'Satzlänge',
153
+ weight: 0,
154
+ severity: 'warn',
155
+ appliesTo: 'question',
156
+ hurdle: null,
157
+ measures: 'Längster Satz in Wörtern. Nur ein Hinweis, nie ein Fehler.',
158
+ }),
159
+ K3: Object.freeze({
160
+ id: 'K3',
161
+ title: 'Beschreibung wiederholt Label',
162
+ weight: 10,
163
+ severity: 'fail',
164
+ appliesTo: 'option',
165
+ hurdle: null,
166
+ measures:
167
+ 'Neue Wörter in der Beschreibung gegenüber dem Label, und wie viel vom Label ' +
168
+ 'die Beschreibung nur wiederholt.',
169
+ }),
170
+ K4: Object.freeze({
171
+ id: 'K4',
172
+ title: 'Empfehlung ohne Grund',
173
+ weight: 25,
174
+ severity: 'fail',
175
+ appliesTo: 'option',
176
+ hurdle: null,
177
+ measures:
178
+ 'Nennt die Option einen Grund, einen Preis, eine Folge oder einen Vergleich? ' +
179
+ 'Eine Empfehlung ohne all das ist eine Behauptung.',
180
+ }),
181
+ K5: Object.freeze({
182
+ id: 'K5',
183
+ title: 'Header-Grenze',
184
+ weight: 0,
185
+ severity: 'fail',
186
+ appliesTo: 'block',
187
+ hurdle: 'H1',
188
+ measures:
189
+ 'Zeichen der Kopfzeile. Über 12 schneidet das Tool selbst ab — das ist keine ' +
190
+ 'Stilfrage, sondern eine harte Grenze.',
191
+ }),
192
+ K6: Object.freeze({
193
+ id: 'K6',
194
+ title: 'Längenbudget',
195
+ weight: 10,
196
+ severity: 'fail',
197
+ appliesTo: 'option',
198
+ hurdle: 'H2',
199
+ measures:
200
+ 'Zeichen je Beschreibung und Label, Nutzlast je Frage, Anzahl Optionen je Frage.',
201
+ }),
202
+ K7: Object.freeze({
203
+ id: 'K7',
204
+ title: 'Unerklärter Bezeichner',
205
+ weight: 25,
206
+ severity: 'fail',
207
+ appliesTo: 'option',
208
+ hurdle: null,
209
+ measures:
210
+ 'Bezeichner-förmige Wörter in einer Beschreibung, die weder allgemein bekannt ' +
211
+ 'noch danebenerklärt sind. Absolute Zahl, keine Dichte.',
212
+ }),
213
+ K8: Object.freeze({
214
+ id: 'K8',
215
+ title: 'Geh selbst nachsehen',
216
+ weight: 10,
217
+ severity: 'fail',
218
+ appliesTo: 'option',
219
+ hurdle: null,
220
+ measures:
221
+ 'Verweist die Option darauf, dass der Operator erst eine Datei öffnen muss, ' +
222
+ 'um entscheiden zu können?',
223
+ }),
224
+ });
225
+
226
+ /** Stabile Reihenfolge für Berichte und Zusammenfassungen. */
227
+ export const CRITERION_IDS = Object.freeze(Object.keys(CRITERIA));
228
+
229
+ /**
230
+ * Summe aller Gewichte. 100 per Konstruktion — `points` ist deshalb direkt eine
231
+ * Prozentzahl und braucht keine zweite Normierung.
232
+ */
233
+ export const TOTAL_WEIGHT = CRITERION_IDS.reduce((sum, id) => sum + CRITERIA[id].weight, 0);
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Die zwei harten Hürden
237
+ // ---------------------------------------------------------------------------
238
+
239
+ /**
240
+ * Hürden sind KEINE gewichteten Kriterien. Eine gerissene Hürde ergibt Note F,
241
+ * unabhängig von der Punktzahl — deshalb stehen sie getrennt und werden in
242
+ * `AuqScore.hurdlesBroken` geführt, nicht in `points` verrechnet.
243
+ *
244
+ * @type {Readonly<Record<'H1'|'H2', Readonly<{id:string,title:string,rule:string,criterion:string,evidence:string}>>>}
245
+ */
246
+ export const HURDLES = Object.freeze({
247
+ H1: Object.freeze({
248
+ id: 'H1',
249
+ title: 'Kopfzeile höchstens 12 Zeichen',
250
+ rule: 'codepointLength(header) <= 12',
251
+ criterion: 'K5',
252
+ evidence:
253
+ 'Gemessen 2026-08-22: 26 von 42 Kopfzeilen-Literalen reißen diese Grenze (62 %), ' +
254
+ 'Spitzenwert 54 Zeichen. Das Tool schneidet ab — der Operator sieht den Rest nie.',
255
+ }),
256
+ H2: Object.freeze({
257
+ id: 'H2',
258
+ title: '2–4 Optionen je Frage, genau eine Empfehlung auf Platz 1',
259
+ rule: 'options.length zwischen 2 und 4 JE FRAGE, und isRecommended nur bei index 0',
260
+ criterion: 'K6',
261
+ evidence:
262
+ 'Pro Block gezählt meldet skills/plan/SKILL.md:137 fälschlich 14 Optionen — ' +
263
+ 'es ist ein legaler Vierfragen-Block. Der Zähler zählt je Frage.',
264
+ }),
265
+ });
266
+
267
+ /** Stabile Reihenfolge. */
268
+ export const HURDLE_IDS = Object.freeze(Object.keys(HURDLES));
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // Schwellen — die einzige Stelle, an der eine Zahl steht
272
+ // ---------------------------------------------------------------------------
273
+
274
+ /**
275
+ * Alle Schwellen, nach Kriterium gruppiert. clarity.mjs liest ausschließlich
276
+ * von hier; ein Zahlenliteral dort wäre ein zweiter Ort für dieselbe Schwelle.
277
+ */
278
+ export const THRESHOLDS = Object.freeze({
279
+ /** K1 — Payload-Form. */
280
+ K1: Object.freeze({
281
+ /** Zeichen im Fragetext (nach Normalisierung der Zeilenumbrüche). */
282
+ questionCharsFail: 120,
283
+ /**
284
+ * Ab so vielen eingebetteten Zeilenumbrüchen ist die Frage ein Bericht —
285
+ * ES SEI DENN, die Zeilen sind eine Aufzählung (`LIST_LINE_PATTERN`). Eine
286
+ * Aufzählung ist lesbare Struktur, ein Fließtextabsatz ist es nicht.
287
+ */
288
+ embeddedLinesFail: 2,
289
+ }),
290
+
291
+ /** K2 — Satzlänge. Erreicht nie `fail`, egal wie hoch der Wert steigt. */
292
+ K2: Object.freeze({ sentenceWordsWarn: 25 }),
293
+
294
+ /**
295
+ * K3 — Beschreibung wiederholt Label. KONJUNKTION, nicht Disjunktion:
296
+ * `fail` nur wenn `novelty < 3` UND `containment >= 0.5`.
297
+ *
298
+ * Containment allein hat 100 % Falsch-Positive: Einwort-Labels wie `new`,
299
+ * `full` oder `feature` erreichen containment 1.00, während die Beschreibung
300
+ * viel Substanz trägt. Erst die Kombination aus "sagt wenig Neues" UND
301
+ * "wiederholt das halbe Label" trifft den echten Fall.
302
+ */
303
+ K3: Object.freeze({
304
+ noveltyFailBelow: 3,
305
+ containmentFailAtOrAbove: 0.5,
306
+ /** Token mit ≤ 2 Zeichen werden verworfen, bevor gezählt wird. */
307
+ tokenMinChars: 3,
308
+ }),
309
+
310
+ /** K4 — Empfehlung ohne Grund. Zählt Marker, nicht Zeichen. */
311
+ K4: Object.freeze({ markersRequired: 1 }),
312
+
313
+ /** K5 — Header-Grenze (= Hürde H1). */
314
+ K5: Object.freeze({
315
+ headerCharsFail: 12,
316
+ /**
317
+ * Enthält die Kopfzeile einen Platzhalter (`<…>` / `[…]`), ist ihre echte
318
+ * Länge unbekannt. Dann gilt `Literallänge + 8` als schlimmster Fall — und
319
+ * nur WARN, weil eine Vermutung keinen Fehler begründet.
320
+ */
321
+ placeholderPad: 8,
322
+ }),
323
+
324
+ /** K6 — Längenbudget (Optionsanzahl = Hürde H2). */
325
+ K6: Object.freeze({
326
+ descriptionCharsWarn: 120,
327
+ descriptionCharsFail: 150,
328
+ labelCharsWarn: 40,
329
+ /** Summe aller Zeichen einer Frage: Fragetext + alle Labels + alle Beschreibungen. */
330
+ questionPayloadWarn: 500,
331
+ questionPayloadFail: 650,
332
+ /** JE FRAGE, nie je Block — siehe H2. */
333
+ optionsMin: 2,
334
+ optionsMax: 4,
335
+ }),
336
+
337
+ /**
338
+ * K7 — Unerklärter Bezeichner. ABSOLUTE ZAHL, keine Dichte: die gemessene
339
+ * Dichte hat Median 0 und Maximum 0,44 und trennt damit nichts. Ein einziger
340
+ * unerklärter Bezeichner reicht, damit der Operator nachschlagen muss.
341
+ */
342
+ K7: Object.freeze({ unexplainedFailAtOrAbove: 1 }),
343
+
344
+ /** K8 — Lese-Imperativ plus Ziel in Reichweite. */
345
+ K8: Object.freeze({
346
+ failAtOrAbove: 1,
347
+ /** Nicht-Punkt-Zeichen zwischen Imperativ und Pfad/Issue-Nummer. */
348
+ proximityChars: 45,
349
+ }),
350
+ });
351
+
352
+ /** Höchstlänge eines Zitats in einem Befund (Codepoints). */
353
+ export const EXCERPT_MAX_CHARS = 80;
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // K1 — Formmuster
357
+ // ---------------------------------------------------------------------------
358
+
359
+ /** Ein Code-Block im Fragetext. */
360
+ export const CODE_FENCE_PATTERN = /```/u;
361
+
362
+ /** Eine Tabellenzeile: mindestens zwei Spaltentrenner. */
363
+ export const TABLE_ROW_PATTERN = /\|[^|\n]*\|/u;
364
+
365
+ /** Eine Aufzählungszeile — strukturierte Mehrzeiligkeit, kein Bericht. */
366
+ export const LIST_LINE_PATTERN = /^\s*(?:-\s|\d+\.\s)/u;
367
+
368
+ /** Literaler `\n` im Quelltext ODER echter Zeilenumbruch. */
369
+ export const NEWLINE_PATTERN = /\\n|\r\n|\n/u;
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // K4 — die vier Markerklassen
373
+ // ---------------------------------------------------------------------------
374
+
375
+ /**
376
+ * Vier geschlossene Klassen. BEIDE SPRACHEN LAUFEN IMMER GLEICHZEITIG — es gibt
377
+ * bewusst KEINE Spracherkennung, weil im Korpus keine Kollision zwischen der
378
+ * deutschen und der englischen Liste auftritt. Eine Erkennung wäre ein
379
+ * zusätzlicher Fehlerpfad ohne einen einzigen Fall, den sie richtig stellt.
380
+ *
381
+ * Warum die vierte Klasse (Vergleich) zwingend ist, gemessen:
382
+ * mit drei Klassen — 16 von 29 `(Recommended)`-Optionen markiert, 7 davon
383
+ * falsch (44 % Falsch-Positive), z. B. "Fastest for
384
+ * file-disjoint tasks.", "most precise decomposition",
385
+ * "Isolates file edits"
386
+ * mit vier Klassen — 9 von 29 markiert, 1 davon falsch (14 %)
387
+ *
388
+ * Warum `comparison` eine GESCHLOSSENE WORTLISTE ist und kein Superlativ-Regex:
389
+ * von den 8 Treffern, die `\b\w+est\b` im Korpus erzeugt, sind 5 keine
390
+ * Superlative — `Test`, `Vitest`, `pytest`, `latest`, `Manifest`.
391
+ *
392
+ * Alle Muster tragen KEIN `g`-Flag: eine geteilte globale RegExp schleppt
393
+ * `lastIndex` zwischen Aufrufen mit und liefert bei jedem zweiten Aufruf ein
394
+ * anderes Ergebnis. Wer alle Treffer braucht, nimmt `globalOf()`.
395
+ */
396
+ export const MARKER_CLASSES = Object.freeze({
397
+ causal: Object.freeze({
398
+ id: 'causal',
399
+ title: 'Grund',
400
+ pattern:
401
+ /\b(?:weil|damit|sonst|deshalb|dadurch|verhindert|entspricht|passt|because|so that|since|otherwise|therefore|prevents|avoids|matches|fits|isolates|safe when|safe default)\b/iu,
402
+ }),
403
+ cost: Object.freeze({
404
+ id: 'cost',
405
+ title: 'Preis',
406
+ pattern:
407
+ /(?:\b(?:kostet|dauert|braucht|Kosten|cost|trade-off|slower|faster|heavier|requires|limits|burden|manually|manual)\b|Pro:|Con:|~\d|\b\d+\s*(?:min|h|Std|Tage)\b)/iu,
408
+ }),
409
+ consequence: Object.freeze({
410
+ id: 'consequence',
411
+ title: 'Folge',
412
+ pattern:
413
+ /(?:\b(?:dann|führt dazu|verliert|löscht|wird|then|loses|commits to|freezes|overwrites|deletes|blocks|breaks|you can|until|leaves|keeps)\b|\bno (?:further|separate|cleanup)\b)/iu,
414
+ }),
415
+ comparison: Object.freeze({
416
+ id: 'comparison',
417
+ title: 'Vergleich',
418
+ /**
419
+ * Geschlossene Wortliste, KEIN Superlativ-Regex — siehe Klassenkommentar.
420
+ * Der Vergleich wird als Alternation über genau diese Wörter geprüft.
421
+ */
422
+ words: Object.freeze([
423
+ 'fastest',
424
+ 'safest',
425
+ 'simplest',
426
+ 'cheapest',
427
+ 'most precise',
428
+ 'least',
429
+ 'only',
430
+ 'unlike',
431
+ 'instead',
432
+ 'vs',
433
+ 'maximum',
434
+ 'minimal',
435
+ 'lightweight',
436
+ 'high synergy',
437
+ 'am schnellsten',
438
+ 'am sichersten',
439
+ ]),
440
+ pattern:
441
+ /\b(?:fastest|safest|simplest|cheapest|most precise|least|only|unlike|instead|vs|maximum|minimal|lightweight|high synergy|am schnellsten|am sichersten)\b/iu,
442
+ }),
443
+ });
444
+
445
+ /** Stabile Reihenfolge der Markerklassen. */
446
+ export const MARKER_CLASS_IDS = Object.freeze(Object.keys(MARKER_CLASSES));
447
+
448
+ /** Wie eine empfohlene Option im Label markiert ist — beide Sprachen. */
449
+ export const RECOMMENDED_MARKERS = Object.freeze(['(Recommended)', '(Empfohlen)']);
450
+
451
+ /**
452
+ * Trägt diese Option eine Empfehlung? EIN Ort, zwei Konsumenten.
453
+ *
454
+ * **Label ODER Beschreibung** — bewusst beide. Der Marker gehört per AUQ-003 ins
455
+ * Label, aber `scripts/lib/config/dispatcher-autonomy-capture.mjs` hat ihn
456
+ * jahrelang in die Beschreibung gesetzt. Wer nur das Label prüft, übersieht
457
+ * genau diese Frage — und meldet sie als „gar keine Empfehlung vorhanden".
458
+ *
459
+ * Warum das hier steht und nicht zweimal daneben: bis 2026-08-22 gab es zwei
460
+ * Ableitungen. `parse.mjs` prüfte Label ODER Beschreibung, der Hook nur das
461
+ * Label. Dieselbe Optionsmenge, entgegengesetzte Urteile — als Datei-Vorlage
462
+ * meldete der Validator einen H2-Bruch, zur Laufzeit liess der Hook durch. Der
463
+ * Marker-String war schon geteilt; das PRÄDIKAT war es nicht, und genau dort
464
+ * lief es auseinander. Gefunden vom Architektur-Review dieser Session (W4-Q7).
465
+ *
466
+ * @param {{label?: unknown, description?: unknown}} option
467
+ * @returns {boolean}
468
+ */
469
+ export function isRecommendedOption(option) {
470
+ if (option === null || typeof option !== 'object') return false;
471
+ const label = typeof option.label === 'string' ? option.label : '';
472
+ const description = typeof option.description === 'string' ? option.description : '';
473
+ return RECOMMENDED_MARKERS.some((m) => label.includes(m) || description.includes(m));
474
+ }
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // K3 — Stoppwörter
478
+ // ---------------------------------------------------------------------------
479
+
480
+ /**
481
+ * Zweisprachige Stoppwortliste für den Neuheits-/Containment-Vergleich.
482
+ * Kleingeschrieben; der Vergleich normalisiert vorher auf Kleinschreibung.
483
+ * Token mit ≤ `THRESHOLDS.K3.tokenMinChars - 1` Zeichen fallen ohnehin heraus,
484
+ * darum stehen hier nur Wörter ab drei Zeichen.
485
+ */
486
+ export const STOPWORDS = Object.freeze(
487
+ new Set([
488
+ // Deutsch
489
+ 'aber',
490
+ 'alle',
491
+ 'also',
492
+ 'auch',
493
+ 'auf',
494
+ 'aus',
495
+ 'bei',
496
+ 'bis',
497
+ 'das',
498
+ 'dem',
499
+ 'den',
500
+ 'der',
501
+ 'des',
502
+ 'die',
503
+ 'ein',
504
+ 'eine',
505
+ 'einem',
506
+ 'einen',
507
+ 'einer',
508
+ 'für',
509
+ 'ist',
510
+ 'kann',
511
+ 'mit',
512
+ 'nach',
513
+ 'nicht',
514
+ 'nur',
515
+ 'oder',
516
+ 'ohne',
517
+ 'sich',
518
+ 'sind',
519
+ 'über',
520
+ 'und',
521
+ 'vom',
522
+ 'von',
523
+ 'vor',
524
+ 'wenn',
525
+ 'werden',
526
+ 'wie',
527
+ 'wird',
528
+ 'zum',
529
+ 'zur',
530
+ // Englisch
531
+ 'all',
532
+ 'and',
533
+ 'any',
534
+ 'are',
535
+ 'but',
536
+ 'can',
537
+ 'for',
538
+ 'from',
539
+ 'has',
540
+ 'have',
541
+ 'into',
542
+ 'its',
543
+ 'not',
544
+ 'off',
545
+ 'one',
546
+ 'onto',
547
+ 'per',
548
+ 'that',
549
+ 'the',
550
+ 'their',
551
+ 'them',
552
+ 'then',
553
+ 'this',
554
+ 'was',
555
+ 'were',
556
+ 'will',
557
+ 'with',
558
+ 'you',
559
+ 'your',
560
+ ]),
561
+ );
562
+
563
+ // ---------------------------------------------------------------------------
564
+ // K5 — Platzhalter in der Kopfzeile
565
+ // ---------------------------------------------------------------------------
566
+
567
+ /** `<name>` oder `[name]` — die Kopfzeile wird zur Laufzeit erst gefüllt. */
568
+ export const HEADER_PLACEHOLDER_PATTERN = /<[^<>]{1,40}>|\[[^[\]]{1,40}\]/u;
569
+
570
+ // ---------------------------------------------------------------------------
571
+ // K7 — der dreistufige Bezeichner-Detektor (Daten; Auswertung in clarity.mjs)
572
+ // ---------------------------------------------------------------------------
573
+
574
+ /** Dateiendungen, die ein Pfad-Token beglaubigen. */
575
+ export const PATH_EXTENSIONS = Object.freeze([
576
+ '.mjs',
577
+ '.js',
578
+ '.ts',
579
+ '.json',
580
+ '.yaml',
581
+ '.yml',
582
+ '.md',
583
+ '.lock',
584
+ '.toml',
585
+ ]);
586
+
587
+ /** Verzeichnisse, die ein Pfad-Token beglaubigen (inkl. Punktverzeichnisse). */
588
+ export const REPO_DIR_PREFIXES = Object.freeze([
589
+ 'scripts/',
590
+ 'skills/',
591
+ 'docs/',
592
+ 'tests/',
593
+ 'commands/',
594
+ 'agents/',
595
+ 'refs/',
596
+ '.claude/',
597
+ '.orchestrator/',
598
+ ]);
599
+
600
+ /**
601
+ * Stufe 1 — die Detektoren. Ein Treffer heißt "sieht aus wie ein Bezeichner",
602
+ * noch nicht "ist unerklärt": Stufe 2 (Allowlist) und Stufe 3 (Glosse) sieben
603
+ * danach.
604
+ *
605
+ * Das Pfadmuster ist ABSICHTLICH VERSCHÄRFT. Das naive `\w+/\w+` ist auf
606
+ * deutschem Text reines Rauschen — es trifft `Zähl-/Struktur-Datensatz`,
607
+ * `Skill-/Phasen-Nutzung`, `Erfolg/Abbruch`, `Pfade/Prompts/Repo-Namen`.
608
+ * Gemessen: naiv markiert 33 von 120 Beschreibungen, verschärft 18 von 120.
609
+ * Ein Pfad-Token muss deshalb entweder auf eine bekannte Endung enden ODER mit
610
+ * einem bekannten Repo-Verzeichnis beginnen.
611
+ *
612
+ * Kein Muster trägt `g` — siehe `globalOf()`.
613
+ */
614
+ export const IDENTIFIER_PATTERNS = Object.freeze([
615
+ Object.freeze({ id: 'backtick', pattern: /`[^`\n]+`/u }),
616
+ Object.freeze({ id: 'issue-ref', pattern: /#\d+/u }),
617
+ Object.freeze({
618
+ id: 'path',
619
+ pattern: new RegExp(
620
+ '(?:' +
621
+ // (a) beginnt mit einem bekannten Repo-Verzeichnis
622
+ `(?:${REPO_DIR_PREFIXES.map((d) => d.replace(/[.]/g, '\\.')).join('|')})[\\w./-]*` +
623
+ '|' +
624
+ // (b) endet auf eine bekannte Endung
625
+ `[\\w./-]*(?:${PATH_EXTENSIONS.map((e) => e.replace(/[.]/g, '\\.')).join('|')})\\b` +
626
+ ')',
627
+ 'u',
628
+ ),
629
+ }),
630
+ Object.freeze({ id: 'namespaced', pattern: /\b[A-Za-z][\w-]*::[\w-]+/u }),
631
+ Object.freeze({ id: 'snake_case', pattern: /\b[a-z][a-z\d]*(?:_[a-z\d]+)+\b/u }),
632
+ Object.freeze({ id: 'ALLCAPS_CONST', pattern: /\b[A-Z][A-Z\d]*(?:_[A-Z\d]+)+\b/u }),
633
+ Object.freeze({ id: 'camelCase', pattern: /\b[a-z][a-z\d]*(?:[A-Z][a-z\d]+)+\b/u }),
634
+ ]);
635
+
636
+ /**
637
+ * Stufe 2 — die Allowlist. Bewusst KLEIN und kuratiert: nur Token, die der
638
+ * Operator täglich selbst benutzt. Gemessene Wirkung: entfernt 5 der 18
639
+ * Markierungen. Der Vergleich ist exakte Zeichengleichheit auf dem gefundenen
640
+ * Token — kein Präfix-, kein Kleinschreibungsvergleich, weil "sieht ähnlich
641
+ * aus" genau die Unschärfe wäre, die die Liste vermeiden soll.
642
+ *
643
+ * Die Liste wächst nur gegen eine MESSUNG (wie viele Markierungen verschwinden),
644
+ * nie gegen ein Gefühl — sonst ist am Ende jeder Bezeichner allowlistet.
645
+ */
646
+ export const IDENTIFIER_ALLOWLIST = Object.freeze(
647
+ new Set([
648
+ '/close',
649
+ '/go',
650
+ '/session',
651
+ '/plan',
652
+ '/grill',
653
+ '/brainstorm',
654
+ '/details',
655
+ 'STATE.md',
656
+ // CLAUDE.md and AGENTS.md are the same file under two names — CLAUDE.md on
657
+ // Claude Code / Cursor, AGENTS.md on Codex CLI. Both are operator-daily
658
+ // vocabulary, so both must be allowlisted: on Codex the operator only ever
659
+ // sees AGENTS.md, and flagging it as an unexplained identifier would be the
660
+ // exact false positive this allowlist exists to prevent.
661
+ 'CLAUDE.md',
662
+ 'AGENTS.md',
663
+ 'README.md',
664
+ 'CHANGELOG.md',
665
+ 'package.json',
666
+ 'pyproject.toml',
667
+ 'Next.js',
668
+ ]),
669
+ );
670
+
671
+ /**
672
+ * Stufe 3 — die Glossen-Regel. Die mechanisch prüfbare Antwort auf "woran
673
+ * erkennt man, dass eine Erklärung danebensteht".
674
+ *
675
+ * Ein Token `T` gilt als GLOSSIERT, wenn im selben Satz eine Spanne `G` ALLE
676
+ * VIER Bedingungen erfüllt:
677
+ * 1. `G` ist begrenzt durch ` — ` / ` – `, ein Klammerpaar, oder `: `;
678
+ * 2. `G` beginnt höchstens `maxGapChars` nach dem Ende von `T`, ODER endet
679
+ * höchstens `maxGapChars` vor dem Beginn von `T`;
680
+ * 3. `G` enthält mindestens `minWords` Wörter;
681
+ * 4. `G` enthält selbst NULL Bezeichner-Token.
682
+ *
683
+ * Bedingung 4 ist die tragende: eine "Erklärung", die selbst aus Bezeichnern
684
+ * besteht, erklärt nichts — sie verschiebt das Nachschlagen nur um ein Wort.
685
+ */
686
+ export const GLOSS_RULE = Object.freeze({
687
+ maxGapChars: 3,
688
+ minWords: 3,
689
+ /** Trenner, die eine Glossen-Spanne einfassen dürfen. */
690
+ delimiters: Object.freeze([
691
+ Object.freeze({ id: 'em-dash', open: ' — ', close: null }),
692
+ Object.freeze({ id: 'en-dash', open: ' – ', close: null }),
693
+ Object.freeze({ id: 'paren', open: '(', close: ')' }),
694
+ Object.freeze({ id: 'bracket', open: '[', close: ']' }),
695
+ Object.freeze({ id: 'colon', open: ': ', close: null }),
696
+ ]),
697
+ mustContainNoIdentifiers: true,
698
+ });
699
+
700
+ // ---------------------------------------------------------------------------
701
+ // K8 — "Geh selbst nachsehen"
702
+ // ---------------------------------------------------------------------------
703
+
704
+ /** Lese-Imperativ. */
705
+ export const READ_IMPERATIVE_PATTERN =
706
+ /\b(?:inspect|check|review|read|see|look at|open|Details?|siehe|prüfe|schau|öffne)\b/iu;
707
+
708
+ /**
709
+ * Ich-Form ist KEIN Verweis an den Operator: "I will inspect the log" sagt zu,
710
+ * dass das System nachsieht — genau das Gegenteil von "sieh du nach".
711
+ */
712
+ export const FIRST_PERSON_EXCLUSION_PATTERN = /\b(?:I|I'll|I will|we|we'll|we will)\s+$/iu;
713
+
714
+ // ---------------------------------------------------------------------------
715
+ // Der Schutz: Sicherheits-Lexikon und Längen-Ausnahme
716
+ // ---------------------------------------------------------------------------
717
+
718
+ /**
719
+ * Sicherheits-Lexikon (`soul.md` § "Never traded for brevity"). Ein Text, der
720
+ * hier passt, darf nie aus Längengründen gekürzt werden.
721
+ *
722
+ * Bewusst großzügig geschnitten: über-befreien kostet einen ungeprüften
723
+ * Längenhinweis, unter-befreien kostet eine weggekürzte Warnung. Die Richtung
724
+ * ist nicht symmetrisch, also ist die Liste es auch nicht.
725
+ *
726
+ * Kein `g`-Flag — `.test()` bleibt zustandslos.
727
+ */
728
+ export const SAFETY_PATTERN = new RegExp(
729
+ [
730
+ // zerstörende Verben
731
+ '\\b(?:delete[sd]?|remove[sd]?|drop(?:s|ped)?|overwrite[sd]?|reset|revert|purge|wipe|destroy)\\b',
732
+ '\\b(?:l(?:ö|oe)sch\\w*|(?:ü|ue)berschreib\\w*|verwerf\\w*|zur(?:ü|ue)cksetz\\w*|entfernt)\\b',
733
+ // erzwungene Aktionen
734
+ '--force',
735
+ '\\bforce[- ]?(?:push|delete)\\b',
736
+ // Sperren und Zustand
737
+ '\\b(?:lock|locked|stale)\\b',
738
+ // Geheimnisse und Rechte
739
+ '\\b(?:secret|credential|token|password|permission|auth)\\w*\\b',
740
+ // Fehler und Warnungen
741
+ '\\b(?:error|fail(?:s|ed|ure)?|invalid|warn(?:s|ing)?)\\b',
742
+ '\\b(?:fehler\\w*|ung(?:ü|ue)ltig\\w*|warn\\w*)\\b',
743
+ ].join('|'),
744
+ 'iu',
745
+ );
746
+
747
+ /**
748
+ * Die Kriterien, von denen ein sicherheitsrelevanter Text befreit ist.
749
+ *
750
+ * NUR die Längenkriterien. K3/K4/K5/K7/K8 gelten weiter, weil sie Information
751
+ * HINZUFÜGEN (Grund, Preis, Erklärung) und nie welche entfernen — eine
752
+ * Sicherheitswarnung wird durch einen genannten Grund besser, nicht schlechter.
753
+ * Steht hier je K7 drin, hört die Jargon-Prüfung für Warntexte stillschweigend
754
+ * auf; genau das darf nicht passieren.
755
+ */
756
+ export const LENGTH_CRITERIA = Object.freeze(['K1', 'K6']);
757
+
758
+ /** Wert, den ein befreiter Befund im Feld `exempt` trägt. */
759
+ export const EXEMPT_LENGTH = 'length';
760
+
761
+ /**
762
+ * Ist dieser Text von den Längenkriterien befreit?
763
+ *
764
+ * Die einzige Funktion in diesem Modul, die einen Text ansieht — und zwar
765
+ * absichtlich: die Ausnahme ist der Schutz selbst. Läge sie in clarity.mjs,
766
+ * wäre sie eine Regel unter vielen und würde beim ersten Umbau mitgezogen.
767
+ *
768
+ * @param {string} text
769
+ * @returns {'length'|null} `'length'` = befreit (in `AuqFinding.exempt` legen)
770
+ */
771
+ export function isLengthExempt(text) {
772
+ if (typeof text !== 'string' || text === '') return null;
773
+ return SAFETY_PATTERN.test(text) ? EXEMPT_LENGTH : null;
774
+ }
775
+
776
+ // ---------------------------------------------------------------------------
777
+ // Messprimitive
778
+ // ---------------------------------------------------------------------------
779
+
780
+ /**
781
+ * Länge in Unicode-CODEPOINTS.
782
+ *
783
+ * Weder `Buffer.byteLength` noch `.length` tun das:
784
+ * `Buffer.byteLength('Evolve — Review')` = 17 (Bytes, nicht Zeichen)
785
+ * `'🚨 Alert'.length` = 8 (UTF-16-Einheiten, 🚨 zählt doppelt)
786
+ * `codepointLength('🚨 Alert')` = 7 (Zeichen — was das Tool abschneidet)
787
+ *
788
+ * @param {string} str
789
+ * @returns {number}
790
+ */
791
+ export function codepointLength(str) {
792
+ if (typeof str !== 'string' || str === '') return 0;
793
+ // NFC ZUERST — sonst misst dieselbe SICHTBARE Zeichenkette zwei verschiedene
794
+ // Längen, und die Differenz entscheidet über eine Operator-Frage.
795
+ //
796
+ // `"Nächste Prüf"` hat in beiden Unicode-Formen 12 Graphemcluster, also 12
797
+ // sichtbare Zeichen. Als NFC sind das 12 Codepoints, als NFD 14 — die Akzente
798
+ // stehen dort als eigene kombinierende Zeichen daneben. Ohne diese Zeile wird
799
+ // die NFD-Fassung von H1 (Kopfzeile ≤ 12) abgelehnt und die NFC-Fassung
800
+ // durchgelassen; gemessen 2026-08-22 am echten Hook über stdin:
801
+ // NFC → ALLOW (kein stdout), NFD → deny.
802
+ //
803
+ // Das ist fail-closed am falschen Ort, und die Ablehnung widerlegt sich beim
804
+ // Lesen selbst: ihr Grund zitiert eine Kopfzeile mit sichtbar 12 Zeichen und
805
+ // behauptet daneben 14. Kürzen ist die einzige nahegelegte Handlung und
806
+ // unmöglich — die Kopfzeile liegt bereits auf der Grenze. Das Modell
807
+ // formuliert um, trifft dieselbe Wand, und weder es noch der Operator erfährt,
808
+ // dass die Entscheidung nie gestellt wurde.
809
+ //
810
+ // Erreichbar ist das über den Laufzeitpfad, nicht über die Vorlagen: macOS
811
+ // liefert Dateisystemnamen als NFD, also trägt jede Kopfzeile, die aus einem
812
+ // Pfad, Branch- oder Dateinamen komponiert wird, die zerlegte Form. Der
813
+ // 72-Vorlagen-Korpus ist vollständig NFC — deshalb konnte weder die Messung
814
+ // aus Welle 2 noch das Umschreiben in Welle 3 darauf stoßen.
815
+ //
816
+ // Gefunden vom Security-Review dieser Session (W4-Q5), koordinator-verifiziert.
817
+ return [...str.normalize('NFC')].length;
818
+ }
819
+
820
+ /**
821
+ * Ersetzt literale `\n` und echte Zeilenumbrüche durch ein Leerzeichen.
822
+ * K1 zählt die Umbrüche ZUERST und misst die Zeichen DANACH — sonst zählt der
823
+ * Fragetext Escape-Sequenzen als zwei Zeichen mit.
824
+ *
825
+ * @param {string} text
826
+ * @returns {string}
827
+ */
828
+ export function normalizeLiteralNewlines(text) {
829
+ if (typeof text !== 'string') return '';
830
+ return text.replace(/\\n|\r\n|\n/gu, ' ');
831
+ }
832
+
833
+ /**
834
+ * Gleiche RegExp, aber mit `g`-Flag — für "alle Treffer".
835
+ *
836
+ * Die Registry-Muster tragen bewusst kein `g`: eine geteilte globale RegExp
837
+ * schleppt `lastIndex` zwischen Aufrufen mit, sodass derselbe `.test()` beim
838
+ * zweiten Mal `false` liefert. Wer alle Treffer braucht, holt sich hier eine
839
+ * FRISCHE Instanz statt die geteilte zu mutieren.
840
+ *
841
+ * @param {RegExp} re
842
+ * @returns {RegExp}
843
+ */
844
+ export function globalOf(re) {
845
+ const flags = re.flags.includes('g') ? re.flags : `${re.flags}g`;
846
+ return new RegExp(re.source, flags);
847
+ }
848
+
849
+ // ---------------------------------------------------------------------------
850
+ // Noten
851
+ // ---------------------------------------------------------------------------
852
+
853
+ /**
854
+ * Notenbänder, absteigend geprüft. `min` ist INKLUSIV: 90 ist ein A, 89 ein B.
855
+ */
856
+ export const GRADE_BANDS = Object.freeze([
857
+ Object.freeze({ grade: 'A', min: 90 }),
858
+ Object.freeze({ grade: 'B', min: 75 }),
859
+ Object.freeze({ grade: 'C', min: 60 }),
860
+ Object.freeze({ grade: 'D', min: 45 }),
861
+ Object.freeze({ grade: 'F', min: -Infinity }),
862
+ ]);
863
+
864
+ /** Stabile Reihenfolge für die Notenverteilung. */
865
+ export const GRADES = Object.freeze(['A', 'B', 'C', 'D', 'F']);
866
+
867
+ /**
868
+ * Note zu einer Punktzahl. Hürden sind hier NICHT enthalten — dafür ist
869
+ * `makeScore` zuständig, das die Hürden-Übersteuerung anwendet.
870
+ *
871
+ * @param {number} points 0–100
872
+ * @returns {'A'|'B'|'C'|'D'|'F'} `F` für alles, was keine Zahl ist
873
+ */
874
+ export function gradeFor(points) {
875
+ if (typeof points !== 'number' || !Number.isFinite(points)) return 'F';
876
+ for (const band of GRADE_BANDS) {
877
+ if (points >= band.min) return band.grade;
878
+ }
879
+ return 'F';
880
+ }
881
+
882
+ // ---------------------------------------------------------------------------
883
+ // Typen (JSDoc) — die Datensätze, die zwischen den drei Modulen fließen
884
+ // ---------------------------------------------------------------------------
885
+
886
+ /**
887
+ * @typedef {object} AuqOption
888
+ * @property {string} label Der sichtbare Knopftext.
889
+ * @property {string} description Der Erklärtext darunter.
890
+ * @property {string|null} preview Optionale Vorschau, `null` wenn keine.
891
+ * @property {boolean} isRecommended Trägt das Label einen Marker aus `RECOMMENDED_MARKERS`.
892
+ * @property {number} index 0-basierte Position innerhalb der Frage.
893
+ */
894
+
895
+ /**
896
+ * @typedef {object} AuqQuestion
897
+ * @property {string} question Der Fragetext.
898
+ * @property {string|null} header Die Kopfzeile, `null` wenn keine gefunden.
899
+ * @property {boolean} multiSelect Vorgabe `false`.
900
+ * @property {AuqOption[]} options
901
+ * @property {number} previewCount Wie viele Optionen dieser Frage eine Vorschau tragen.
902
+ * @property {string} file Repo-relativer Pfad.
903
+ * @property {number} line 1-basierte Zeile des Fundorts.
904
+ * @property {'A'|'B'|'C-mdc'|'C-mjs'|'C-prose'|'C-hybrid'} population
905
+ * @property {'template'|'illustration'} kind
906
+ * @property {'double'|'backtick'|'prose'} quoting
907
+ */
908
+
909
+ /**
910
+ * @typedef {object} AuqBlock
911
+ * @property {string} file
912
+ * @property {number} line
913
+ * @property {AuqQuestion[]} questions 1–4 Fragen (die Tool-Grenze).
914
+ */
915
+
916
+ /**
917
+ * @typedef {object} AuqFinding
918
+ * @property {string} criterion Eine `id` aus `CRITERIA`.
919
+ * @property {'fail'|'warn'} severity
920
+ * @property {string} file
921
+ * @property {number} line
922
+ * @property {'question'|'option'|'header'} target
923
+ * @property {number|null} optionIndex Pflicht bei `target: 'option'`, sonst `null`.
924
+ * @property {string} message Operator-lesbar, deutsch, ohne Fachjargon.
925
+ * @property {number|string|null} measured Der konkrete gemessene Wert.
926
+ * @property {number|string|null} threshold Die Schwelle, gegen die gemessen wurde.
927
+ * @property {string|null} exempt `'length'` = von K1/K6 befreit, sonst `null`.
928
+ * @property {string} excerpt Kurzes Zitat, höchstens `EXCERPT_MAX_CHARS`.
929
+ */
930
+
931
+ /**
932
+ * @typedef {object} AuqScore
933
+ * @property {string} file
934
+ * @property {number} line
935
+ * @property {number} questionIndex 0-basierte Position der Frage in ihrem Block.
936
+ * @property {number} points 0–100.
937
+ * @property {'A'|'B'|'C'|'D'|'F'} grade
938
+ * @property {Array<'H1'|'H2'>} hurdlesBroken
939
+ * @property {AuqFinding[]} findings
940
+ */
941
+
942
+ /**
943
+ * @typedef {object} AuqReport
944
+ * @property {string} schemaVersion
945
+ * @property {string} measuredAt ISO-8601.
946
+ * @property {string|null} head Kurzer HEAD-SHA.
947
+ * @property {boolean} dirty Arbeitsbaum schmutzig — dann ist `head` kein Anker.
948
+ * @property {Record<string, number>} corpus Zählung je Population.
949
+ * @property {AuqBlock[]} blocks
950
+ * @property {AuqScore[]} scores
951
+ * @property {{grades: Record<string, number>, byCriterion: Record<string, {fail: number, warn: number}>}} summary
952
+ */
953
+
954
+ // ---------------------------------------------------------------------------
955
+ // Fabriken
956
+ // ---------------------------------------------------------------------------
957
+
958
+ /**
959
+ * Fehler beim Bau eines Datensatzes ist ein PROGRAMMIERFEHLER im erzeugenden
960
+ * Modul, kein Datenbefund — deshalb wirft die Fabrik, statt still zu
961
+ * normalisieren. Ein Befund mit der Kriteriums-ID `K9` würde sonst lautlos in
962
+ * den Bericht laufen und in `summary.byCriterion` schlicht fehlen.
963
+ *
964
+ * @param {string} msg
965
+ * @returns {never}
966
+ */
967
+ function fail(msg) {
968
+ throw new TypeError(`auq/schema: ${msg}`);
969
+ }
970
+
971
+ /** @param {unknown} v @returns {string} */
972
+ function asString(v, field) {
973
+ if (typeof v !== 'string') fail(`${field} muss ein String sein (bekommen: ${typeof v})`);
974
+ return v;
975
+ }
976
+
977
+ /** @param {unknown} v @returns {number} */
978
+ function asInt(v, field) {
979
+ if (typeof v !== 'number' || !Number.isInteger(v)) {
980
+ fail(`${field} muss eine ganze Zahl sein (bekommen: ${String(v)})`);
981
+ }
982
+ return v;
983
+ }
984
+
985
+ /**
986
+ * Kürzt ein Zitat auf `EXCERPT_MAX_CHARS` Codepoints und hängt ein Auslassungs-
987
+ * zeichen an. Zentral, damit nicht drei Module drei Zitatlängen erzeugen.
988
+ *
989
+ * @param {string} text
990
+ * @returns {string}
991
+ */
992
+ export function truncateExcerpt(text) {
993
+ if (typeof text !== 'string' || text === '') return '';
994
+ const flat = normalizeLiteralNewlines(text).trim();
995
+ const chars = [...flat];
996
+ if (chars.length <= EXCERPT_MAX_CHARS) return flat;
997
+ return `${chars.slice(0, EXCERPT_MAX_CHARS - 1).join('')}…`;
998
+ }
999
+
1000
+ /**
1001
+ * Baut eine Option. Setzt die Vorgaben, die sonst dreimal erfunden würden.
1002
+ *
1003
+ * @param {{label: string, description?: string, preview?: string|null, isRecommended?: boolean, index: number}} rec
1004
+ * @returns {AuqOption}
1005
+ */
1006
+ export function makeOption(rec) {
1007
+ if (rec === null || typeof rec !== 'object') fail('makeOption braucht ein Objekt');
1008
+ return Object.freeze({
1009
+ label: asString(rec.label, 'label'),
1010
+ description: typeof rec.description === 'string' ? rec.description : '',
1011
+ preview: typeof rec.preview === 'string' ? rec.preview : null,
1012
+ isRecommended: rec.isRecommended === true,
1013
+ index: asInt(rec.index, 'index'),
1014
+ });
1015
+ }
1016
+
1017
+ /**
1018
+ * Baut eine Frage. `previewCount` wird ABGELEITET, nicht übergeben — eine von
1019
+ * Hand mitgeführte Zahl ist ein Defekt in Wartestellung.
1020
+ *
1021
+ * @param {{question: string, header?: string|null, multiSelect?: boolean, options?: AuqOption[],
1022
+ * file: string, line: number, population: string, kind: string, quoting: string}} rec
1023
+ * @returns {AuqQuestion}
1024
+ */
1025
+ export function makeQuestion(rec) {
1026
+ if (rec === null || typeof rec !== 'object') fail('makeQuestion braucht ein Objekt');
1027
+ const options = Array.isArray(rec.options) ? rec.options : [];
1028
+ if (!POPULATIONS.includes(rec.population)) {
1029
+ fail(`unbekannte population: ${String(rec.population)} (erlaubt: ${POPULATIONS.join(', ')})`);
1030
+ }
1031
+ if (!KINDS.includes(rec.kind)) {
1032
+ fail(`unbekannter kind: ${String(rec.kind)} (erlaubt: ${KINDS.join(', ')})`);
1033
+ }
1034
+ if (!QUOTINGS.includes(rec.quoting)) {
1035
+ fail(`unbekanntes quoting: ${String(rec.quoting)} (erlaubt: ${QUOTINGS.join(', ')})`);
1036
+ }
1037
+ return Object.freeze({
1038
+ question: asString(rec.question, 'question'),
1039
+ header: typeof rec.header === 'string' ? rec.header : null,
1040
+ multiSelect: rec.multiSelect === true,
1041
+ options: Object.freeze([...options]),
1042
+ previewCount: options.filter((o) => o && o.preview !== null && o.preview !== undefined).length,
1043
+ file: asString(rec.file, 'file'),
1044
+ line: asInt(rec.line, 'line'),
1045
+ population: rec.population,
1046
+ kind: rec.kind,
1047
+ quoting: rec.quoting,
1048
+ });
1049
+ }
1050
+
1051
+ /**
1052
+ * Baut einen Block. Die Fragenzahl wird NICHT erzwungen — ein Block mit 5
1053
+ * Fragen ist ein echter Befund des Korpus und muss in den Bericht, nicht in
1054
+ * einen Ausnahmefehler. (Die Optionsgrenze dagegen IST die Hürde H2 und wird
1055
+ * von clarity.mjs je Frage geprüft.)
1056
+ *
1057
+ * @param {{file: string, line: number, questions?: AuqQuestion[]}} rec
1058
+ * @returns {AuqBlock}
1059
+ */
1060
+ export function makeBlock(rec) {
1061
+ if (rec === null || typeof rec !== 'object') fail('makeBlock braucht ein Objekt');
1062
+ return Object.freeze({
1063
+ file: asString(rec.file, 'file'),
1064
+ line: asInt(rec.line, 'line'),
1065
+ questions: Object.freeze(Array.isArray(rec.questions) ? [...rec.questions] : []),
1066
+ });
1067
+ }
1068
+
1069
+ /**
1070
+ * Baut einen Befund.
1071
+ *
1072
+ * `message` ist das Feld, an dem dieses Programm sich selbst misst: es steht
1073
+ * später vor dem Operator. Deutsch, ohne Fachjargon, und es nennt WAS gemessen
1074
+ * wurde — nicht die Kriteriums-ID.
1075
+ *
1076
+ * @param {{criterion: string, severity: 'fail'|'warn', file: string, line: number,
1077
+ * target: 'question'|'option'|'header', optionIndex?: number|null, message: string,
1078
+ * measured?: number|string|null, threshold?: number|string|null,
1079
+ * exempt?: string|null, excerpt?: string}} rec
1080
+ * @returns {AuqFinding}
1081
+ */
1082
+ export function makeFinding(rec) {
1083
+ if (rec === null || typeof rec !== 'object') fail('makeFinding braucht ein Objekt');
1084
+
1085
+ const criterion = asString(rec.criterion, 'criterion');
1086
+ if (!Object.prototype.hasOwnProperty.call(CRITERIA, criterion)) {
1087
+ fail(`unbekanntes Kriterium: ${criterion} (erlaubt: ${CRITERION_IDS.join(', ')})`);
1088
+ }
1089
+ if (!SEVERITIES.includes(rec.severity)) {
1090
+ fail(`unbekannte severity: ${String(rec.severity)} (erlaubt: ${SEVERITIES.join(', ')})`);
1091
+ }
1092
+ if (!TARGETS.includes(rec.target)) {
1093
+ fail(`unbekanntes target: ${String(rec.target)} (erlaubt: ${TARGETS.join(', ')})`);
1094
+ }
1095
+ const message = asString(rec.message, 'message');
1096
+ if (message.trim() === '') fail('message darf nicht leer sein — sie steht vor dem Operator');
1097
+
1098
+ // Ein Options-Befund ohne Index ist keiner Option zuzuordnen und damit
1099
+ // unbrauchbar; ein Nicht-Options-Befund MIT Index behauptet eine Zuordnung,
1100
+ // die es nicht gibt.
1101
+ const hasIndex = typeof rec.optionIndex === 'number' && Number.isInteger(rec.optionIndex);
1102
+ if (rec.target === 'option' && !hasIndex) {
1103
+ fail('target "option" braucht einen ganzzahligen optionIndex');
1104
+ }
1105
+ if (rec.target !== 'option' && hasIndex) {
1106
+ fail(`target "${rec.target}" darf keinen optionIndex tragen`);
1107
+ }
1108
+
1109
+ // `hurdle` — WELCHE harte Grenze dieser eine Befund reisst, oder `null`.
1110
+ //
1111
+ // Ohne dieses Feld muessen die Konsumenten die Zuordnung ueber
1112
+ // `HURDLES[id].criterion` rekonstruieren — und greifen dabei ueber: K6
1113
+ // erzeugt VIER Befundklassen (Beschreibungslaenge, Labellaenge, Nutzlast,
1114
+ // Optionszahl) und nur die letzte traegt H2. Gemessen 2026-08-22 am echten
1115
+ // Hook: unter der Ueberschrift „H2 — 2-4 Optionen je Frage" standen bei
1116
+ // 4 Fragen x 5 Optionen ZEHN Beschreibungslaengen-Zeilen und EINE, die den
1117
+ // echten Optionszahl-Bruch benannte; die Brueche der Fragen 2-4 fielen
1118
+ // komplett aus dem Zeilenbudget.
1119
+ //
1120
+ // Die Zuordnung existiert bei der Pruefung bereits (checkK5/checkK6 geben
1121
+ // `{findings, hurdles}` im selben Durchlauf zurueck) und wurde bisher an
1122
+ // `makeScore` weggeworfen. Sie hier mitzufuehren ersetzt zwei Rekonstruktionen
1123
+ // durch eine Tatsache — genau die Klasse aus Epic #1035 („Eine Tatsache, zwei
1124
+ // Kopien").
1125
+ //
1126
+ // Gefunden vom Architektur-Review dieser Session (W4-Q7), das den Fix in
1127
+ // dieser Form vorgeschlagen hat; koordinator-verifiziert.
1128
+ const hurdle =
1129
+ typeof rec.hurdle === 'string' && HURDLE_IDS.includes(rec.hurdle) ? rec.hurdle : null;
1130
+
1131
+ return Object.freeze({
1132
+ criterion,
1133
+ severity: rec.severity,
1134
+ file: asString(rec.file, 'file'),
1135
+ line: asInt(rec.line, 'line'),
1136
+ target: rec.target,
1137
+ optionIndex: hasIndex ? rec.optionIndex : null,
1138
+ message,
1139
+ measured: rec.measured === undefined ? null : rec.measured,
1140
+ threshold: rec.threshold === undefined ? null : rec.threshold,
1141
+ exempt: typeof rec.exempt === 'string' ? rec.exempt : null,
1142
+ excerpt: truncateExcerpt(rec.excerpt ?? ''),
1143
+ hurdle,
1144
+ });
1145
+ }
1146
+
1147
+ /**
1148
+ * Baut ein Ergebnis und LEITET die Note ab.
1149
+ *
1150
+ * Die Hürden-Übersteuerung sitzt hier, nicht beim Aufrufer: eine gerissene
1151
+ * Hürde ergibt F, auch bei 95 Punkten. Genau das ist der Unterschied zwischen
1152
+ * einer Hürde und einem schweren Kriterium — und genau die Stelle, an der ein
1153
+ * Aufrufer es vergessen würde.
1154
+ *
1155
+ * @param {{file: string, line: number, questionIndex: number, points: number,
1156
+ * hurdlesBroken?: Array<'H1'|'H2'>, findings?: AuqFinding[]}} rec
1157
+ * @returns {AuqScore}
1158
+ */
1159
+ export function makeScore(rec) {
1160
+ if (rec === null || typeof rec !== 'object') fail('makeScore braucht ein Objekt');
1161
+ if (typeof rec.points !== 'number' || !Number.isFinite(rec.points)) {
1162
+ fail(`points muss eine endliche Zahl sein (bekommen: ${String(rec.points)})`);
1163
+ }
1164
+ if (rec.points < 0 || rec.points > 100) {
1165
+ fail(`points muss zwischen 0 und 100 liegen (bekommen: ${rec.points})`);
1166
+ }
1167
+
1168
+ const hurdlesBroken = Array.isArray(rec.hurdlesBroken) ? [...rec.hurdlesBroken] : [];
1169
+ for (const h of hurdlesBroken) {
1170
+ if (!HURDLE_IDS.includes(h)) {
1171
+ fail(`unbekannte Hürde: ${String(h)} (erlaubt: ${HURDLE_IDS.join(', ')})`);
1172
+ }
1173
+ }
1174
+
1175
+ return Object.freeze({
1176
+ file: asString(rec.file, 'file'),
1177
+ line: asInt(rec.line, 'line'),
1178
+ questionIndex: asInt(rec.questionIndex, 'questionIndex'),
1179
+ points: rec.points,
1180
+ grade: hurdlesBroken.length > 0 ? 'F' : gradeFor(rec.points),
1181
+ hurdlesBroken: Object.freeze(hurdlesBroken),
1182
+ findings: Object.freeze(Array.isArray(rec.findings) ? [...rec.findings] : []),
1183
+ });
1184
+ }
1185
+
1186
+ /**
1187
+ * Leerer Korpus-Zähler mit ALLEN Populationen auf 0.
1188
+ *
1189
+ * Fabrik statt handgeschriebenem Objektliteral, damit eine neue Population
1190
+ * nicht in einem der drei Module fehlt und dort als `undefined` in eine
1191
+ * Addition läuft.
1192
+ *
1193
+ * @returns {Record<string, number>}
1194
+ */
1195
+ export function emptyCorpus() {
1196
+ const out = {};
1197
+ for (const p of POPULATIONS) out[p] = 0;
1198
+ return out;
1199
+ }
1200
+
1201
+ /**
1202
+ * Leere Zusammenfassung: jede Note auf 0, jedes Kriterium mit `{fail: 0, warn: 0}`.
1203
+ * Gleicher Grund wie `emptyCorpus`.
1204
+ *
1205
+ * @returns {{grades: Record<string, number>, byCriterion: Record<string, {fail: number, warn: number}>}}
1206
+ */
1207
+ export function emptySummary() {
1208
+ const grades = {};
1209
+ for (const g of GRADES) grades[g] = 0;
1210
+ const byCriterion = {};
1211
+ for (const k of CRITERION_IDS) byCriterion[k] = { fail: 0, warn: 0 };
1212
+ return { grades, byCriterion };
1213
+ }
1214
+
1215
+ // ---------------------------------------------------------------------------
1216
+ // Validierung
1217
+ // ---------------------------------------------------------------------------
1218
+
1219
+ /**
1220
+ * Prüft einen fertigen Bericht gegen dieses Schema.
1221
+ *
1222
+ * WIRFT NIE — auch nicht bei `null`, einem String oder einem zyklischen Objekt.
1223
+ * Der Aufrufer ist die CLI, und ein Validator, der beim Prüfen abstürzt, nimmt
1224
+ * dem Operator genau die Fehlermeldung weg, für die er gebaut wurde.
1225
+ *
1226
+ * Die Fehlerliste ist VOLLSTÄNDIG (kein Abbruch beim ersten Fund) und jeder
1227
+ * Eintrag nennt seinen Pfad, damit man ihn ohne Suchen findet.
1228
+ *
1229
+ * @param {unknown} obj
1230
+ * @returns {{ok: boolean, errors: string[]}}
1231
+ */
1232
+ export function validateReport(obj) {
1233
+ const errors = [];
1234
+ const push = (msg) => {
1235
+ if (errors.length < 200) errors.push(msg);
1236
+ };
1237
+
1238
+ try {
1239
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
1240
+ return { ok: false, errors: [`report: muss ein Objekt sein (bekommen: ${obj === null ? 'null' : Array.isArray(obj) ? 'Array' : typeof obj})`] };
1241
+ }
1242
+ const r = /** @type {Record<string, unknown>} */ (obj);
1243
+
1244
+ if (r.schemaVersion !== SCHEMA_VERSION) {
1245
+ push(`report.schemaVersion: erwartet "${SCHEMA_VERSION}", bekommen ${JSON.stringify(r.schemaVersion)}`);
1246
+ }
1247
+ if (typeof r.measuredAt !== 'string' || Number.isNaN(Date.parse(r.measuredAt))) {
1248
+ push(`report.measuredAt: muss ein ISO-8601-Zeitstempel sein (bekommen: ${JSON.stringify(r.measuredAt)})`);
1249
+ }
1250
+ if (!(typeof r.head === 'string' || r.head === null)) {
1251
+ push('report.head: muss ein String oder null sein');
1252
+ }
1253
+ if (typeof r.dirty !== 'boolean') {
1254
+ push('report.dirty: muss ein Boolean sein');
1255
+ }
1256
+
1257
+ // corpus — jede Population, jede Zahl.
1258
+ if (r.corpus === null || typeof r.corpus !== 'object' || Array.isArray(r.corpus)) {
1259
+ push('report.corpus: muss ein Objekt sein');
1260
+ } else {
1261
+ for (const p of POPULATIONS) {
1262
+ const v = /** @type {Record<string, unknown>} */ (r.corpus)[p];
1263
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) {
1264
+ push(`report.corpus.${p}: muss eine Zahl >= 0 sein (bekommen: ${JSON.stringify(v)})`);
1265
+ }
1266
+ }
1267
+ }
1268
+
1269
+ // blocks
1270
+ if (!Array.isArray(r.blocks)) {
1271
+ push('report.blocks: muss ein Array sein');
1272
+ } else {
1273
+ r.blocks.forEach((b, i) => validateBlock(b, `report.blocks[${i}]`, push));
1274
+ }
1275
+
1276
+ // scores
1277
+ if (!Array.isArray(r.scores)) {
1278
+ push('report.scores: muss ein Array sein');
1279
+ } else {
1280
+ r.scores.forEach((s, i) => validateScore(s, `report.scores[${i}]`, push));
1281
+ }
1282
+
1283
+ // summary
1284
+ if (r.summary === null || typeof r.summary !== 'object' || Array.isArray(r.summary)) {
1285
+ push('report.summary: muss ein Objekt sein');
1286
+ } else {
1287
+ const s = /** @type {Record<string, unknown>} */ (r.summary);
1288
+ if (s.grades === null || typeof s.grades !== 'object') {
1289
+ push('report.summary.grades: muss ein Objekt sein');
1290
+ } else {
1291
+ for (const g of GRADES) {
1292
+ const v = /** @type {Record<string, unknown>} */ (s.grades)[g];
1293
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) {
1294
+ push(`report.summary.grades.${g}: muss eine Zahl >= 0 sein`);
1295
+ }
1296
+ }
1297
+ }
1298
+ if (s.byCriterion === null || typeof s.byCriterion !== 'object') {
1299
+ push('report.summary.byCriterion: muss ein Objekt sein');
1300
+ } else {
1301
+ for (const k of CRITERION_IDS) {
1302
+ const v = /** @type {Record<string, unknown>} */ (s.byCriterion)[k];
1303
+ if (v === null || typeof v !== 'object') {
1304
+ push(`report.summary.byCriterion.${k}: muss ein Objekt {fail, warn} sein`);
1305
+ continue;
1306
+ }
1307
+ for (const level of SEVERITIES) {
1308
+ const n = /** @type {Record<string, unknown>} */ (v)[level];
1309
+ if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) {
1310
+ push(`report.summary.byCriterion.${k}.${level}: muss eine Zahl >= 0 sein`);
1311
+ }
1312
+ }
1313
+ }
1314
+ }
1315
+ }
1316
+ } catch (err) {
1317
+ // Ein abstürzender Validator ist schlimmer als ein ungültiger Bericht.
1318
+ return { ok: false, errors: [`report: Validierung abgebrochen — ${err?.message ?? String(err)}`] };
1319
+ }
1320
+
1321
+ return { ok: errors.length === 0, errors };
1322
+ }
1323
+
1324
+ /** @param {unknown} b @param {string} path @param {(m: string) => void} push */
1325
+ function validateBlock(b, path, push) {
1326
+ if (b === null || typeof b !== 'object' || Array.isArray(b)) {
1327
+ push(`${path}: muss ein Objekt sein`);
1328
+ return;
1329
+ }
1330
+ const rec = /** @type {Record<string, unknown>} */ (b);
1331
+ if (typeof rec.file !== 'string' || rec.file === '') push(`${path}.file: muss ein nicht-leerer String sein`);
1332
+ if (!Number.isInteger(rec.line)) push(`${path}.line: muss eine ganze Zahl sein`);
1333
+ if (!Array.isArray(rec.questions)) {
1334
+ push(`${path}.questions: muss ein Array sein`);
1335
+ return;
1336
+ }
1337
+ rec.questions.forEach((q, i) => validateQuestion(q, `${path}.questions[${i}]`, push));
1338
+ }
1339
+
1340
+ /** @param {unknown} q @param {string} path @param {(m: string) => void} push */
1341
+ function validateQuestion(q, path, push) {
1342
+ if (q === null || typeof q !== 'object' || Array.isArray(q)) {
1343
+ push(`${path}: muss ein Objekt sein`);
1344
+ return;
1345
+ }
1346
+ const rec = /** @type {Record<string, unknown>} */ (q);
1347
+ if (typeof rec.question !== 'string') push(`${path}.question: muss ein String sein`);
1348
+ if (!(typeof rec.header === 'string' || rec.header === null)) {
1349
+ push(`${path}.header: muss ein String oder null sein`);
1350
+ }
1351
+ if (typeof rec.multiSelect !== 'boolean') push(`${path}.multiSelect: muss ein Boolean sein`);
1352
+ if (!POPULATIONS.includes(/** @type {string} */ (rec.population))) {
1353
+ push(`${path}.population: unbekannt (${JSON.stringify(rec.population)})`);
1354
+ }
1355
+ if (!KINDS.includes(/** @type {string} */ (rec.kind))) {
1356
+ push(`${path}.kind: unbekannt (${JSON.stringify(rec.kind)})`);
1357
+ }
1358
+ if (!QUOTINGS.includes(/** @type {string} */ (rec.quoting))) {
1359
+ push(`${path}.quoting: unbekannt (${JSON.stringify(rec.quoting)})`);
1360
+ }
1361
+ if (typeof rec.file !== 'string' || rec.file === '') push(`${path}.file: muss ein nicht-leerer String sein`);
1362
+ if (!Number.isInteger(rec.line)) push(`${path}.line: muss eine ganze Zahl sein`);
1363
+ if (!Number.isInteger(rec.previewCount)) push(`${path}.previewCount: muss eine ganze Zahl sein`);
1364
+ if (!Array.isArray(rec.options)) {
1365
+ push(`${path}.options: muss ein Array sein`);
1366
+ return;
1367
+ }
1368
+ rec.options.forEach((o, i) => {
1369
+ const p = `${path}.options[${i}]`;
1370
+ if (o === null || typeof o !== 'object' || Array.isArray(o)) {
1371
+ push(`${p}: muss ein Objekt sein`);
1372
+ return;
1373
+ }
1374
+ const opt = /** @type {Record<string, unknown>} */ (o);
1375
+ if (typeof opt.label !== 'string') push(`${p}.label: muss ein String sein`);
1376
+ if (typeof opt.description !== 'string') push(`${p}.description: muss ein String sein`);
1377
+ if (!(typeof opt.preview === 'string' || opt.preview === null)) {
1378
+ push(`${p}.preview: muss ein String oder null sein`);
1379
+ }
1380
+ if (typeof opt.isRecommended !== 'boolean') push(`${p}.isRecommended: muss ein Boolean sein`);
1381
+ if (!Number.isInteger(opt.index)) push(`${p}.index: muss eine ganze Zahl sein`);
1382
+ });
1383
+ }
1384
+
1385
+ /** @param {unknown} s @param {string} path @param {(m: string) => void} push */
1386
+ function validateScore(s, path, push) {
1387
+ if (s === null || typeof s !== 'object' || Array.isArray(s)) {
1388
+ push(`${path}: muss ein Objekt sein`);
1389
+ return;
1390
+ }
1391
+ const rec = /** @type {Record<string, unknown>} */ (s);
1392
+ if (typeof rec.file !== 'string' || rec.file === '') push(`${path}.file: muss ein nicht-leerer String sein`);
1393
+ if (!Number.isInteger(rec.line)) push(`${path}.line: muss eine ganze Zahl sein`);
1394
+ if (!Number.isInteger(rec.questionIndex)) push(`${path}.questionIndex: muss eine ganze Zahl sein`);
1395
+ if (typeof rec.points !== 'number' || !Number.isFinite(rec.points) || rec.points < 0 || rec.points > 100) {
1396
+ push(`${path}.points: muss eine Zahl zwischen 0 und 100 sein (bekommen: ${JSON.stringify(rec.points)})`);
1397
+ }
1398
+ if (!GRADES.includes(/** @type {string} */ (rec.grade))) {
1399
+ push(`${path}.grade: unbekannt (${JSON.stringify(rec.grade)})`);
1400
+ }
1401
+ if (!Array.isArray(rec.hurdlesBroken)) {
1402
+ push(`${path}.hurdlesBroken: muss ein Array sein`);
1403
+ } else {
1404
+ rec.hurdlesBroken.forEach((h, i) => {
1405
+ if (!HURDLE_IDS.includes(/** @type {string} */ (h))) {
1406
+ push(`${path}.hurdlesBroken[${i}]: unbekannte Hürde (${JSON.stringify(h)})`);
1407
+ }
1408
+ });
1409
+ // Die Hürden-Übersteuerung ist der Sinn einer Hürde — ein Ergebnis, das
1410
+ // sie reißt und trotzdem eine Note trägt, hat sie unterwegs verloren.
1411
+ if (rec.hurdlesBroken.length > 0 && rec.grade !== 'F') {
1412
+ push(`${path}.grade: muss "F" sein, weil ${rec.hurdlesBroken.length} Hürde(n) gerissen sind`);
1413
+ }
1414
+ }
1415
+ if (!Array.isArray(rec.findings)) {
1416
+ push(`${path}.findings: muss ein Array sein`);
1417
+ return;
1418
+ }
1419
+ rec.findings.forEach((f, i) => validateFinding(f, `${path}.findings[${i}]`, push));
1420
+ }
1421
+
1422
+ /** @param {unknown} f @param {string} path @param {(m: string) => void} push */
1423
+ function validateFinding(f, path, push) {
1424
+ if (f === null || typeof f !== 'object' || Array.isArray(f)) {
1425
+ push(`${path}: muss ein Objekt sein`);
1426
+ return;
1427
+ }
1428
+ const rec = /** @type {Record<string, unknown>} */ (f);
1429
+ if (!Object.prototype.hasOwnProperty.call(CRITERIA, /** @type {string} */ (rec.criterion))) {
1430
+ push(`${path}.criterion: unbekannt (${JSON.stringify(rec.criterion)})`);
1431
+ }
1432
+ if (!SEVERITIES.includes(/** @type {string} */ (rec.severity))) {
1433
+ push(`${path}.severity: unbekannt (${JSON.stringify(rec.severity)})`);
1434
+ }
1435
+ if (!TARGETS.includes(/** @type {string} */ (rec.target))) {
1436
+ push(`${path}.target: unbekannt (${JSON.stringify(rec.target)})`);
1437
+ }
1438
+ if (typeof rec.message !== 'string' || rec.message.trim() === '') {
1439
+ push(`${path}.message: muss ein nicht-leerer String sein`);
1440
+ }
1441
+ if (typeof rec.file !== 'string' || rec.file === '') push(`${path}.file: muss ein nicht-leerer String sein`);
1442
+ if (!Number.isInteger(rec.line)) push(`${path}.line: muss eine ganze Zahl sein`);
1443
+ if (rec.target === 'option') {
1444
+ if (!Number.isInteger(rec.optionIndex)) {
1445
+ push(`${path}.optionIndex: Pflicht bei target "option"`);
1446
+ }
1447
+ } else if (rec.optionIndex !== null) {
1448
+ push(`${path}.optionIndex: muss null sein bei target "${String(rec.target)}"`);
1449
+ }
1450
+ if (!(typeof rec.exempt === 'string' || rec.exempt === null)) {
1451
+ push(`${path}.exempt: muss ein String oder null sein`);
1452
+ }
1453
+ if (typeof rec.excerpt !== 'string') push(`${path}.excerpt: muss ein String sein`);
1454
+ else if (codepointLength(rec.excerpt) > EXCERPT_MAX_CHARS) {
1455
+ push(`${path}.excerpt: höchstens ${EXCERPT_MAX_CHARS} Zeichen (bekommen: ${codepointLength(rec.excerpt)})`);
1456
+ }
1457
+ }