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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +101 -0
- package/README.md +10 -10
- package/agents/memory-proposal-collector.md +6 -4
- package/commands/eli5.md +33 -0
- package/commands/release.md +5 -3
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/scope-collision-guard.md +3 -3
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +11 -1
- package/hooks/on-session-end.mjs +24 -92
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +7 -8
- package/scripts/lib/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- package/scripts/lib/ci-status-banner.mjs +63 -57
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/io.mjs +239 -0
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +147 -50
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +30 -0
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vcs-repo-spec.mjs +182 -13
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/release.mjs +443 -122
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/validate-plugin.mjs +3 -0
- package/scripts/validate-wave-scope.mjs +6 -1
- package/scripts/vault-backfill.mjs +32 -5
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +32 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/discovery/SKILL.md +28 -24
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/gitlab-ops/SKILL.md +30 -26
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/npm-publish/SKILL.md +4 -4
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +35 -23
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/session-end/SKILL.md +53 -20
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-start/SKILL.md +69 -35
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/soul.md +110 -0
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/wave-executor/wave-loop.md +27 -5
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- 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
|
+
}
|