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,1314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auq/clarity.mjs — der Bewerter der AUQ-Klarheitsmessung (#1107).
|
|
3
|
+
*
|
|
4
|
+
* ## Was dieses Modul tut, und was ausdrücklich nicht
|
|
5
|
+
*
|
|
6
|
+
* Es BEWERTET fertig geparste Fragen und PARST NICHTS. Kein Datei-IO, kein
|
|
7
|
+
* Netz, kein LLM, keine neue Abhängigkeit — reine Funktion von `AuqQuestion`
|
|
8
|
+
* nach `AuqScore`. Die Vorlagen findet `scripts/lib/auq/parse.mjs`, den Bericht
|
|
9
|
+
* baut `scripts/auq-audit.mjs`.
|
|
10
|
+
*
|
|
11
|
+
* JEDE Schwelle, jedes Muster und jede Wortliste kommt aus `./schema.mjs`. Ein
|
|
12
|
+
* Zahlenliteral in dieser Datei wäre ein zweiter Ort für dieselbe Schwelle —
|
|
13
|
+
* und damit ein Defekt in Wartestellung: ändert sich die Registry, driftet der
|
|
14
|
+
* Bewerter still. Die einzigen Zahlen hier sind `MAX_POINTS` (die Skala der
|
|
15
|
+
* Punkteformel, die im Schema-Kopf steht) und die 0 der unteren Klemmung.
|
|
16
|
+
*
|
|
17
|
+
* ## Die Randbedingung, die über allen acht Kriterien steht
|
|
18
|
+
*
|
|
19
|
+
* Ein Prüfer, der korrekte Fragen anmeckert, wird abgeschaltet — und dann ist
|
|
20
|
+
* jede richtig gefundene Schwäche mit ihm weg. Falsch-Positive kosten deshalb
|
|
21
|
+
* mehr als Falsch-Negative. Gemessen haben nur K5 und H2 eine FP-Rate von 0 %;
|
|
22
|
+
* alles andere liegt zwischen 14 % und 25 %. Konsequenzen im Code:
|
|
23
|
+
*
|
|
24
|
+
* - Im Zweifel wird NICHT gemeldet (Platzhalter-Texte werden übersprungen,
|
|
25
|
+
* Aufzählungen zählen bei K1 nicht als Bericht, K3 ist eine Konjunktion).
|
|
26
|
+
* - Hart scheitert nur, was eine Hürde reißt (H1/H2). Die Punktzahl wird
|
|
27
|
+
* berichtet, nicht erzwungen — `--strict` ist in der CLI standardmäßig aus.
|
|
28
|
+
*
|
|
29
|
+
* ## Das Feld, an dem sich dieses Programm selbst misst
|
|
30
|
+
*
|
|
31
|
+
* `AuqFinding.message` steht später vor dem Operator. Deutsch, ohne Fachjargon,
|
|
32
|
+
* und es nennt WAS gemessen wurde — nie die Kriteriums-ID. Eine Meldung wie
|
|
33
|
+
* "K7 violation: unglossed identifier token" wäre genau die Ironie, an der ein
|
|
34
|
+
* Klarheits-Prüfer scheitert.
|
|
35
|
+
*
|
|
36
|
+
* ## Der Schutz, der nicht wegoptimiert werden darf
|
|
37
|
+
*
|
|
38
|
+
* Ein Text, der auf `SAFETY_PATTERN` passt, ist von den LÄNGEN-Kriterien
|
|
39
|
+
* (`LENGTH_CRITERIA` = K1, K6) befreit. Das ist hier STRUKTURELL verdrahtet,
|
|
40
|
+
* nicht per Disziplin: `lengthFinding()` ist der einzige Weg, einen K1/K6-Befund
|
|
41
|
+
* zu bauen, und wirft, wenn man ein anderes Kriterium hindurchschickt;
|
|
42
|
+
* `finding()` wirft umgekehrt für K1/K6. Ein befreiter Befund wird auf `warn`
|
|
43
|
+
* herabgestuft UND trägt `exempt: 'length'` — die Punkterechnung ignoriert
|
|
44
|
+
* beides unabhängig voneinander, damit ein Ausrutscher an einer Stelle nicht
|
|
45
|
+
* reicht, um eine Sicherheitswarnung wegkürzbar zu machen.
|
|
46
|
+
*
|
|
47
|
+
* K3/K4/K5/K7/K8 gelten für Sicherheitstexte WEITER. Sie fügen Information
|
|
48
|
+
* hinzu (Grund, Preis, Erklärung) und nehmen keine weg — eine Warnung wird
|
|
49
|
+
* durch einen genannten Grund besser, nicht kürzer.
|
|
50
|
+
*
|
|
51
|
+
* ## Woher die Zahlen in den Begründungen stammen
|
|
52
|
+
*
|
|
53
|
+
* Jede Quote unten ("44 % Falsch-Positive", "fünf von 120 Paaren") kommt aus der
|
|
54
|
+
* Discovery-Zählung vom 2026-08-22 über 120 Optionen in 33 doppelt gequoteten
|
|
55
|
+
* Fragen. Dieser Zensus wurde danach nach oben korrigiert: 42 Fragen (33 doppelt
|
|
56
|
+
* gequotet + 9 in Backticks) mit 156 Optionen — ein zeilenverankertes grep hatte
|
|
57
|
+
* drei kompakte Einzeiler in skills/plan/SKILL.md verloren.
|
|
58
|
+
*
|
|
59
|
+
* Die NENNER unten sind also die des kleineren Zensus. Sie stehen hier als
|
|
60
|
+
* Begründung dafür, warum ein Kriterium so und nicht anders geschnitten ist,
|
|
61
|
+
* nicht als aktueller Bestand — der lebt im Bericht, den `scripts/auq-audit.mjs`
|
|
62
|
+
* erzeugt, und wird bei jeder Vorlagenänderung neu gemessen. Kein Test pinnt eine
|
|
63
|
+
* dieser Gesamtzahlen; ein Zählstand in einem Unit-Test driftet garantiert.
|
|
64
|
+
*
|
|
65
|
+
* @see ./schema.mjs — der eingefrorene Vertrag (Kriterien, Schwellen, Fabriken)
|
|
66
|
+
* @see .claude/rules/ask-via-tool.md — AUQ-001..005, die gemessene Regel
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
import {
|
|
70
|
+
CRITERIA,
|
|
71
|
+
CRITERION_IDS,
|
|
72
|
+
THRESHOLDS,
|
|
73
|
+
HURDLES,
|
|
74
|
+
LENGTH_CRITERIA,
|
|
75
|
+
CODE_FENCE_PATTERN,
|
|
76
|
+
TABLE_ROW_PATTERN,
|
|
77
|
+
LIST_LINE_PATTERN,
|
|
78
|
+
NEWLINE_PATTERN,
|
|
79
|
+
MARKER_CLASSES,
|
|
80
|
+
MARKER_CLASS_IDS,
|
|
81
|
+
RECOMMENDED_MARKERS,
|
|
82
|
+
STOPWORDS,
|
|
83
|
+
HEADER_PLACEHOLDER_PATTERN,
|
|
84
|
+
IDENTIFIER_PATTERNS,
|
|
85
|
+
IDENTIFIER_ALLOWLIST,
|
|
86
|
+
GLOSS_RULE,
|
|
87
|
+
READ_IMPERATIVE_PATTERN,
|
|
88
|
+
FIRST_PERSON_EXCLUSION_PATTERN,
|
|
89
|
+
isLengthExempt,
|
|
90
|
+
codepointLength,
|
|
91
|
+
normalizeLiteralNewlines,
|
|
92
|
+
globalOf,
|
|
93
|
+
makeFinding,
|
|
94
|
+
makeScore,
|
|
95
|
+
} from './schema.mjs';
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Struktur-Konstanten (KEINE Schwellen — die stehen alle in schema.mjs)
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Die Skala der Punkteformel aus dem Schema-Kopf: `points = 100 − Σ weight`.
|
|
103
|
+
* Bewusst NICHT `TOTAL_WEIGHT`: dass die Gewichtssumme heute ebenfalls 100 ist,
|
|
104
|
+
* ist eine Invariante der Registry, nicht die Definition der Skala. Würden die
|
|
105
|
+
* beiden über `TOTAL_WEIGHT` gekoppelt, verschöbe eine Gewichtsänderung
|
|
106
|
+
* stillschweigend auch die Notenbänder.
|
|
107
|
+
*/
|
|
108
|
+
const MAX_POINTS = 100;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Ein Platzhalter im Text: `<name>` oder `{{name}}`. Bewusst zeilenbegrenzt und
|
|
112
|
+
* ohne Längengrenze, damit hier keine Zahl steht.
|
|
113
|
+
*
|
|
114
|
+
* Die eckige Klammer `[...]` aus `HEADER_PLACEHOLDER_PATTERN` ist hier ABSICHTLICH
|
|
115
|
+
* NICHT dabei: in Kopfzeilen ist `[x]` ein Platzhalter, in Fragetexten ist es
|
|
116
|
+
* echter Inhalt (`"- [X] critical"` in skills/discovery/SKILL.md). Sie mit
|
|
117
|
+
* aufzunehmen würde reale Aufzählungen wegfiltern.
|
|
118
|
+
*/
|
|
119
|
+
const PLACEHOLDER_PATTERN = /<[^<>\n]+>|\{\{[^{}\n]+\}\}/u;
|
|
120
|
+
|
|
121
|
+
/** Die im Korpus verwendete Platzhalter-Floskel ohne Klammern. */
|
|
122
|
+
const PLACEHOLDER_PHRASE = 'one-line description';
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Satzgrenze: Punkt/Ausrufe-/Fragezeichen, Leerraum, und danach ein
|
|
126
|
+
* GROSSBUCHSTABE oder eine öffnende Klammer.
|
|
127
|
+
*
|
|
128
|
+
* Die Großbuchstaben-Bedingung ist tragend, nicht kosmetisch: ohne sie zerfällt
|
|
129
|
+
* `"… docs/prd/YYYY-MM-DD-<feature>.md — most precise decomposition."` am `.md `
|
|
130
|
+
* in zwei Sätze, und die Glosse hinter dem Gedankenstrich landet in einem
|
|
131
|
+
* anderen Satz als der Bezeichner, den sie erklärt. K7 meldete dann einen
|
|
132
|
+
* Falsch-Positiv auf einer vorbildlich glossierten Option.
|
|
133
|
+
*/
|
|
134
|
+
const SENTENCE_BOUNDARY = /(?<=[.!?])\s+(?=["'([{\p{Lu}])/u;
|
|
135
|
+
|
|
136
|
+
/** Wortgrenze für Wortzählungen. */
|
|
137
|
+
const WHITESPACE = /\s+/u;
|
|
138
|
+
|
|
139
|
+
/** Alles, was kein Buchstabe und keine Ziffer ist, trennt Inhaltswörter. */
|
|
140
|
+
const NON_WORD = /[^\p{L}\p{N}]+/u;
|
|
141
|
+
|
|
142
|
+
/** Punkte werden aus dem K8-Abstand herausgerechnet (siehe THRESHOLDS.K8). */
|
|
143
|
+
const PERIOD = /\./u;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Eine Einsetzung in einem Backtick-Literal: `${…}`.
|
|
147
|
+
*
|
|
148
|
+
* In einem doppelt gequoteten String ist dieselbe Zeichenfolge wörtlicher Text
|
|
149
|
+
* und KEINE Einsetzung — deshalb wird zusätzlich `quoting === 'backtick'`
|
|
150
|
+
* geprüft, statt allein auf dieses Muster zu gehen.
|
|
151
|
+
*/
|
|
152
|
+
const INTERPOLATION = /\$\{/u;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Der Zusatz, der eine Längenangabe als UNTERGRENZE kennzeichnet.
|
|
156
|
+
*
|
|
157
|
+
* Neun Fragen im Korpus stehen in Backticks und setzen Werte ein
|
|
158
|
+
* (`${blockingSession.worktreePath}` wird zu einem absoluten Pfad). Am
|
|
159
|
+
* Quelltext gemessen ist die Zahl also kleiner als das, was der Operator sieht.
|
|
160
|
+
*
|
|
161
|
+
* WICHTIG, in welche Richtung das wirkt: die Unsicherheit ist EINSEITIG. Reißt
|
|
162
|
+
* schon die Untergrenze eine Schwelle, ist der Verstoß sicher — er wird durch
|
|
163
|
+
* die Einsetzung nur größer. Gefährlich ist allein der stumme Gegenfall: eine
|
|
164
|
+
* Frage, die im Quelltext unter der Schwelle liegt und auf dem Schirm darüber.
|
|
165
|
+
* Den kann dieses Modul nicht sehen, und es wird ihn nicht schätzen — ein
|
|
166
|
+
* erfundener Expansionsfaktor wäre eine Zahl ohne Messung. Der Zusatz sorgt
|
|
167
|
+
* dafür, dass ein späterer Leser die neun Werte nicht für exakt hält.
|
|
168
|
+
*
|
|
169
|
+
* @param {import('./schema.mjs').AuqQuestion} q
|
|
170
|
+
* @param {string} text der Text, dessen Länge gemessen wurde
|
|
171
|
+
* @returns {string} leerer String, wenn die Messung exakt ist
|
|
172
|
+
*/
|
|
173
|
+
function lowerBoundNote(q, text) {
|
|
174
|
+
if (q.quoting !== 'backtick') return '';
|
|
175
|
+
if (!INTERPOLATION.test(str(text))) return '';
|
|
176
|
+
return (
|
|
177
|
+
' Die Vorlage setzt zur Laufzeit Werte ein — der gemessene Wert ist eine UNTERGRENZE, ' +
|
|
178
|
+
'auf dem Schirm steht mehr.'
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Satzzeichen am ENDE eines gefundenen Bezeichners.
|
|
184
|
+
*
|
|
185
|
+
* Das Pfadmuster ist gierig und verschluckt den Satzpunkt: aus
|
|
186
|
+
* `"… via node scripts/telemetry.mjs."` wird das Token `scripts/telemetry.mjs.`.
|
|
187
|
+
* Das ist nicht nur hässlich in der Meldung — die Allowlist vergleicht per
|
|
188
|
+
* VERTRAG auf exakte Zeichengleichheit, also verfehlt `STATE.md.` am Satzende
|
|
189
|
+
* den Eintrag `STATE.md` und K7 meldet einen ausdrücklich erlaubten Bezeichner
|
|
190
|
+
* als unerklärt. Führende Punkte bleiben erhalten (`.orchestrator/session.lock`).
|
|
191
|
+
*/
|
|
192
|
+
const TRAILING_PUNCTUATION = /[.,;:!?)\]]+$/u;
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Textprimitive
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/** @param {unknown} v @returns {string} */
|
|
199
|
+
function str(v) {
|
|
200
|
+
return typeof v === 'string' ? v : '';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Entfernt Platzhalter ERSATZLOS (nicht durch ein Leerzeichen).
|
|
205
|
+
*
|
|
206
|
+
* Ersatzlos ist der Punkt: `docs/prd/YYYY-MM-DD-<feature>.md` zerfällt sonst in
|
|
207
|
+
* die zwei Bruchstücke `docs/prd/YYYY-MM-DD-` und `.md`, von denen die Glosse
|
|
208
|
+
* hinter dem Gedankenstrich nur das zweite erreicht. Ein Platzhalter ist ein
|
|
209
|
+
* zur Laufzeit gefülltes Loch, kein Wortende.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} text
|
|
212
|
+
* @returns {string}
|
|
213
|
+
*/
|
|
214
|
+
function stripPlaceholders(text) {
|
|
215
|
+
return str(text).replace(globalOf(PLACEHOLDER_PATTERN), '');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Entfernt `(Recommended)` / `(Empfohlen)` aus einem Label.
|
|
220
|
+
* @param {string} text
|
|
221
|
+
* @returns {string}
|
|
222
|
+
*/
|
|
223
|
+
function stripRecommendedMarkers(text) {
|
|
224
|
+
let out = str(text);
|
|
225
|
+
for (const marker of RECOMMENDED_MARKERS) out = out.split(marker).join('');
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Ist der Text NUR ein Platzhalter (und damit nichts, was man bewerten kann)?
|
|
231
|
+
*
|
|
232
|
+
* Eine Vorlage wie `"<one-line description of what this tier scaffolds>"` ist
|
|
233
|
+
* kein Verstoß gegen K3/K4 — sie ist noch gar kein Text. Ohne diese Prüfung
|
|
234
|
+
* meldet der Bewerter genau die Stellen, an denen der Autor bereits weiß, dass
|
|
235
|
+
* dort etwas hingehört: die teuerste Sorte Falsch-Positiv, weil sie den Prüfer
|
|
236
|
+
* unglaubwürdig macht, ohne irgendetwas zu finden.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} text
|
|
239
|
+
* @returns {boolean}
|
|
240
|
+
*/
|
|
241
|
+
function isPlaceholderOnly(text) {
|
|
242
|
+
const s = str(text);
|
|
243
|
+
if (s.toLowerCase().includes(PLACEHOLDER_PHRASE)) return true;
|
|
244
|
+
return stripRecommendedMarkers(stripPlaceholders(s)).trim() === '';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Zerlegt einen Text in Sätze. K7 und K8 messen JE SATZ — eine Erklärung im
|
|
249
|
+
* nächsten Satz ist keine Glosse mehr, und ein Lese-Imperativ im vorigen Satz
|
|
250
|
+
* zeigt nicht auf den Pfad im aktuellen.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} text
|
|
253
|
+
* @returns {string[]}
|
|
254
|
+
*/
|
|
255
|
+
function sentencesOf(text) {
|
|
256
|
+
const flat = normalizeLiteralNewlines(str(text)).trim();
|
|
257
|
+
if (flat === '') return [];
|
|
258
|
+
return flat
|
|
259
|
+
.split(SENTENCE_BOUNDARY)
|
|
260
|
+
.map((s) => s.trim())
|
|
261
|
+
.filter((s) => s !== '');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** @param {string} text @returns {string[]} */
|
|
265
|
+
function wordsOf(text) {
|
|
266
|
+
return str(text).trim().split(WHITESPACE).filter((w) => w !== '');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Inhaltswörter eines Textes: kleingeschrieben, ohne Platzhalter, ohne
|
|
271
|
+
* Empfehlungsmarker, ohne Stoppwörter, ohne zu kurze Token.
|
|
272
|
+
*
|
|
273
|
+
* @param {string} text
|
|
274
|
+
* @returns {Set<string>}
|
|
275
|
+
*/
|
|
276
|
+
function contentTokens(text) {
|
|
277
|
+
const out = new Set();
|
|
278
|
+
const cleaned = stripRecommendedMarkers(stripPlaceholders(text)).toLowerCase();
|
|
279
|
+
for (const token of cleaned.split(NON_WORD)) {
|
|
280
|
+
if (token === '') continue;
|
|
281
|
+
if (codepointLength(token) < THRESHOLDS.K3.tokenMinChars) continue;
|
|
282
|
+
if (STOPWORDS.has(token)) continue;
|
|
283
|
+
out.add(token);
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// Bezeichner-Erkennung (Stufe 1 des K7-Detektors, auch von K8 benutzt)
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @typedef {object} Span
|
|
294
|
+
* @property {string} id Die `id` des Musters aus `IDENTIFIER_PATTERNS`.
|
|
295
|
+
* @property {string} token Der gefundene Text.
|
|
296
|
+
* @property {number} start Index (UTF-16) im übergebenen Text.
|
|
297
|
+
* @property {number} end Index hinter dem letzten Zeichen.
|
|
298
|
+
*/
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Alle Bezeichner-Spannen eines Textes, ohne Doppelzählung.
|
|
302
|
+
*
|
|
303
|
+
* Mehrere Muster treffen denselben Text (`docs/telemetry.md` passt sowohl auf
|
|
304
|
+
* das Verzeichnis-Präfix als auch auf die Dateiendung). Enthaltene Treffer
|
|
305
|
+
* werden verworfen, sonst zählte ein Bezeichner zweimal und die K7-Meldung
|
|
306
|
+
* nennte ihn dem Operator doppelt.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} text
|
|
309
|
+
* @returns {Span[]}
|
|
310
|
+
*/
|
|
311
|
+
function identifierSpans(text) {
|
|
312
|
+
const found = [];
|
|
313
|
+
for (const { id, pattern } of IDENTIFIER_PATTERNS) {
|
|
314
|
+
const re = globalOf(pattern);
|
|
315
|
+
let match = re.exec(text);
|
|
316
|
+
while (match !== null) {
|
|
317
|
+
if (match[0] === '') {
|
|
318
|
+
re.lastIndex += 1;
|
|
319
|
+
} else {
|
|
320
|
+
// `start`/`end` bleiben am ROHEN Treffer: ein kleinerer Abstand zur
|
|
321
|
+
// Glosse ist die FP-sichere Richtung. Getrimmt wird nur der Text, der
|
|
322
|
+
// gegen die Allowlist geprüft und dem Operator genannt wird.
|
|
323
|
+
const trimmed = match[0].replace(TRAILING_PUNCTUATION, '');
|
|
324
|
+
found.push({
|
|
325
|
+
id,
|
|
326
|
+
token: trimmed === '' ? match[0] : trimmed,
|
|
327
|
+
start: match.index,
|
|
328
|
+
end: match.index + match[0].length,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
match = re.exec(text);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// Längste Spanne zuerst, damit die enthaltene und nicht die umfassende fällt.
|
|
335
|
+
found.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
336
|
+
/** @type {Span[]} */
|
|
337
|
+
const kept = [];
|
|
338
|
+
for (const span of found) {
|
|
339
|
+
const covered = kept.some((k) => span.start >= k.start && span.end <= k.end);
|
|
340
|
+
if (!covered) kept.push(span);
|
|
341
|
+
}
|
|
342
|
+
return kept;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// Glossen-Erkennung (Stufe 3 des K7-Detektors)
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Alle Spannen, die von einem Trenner aus `GLOSS_RULE.delimiters` eingefasst
|
|
351
|
+
* sind. Trenner ohne Schließzeichen (Gedankenstrich, Doppelpunkt) reichen bis
|
|
352
|
+
* zum Satzende — genau so liest ein Mensch sie.
|
|
353
|
+
*
|
|
354
|
+
* @param {string} sentence
|
|
355
|
+
* @returns {Array<{id: string, start: number, end: number}>}
|
|
356
|
+
*/
|
|
357
|
+
function delimitedSpans(sentence) {
|
|
358
|
+
const spans = [];
|
|
359
|
+
for (const delim of GLOSS_RULE.delimiters) {
|
|
360
|
+
let from = 0;
|
|
361
|
+
for (;;) {
|
|
362
|
+
const open = sentence.indexOf(delim.open, from);
|
|
363
|
+
if (open === -1) break;
|
|
364
|
+
const innerStart = open + delim.open.length;
|
|
365
|
+
if (delim.close === null) {
|
|
366
|
+
spans.push({ id: delim.id, start: innerStart, end: sentence.length });
|
|
367
|
+
from = innerStart;
|
|
368
|
+
} else {
|
|
369
|
+
const close = sentence.indexOf(delim.close, innerStart);
|
|
370
|
+
if (close === -1) break;
|
|
371
|
+
spans.push({ id: delim.id, start: innerStart, end: close });
|
|
372
|
+
from = close + delim.close.length;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return spans;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Die Spannen, die alle inhaltlichen Glossen-Bedingungen erfüllen (3 und 4 aus
|
|
381
|
+
* `GLOSS_RULE`). Bedingung 4 ist die tragende: eine "Erklärung", die selbst aus
|
|
382
|
+
* Bezeichnern besteht, verschiebt das Nachschlagen nur um ein Wort.
|
|
383
|
+
*
|
|
384
|
+
* @param {string} sentence
|
|
385
|
+
* @returns {Array<{id: string, start: number, end: number}>}
|
|
386
|
+
*/
|
|
387
|
+
function qualifyingGlosses(sentence) {
|
|
388
|
+
return delimitedSpans(sentence).filter((span) => {
|
|
389
|
+
const inner = sentence.slice(span.start, span.end);
|
|
390
|
+
if (wordsOf(inner).length < GLOSS_RULE.minWords) return false;
|
|
391
|
+
if (GLOSS_RULE.mustContainNoIdentifiers && identifierSpans(inner).length > 0) return false;
|
|
392
|
+
return true;
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Bedingung 2 aus `GLOSS_RULE`: steht die Glosse dicht genug am Bezeichner?
|
|
398
|
+
*
|
|
399
|
+
* @param {Span} token
|
|
400
|
+
* @param {Array<{start: number, end: number}>} glosses
|
|
401
|
+
* @returns {boolean}
|
|
402
|
+
*/
|
|
403
|
+
function isGlossed(token, glosses) {
|
|
404
|
+
return glosses.some((gloss) => {
|
|
405
|
+
const after = gloss.start - token.end;
|
|
406
|
+
if (after >= 0 && after <= GLOSS_RULE.maxGapChars) return true;
|
|
407
|
+
const before = token.start - gloss.end;
|
|
408
|
+
return before >= 0 && before <= GLOSS_RULE.maxGapChars;
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Der dreistufige K7-Detektor: erkennen → Allowlist → Glossen-Regel.
|
|
414
|
+
*
|
|
415
|
+
* @param {string} text
|
|
416
|
+
* @returns {string[]} die unerklärten Bezeichner, in Fundreihenfolge
|
|
417
|
+
*/
|
|
418
|
+
function unexplainedIdentifiers(text) {
|
|
419
|
+
const out = [];
|
|
420
|
+
for (const sentence of sentencesOf(stripPlaceholders(text))) {
|
|
421
|
+
const glosses = qualifyingGlosses(sentence);
|
|
422
|
+
for (const token of identifierSpans(sentence)) {
|
|
423
|
+
if (IDENTIFIER_ALLOWLIST.has(token.token)) continue;
|
|
424
|
+
if (isGlossed(token, glosses)) continue;
|
|
425
|
+
out.push(token.token);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
// Befund-Fabriken — der strukturelle Ort der Sicherheits-Ausnahme
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Die Stufe, die ein Kriterium überhaupt erreichen KANN. K2 kann nie `fail`
|
|
437
|
+
* werden, egal wie lang der Satz ist — das steht in der Registry und wird hier
|
|
438
|
+
* erzwungen statt an acht Aufrufstellen wiederholt.
|
|
439
|
+
*
|
|
440
|
+
* @param {string} criterion
|
|
441
|
+
* @param {'fail'|'warn'} wanted
|
|
442
|
+
* @returns {'fail'|'warn'}
|
|
443
|
+
*/
|
|
444
|
+
function cappedSeverity(criterion, wanted) {
|
|
445
|
+
return CRITERIA[criterion].severity === 'warn' ? 'warn' : wanted;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Befund für ein Kriterium, das KEIN Längenkriterium ist.
|
|
450
|
+
*
|
|
451
|
+
* Wirft für K1/K6. Das ist die eine Hälfte der strukturellen Absicherung: ein
|
|
452
|
+
* Längenbefund kann die Sicherheits-Ausnahme nicht versehentlich umgehen, weil
|
|
453
|
+
* er hier gar nicht gebaut werden kann.
|
|
454
|
+
*
|
|
455
|
+
* @param {Parameters<typeof makeFinding>[0]} rec
|
|
456
|
+
* @returns {import('./schema.mjs').AuqFinding}
|
|
457
|
+
*/
|
|
458
|
+
function finding(rec) {
|
|
459
|
+
if (LENGTH_CRITERIA.includes(rec.criterion)) {
|
|
460
|
+
throw new TypeError(
|
|
461
|
+
`auq/clarity: ${rec.criterion} ist ein Längenkriterium und muss über lengthFinding() laufen`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
return makeFinding({ ...rec, severity: cappedSeverity(rec.criterion, rec.severity) });
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Befund für ein Längenkriterium (K1/K6), mit angewandter Sicherheits-Ausnahme.
|
|
469
|
+
*
|
|
470
|
+
* `subject` ist der Text, dessen LÄNGE gemessen wurde. Passt er auf das
|
|
471
|
+
* Sicherheits-Lexikon, wird der Befund auf `warn` herabgestuft und trägt
|
|
472
|
+
* `exempt: 'length'`.
|
|
473
|
+
*
|
|
474
|
+
* Für Befunde ohne gemessenen Text — die Optionsanzahl je Frage — ist `subject`
|
|
475
|
+
* der leere String. `isLengthExempt('')` ist per Vertrag `null`, also kann die
|
|
476
|
+
* Ausnahme die Hürde H2 strukturell nicht aushebeln: eine ZAHL hat keinen Text,
|
|
477
|
+
* der geschützt werden müsste, und ein Sicherheitswort in irgendeiner Option
|
|
478
|
+
* darf nicht dazu führen, dass eine Frage mit 7 Optionen als in Ordnung gilt.
|
|
479
|
+
*
|
|
480
|
+
* @param {Parameters<typeof makeFinding>[0]} rec
|
|
481
|
+
* @param {string} subject
|
|
482
|
+
* @returns {import('./schema.mjs').AuqFinding}
|
|
483
|
+
*/
|
|
484
|
+
function lengthFinding(rec, subject) {
|
|
485
|
+
if (!LENGTH_CRITERIA.includes(rec.criterion)) {
|
|
486
|
+
throw new TypeError(
|
|
487
|
+
`auq/clarity: ${rec.criterion} ist kein Längenkriterium und gehört nicht in lengthFinding()`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
const exempt = isLengthExempt(subject);
|
|
491
|
+
const severity = exempt === null ? cappedSeverity(rec.criterion, rec.severity) : 'warn';
|
|
492
|
+
return makeFinding({ ...rec, severity, exempt });
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
// K1 — Payload-Form (Frage)
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Zählt die eingebetteten Zeilen, die ein BERICHT sind.
|
|
501
|
+
*
|
|
502
|
+
* Leerzeilen und Aufzählungszeilen zählen NICHT mit. Die Ausnahme ist tragend:
|
|
503
|
+
* `"Ready to create [N] issues?\n\n- [X] critical\n- [Y] high…"` hat fünf
|
|
504
|
+
* eingebettete Zeilen und ist trotzdem in Ordnung — die Liste IST die Tatsache,
|
|
505
|
+
* die der Operator zum Entscheiden braucht. Ohne die Ausnahme meldet K1 die
|
|
506
|
+
* bestgebaute Bestätigungsfrage im Repo als Verstoß.
|
|
507
|
+
*
|
|
508
|
+
* @param {string} text
|
|
509
|
+
* @returns {number}
|
|
510
|
+
*/
|
|
511
|
+
function reportLines(text) {
|
|
512
|
+
const segments = str(text).split(NEWLINE_PATTERN);
|
|
513
|
+
let count = 0;
|
|
514
|
+
for (let i = 1; i < segments.length; i += 1) {
|
|
515
|
+
const segment = segments[i];
|
|
516
|
+
if (segment.trim() === '') continue;
|
|
517
|
+
if (LIST_LINE_PATTERN.test(segment)) continue;
|
|
518
|
+
count += 1;
|
|
519
|
+
}
|
|
520
|
+
return count;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* @param {import('./schema.mjs').AuqQuestion} q
|
|
525
|
+
* @param {{file: string, line: number}} at
|
|
526
|
+
* @returns {import('./schema.mjs').AuqFinding[]}
|
|
527
|
+
*/
|
|
528
|
+
function checkK1(q, at) {
|
|
529
|
+
const raw = str(q.question);
|
|
530
|
+
if (raw.trim() === '') return [];
|
|
531
|
+
const out = [];
|
|
532
|
+
const base = { criterion: 'K1', severity: /** @type {'fail'} */ ('fail'), target: /** @type {'question'} */ ('question'), ...at, excerpt: raw };
|
|
533
|
+
|
|
534
|
+
const chars = codepointLength(normalizeLiteralNewlines(raw));
|
|
535
|
+
if (chars > THRESHOLDS.K1.questionCharsFail) {
|
|
536
|
+
out.push(
|
|
537
|
+
lengthFinding(
|
|
538
|
+
{
|
|
539
|
+
...base,
|
|
540
|
+
message:
|
|
541
|
+
`Die Frage ist ${chars} Zeichen lang. Über ${THRESHOLDS.K1.questionCharsFail} liest der ` +
|
|
542
|
+
'Operator sie als Bericht, nicht als Frage — der Sachstand gehört in die Optionen.' +
|
|
543
|
+
lowerBoundNote(q, raw),
|
|
544
|
+
measured: chars,
|
|
545
|
+
threshold: THRESHOLDS.K1.questionCharsFail,
|
|
546
|
+
},
|
|
547
|
+
raw,
|
|
548
|
+
),
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const lines = reportLines(raw);
|
|
553
|
+
if (lines >= THRESHOLDS.K1.embeddedLinesFail) {
|
|
554
|
+
out.push(
|
|
555
|
+
lengthFinding(
|
|
556
|
+
{
|
|
557
|
+
...base,
|
|
558
|
+
message:
|
|
559
|
+
`Die Frage bringt ${lines} zusätzliche Fließtextzeilen mit. Das ist ein Bericht mit ` +
|
|
560
|
+
'Fragezeichen. (Aufzählungszeilen zählen nicht mit — die sind lesbare Struktur.)',
|
|
561
|
+
measured: lines,
|
|
562
|
+
threshold: THRESHOLDS.K1.embeddedLinesFail,
|
|
563
|
+
},
|
|
564
|
+
raw,
|
|
565
|
+
),
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (CODE_FENCE_PATTERN.test(raw)) {
|
|
570
|
+
out.push(
|
|
571
|
+
lengthFinding(
|
|
572
|
+
{
|
|
573
|
+
...base,
|
|
574
|
+
message:
|
|
575
|
+
'Die Frage enthält einen Codeblock. Der Operator soll entscheiden, nicht Quelltext lesen.',
|
|
576
|
+
measured: 'Codeblock',
|
|
577
|
+
threshold: 'kein Codeblock',
|
|
578
|
+
},
|
|
579
|
+
raw,
|
|
580
|
+
),
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (TABLE_ROW_PATTERN.test(normalizeLiteralNewlines(raw))) {
|
|
585
|
+
out.push(
|
|
586
|
+
lengthFinding(
|
|
587
|
+
{
|
|
588
|
+
...base,
|
|
589
|
+
message:
|
|
590
|
+
'Die Frage enthält eine Tabellenzeile. Eine Tabelle ist Material zum Nachschlagen, ' +
|
|
591
|
+
'keine Frage.',
|
|
592
|
+
measured: 'Tabellenzeile',
|
|
593
|
+
threshold: 'keine Tabelle',
|
|
594
|
+
},
|
|
595
|
+
raw,
|
|
596
|
+
),
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return out;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
// K2 — Satzlänge (Frage, Gewicht 0, nur Hinweis)
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Nebensignal, absichtlich zahnlos.
|
|
609
|
+
*
|
|
610
|
+
* Der längste Satz im ganzen Korpus hat 27 Wörter: jede Schwelle feuert auf
|
|
611
|
+
* 1–3 von 33 Fragen (Zensus siehe Kopf) und verfehlt die schlechteste Frage
|
|
612
|
+
* vollständig. Als
|
|
613
|
+
* Kriterium wäre das ein Zufallsgenerator, als Hinweis ist es brauchbar —
|
|
614
|
+
* deshalb Gewicht 0 und `severity: 'warn'` in der Registry, was `cappedSeverity`
|
|
615
|
+
* hier erzwingt. Sichtbar, nicht wirksam.
|
|
616
|
+
*
|
|
617
|
+
* @param {import('./schema.mjs').AuqQuestion} q
|
|
618
|
+
* @param {{file: string, line: number}} at
|
|
619
|
+
* @returns {import('./schema.mjs').AuqFinding[]}
|
|
620
|
+
*/
|
|
621
|
+
function checkK2(q, at) {
|
|
622
|
+
const sentences = sentencesOf(q.question);
|
|
623
|
+
if (sentences.length === 0) return [];
|
|
624
|
+
let longest = '';
|
|
625
|
+
let longestWords = 0;
|
|
626
|
+
for (const sentence of sentences) {
|
|
627
|
+
const count = wordsOf(sentence).length;
|
|
628
|
+
if (count > longestWords) {
|
|
629
|
+
longestWords = count;
|
|
630
|
+
longest = sentence;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (longestWords <= THRESHOLDS.K2.sentenceWordsWarn) return [];
|
|
634
|
+
return [
|
|
635
|
+
finding({
|
|
636
|
+
criterion: 'K2',
|
|
637
|
+
severity: 'warn',
|
|
638
|
+
target: 'question',
|
|
639
|
+
...at,
|
|
640
|
+
message:
|
|
641
|
+
`Der längste Satz der Frage hat ${longestWords} Wörter. Ab ` +
|
|
642
|
+
`${THRESHOLDS.K2.sentenceWordsWarn} muss der Operator zweimal lesen — ein Hinweis, ` +
|
|
643
|
+
'kein Fehler.',
|
|
644
|
+
measured: longestWords,
|
|
645
|
+
threshold: THRESHOLDS.K2.sentenceWordsWarn,
|
|
646
|
+
excerpt: longest,
|
|
647
|
+
}),
|
|
648
|
+
];
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ---------------------------------------------------------------------------
|
|
652
|
+
// K3 — Beschreibung wiederholt Label (Option)
|
|
653
|
+
// ---------------------------------------------------------------------------
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* KONJUNKTION, nicht Disjunktion — das ist der ganze Inhalt dieses Kriteriums.
|
|
657
|
+
*
|
|
658
|
+
* Containment allein hat 100 % Falsch-Positive: fünf von 120 Paaren (Zensus siehe
|
|
659
|
+
* Kopf) erreichen
|
|
660
|
+
* containment 1.00, und alle fünf sind Einwort-Labels (`new`, `full`, `feature`,
|
|
661
|
+
* `onboarding`), deren Beschreibung reichlich Substanz trägt. Erst
|
|
662
|
+
* "sagt wenig Neues" UND "wiederholt das halbe Label" trifft den echten Fall.
|
|
663
|
+
*
|
|
664
|
+
* @param {import('./schema.mjs').AuqOption} option
|
|
665
|
+
* @param {{file: string, line: number}} at
|
|
666
|
+
* @returns {import('./schema.mjs').AuqFinding[]}
|
|
667
|
+
*/
|
|
668
|
+
function checkK3(option, at) {
|
|
669
|
+
const label = str(option.label);
|
|
670
|
+
const description = str(option.description);
|
|
671
|
+
if (description.trim() === '') return [];
|
|
672
|
+
if (isPlaceholderOnly(label) || isPlaceholderOnly(description)) return [];
|
|
673
|
+
|
|
674
|
+
const labelTokens = contentTokens(label);
|
|
675
|
+
if (labelTokens.size === 0) return [];
|
|
676
|
+
const descriptionTokens = contentTokens(description);
|
|
677
|
+
|
|
678
|
+
let repeated = 0;
|
|
679
|
+
for (const token of labelTokens) if (descriptionTokens.has(token)) repeated += 1;
|
|
680
|
+
const containment = repeated / labelTokens.size;
|
|
681
|
+
|
|
682
|
+
let novelty = 0;
|
|
683
|
+
for (const token of descriptionTokens) if (!labelTokens.has(token)) novelty += 1;
|
|
684
|
+
|
|
685
|
+
if (novelty >= THRESHOLDS.K3.noveltyFailBelow) return [];
|
|
686
|
+
if (containment < THRESHOLDS.K3.containmentFailAtOrAbove) return [];
|
|
687
|
+
|
|
688
|
+
const percent = Math.round(containment * MAX_POINTS);
|
|
689
|
+
return [
|
|
690
|
+
finding({
|
|
691
|
+
criterion: 'K3',
|
|
692
|
+
severity: 'fail',
|
|
693
|
+
target: 'option',
|
|
694
|
+
optionIndex: option.index,
|
|
695
|
+
...at,
|
|
696
|
+
message:
|
|
697
|
+
`Die Beschreibung sagt kaum etwas Neues: ${novelty} neue Wörter, und sie wiederholt ` +
|
|
698
|
+
`${percent} % des Labels. Der Operator erfährt hier nichts, was auf dem Knopf nicht ` +
|
|
699
|
+
'schon steht.',
|
|
700
|
+
measured: `novelty=${novelty} containment=${containment.toFixed(2)}`,
|
|
701
|
+
threshold: `novelty<${THRESHOLDS.K3.noveltyFailBelow} und containment>=${THRESHOLDS.K3.containmentFailAtOrAbove}`,
|
|
702
|
+
excerpt: `${label} → ${description}`,
|
|
703
|
+
}),
|
|
704
|
+
];
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ---------------------------------------------------------------------------
|
|
708
|
+
// K4 — Empfehlung ohne Grund (Option)
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* ALLE VIER Markerklassen, und zwar zwingend.
|
|
713
|
+
*
|
|
714
|
+
* Gemessen (Zensus siehe Kopf): mit drei Klassen sind 16 von 29
|
|
715
|
+
* `(Recommended)`-Optionen markiert,
|
|
716
|
+
* 7 davon falsch (44 % FP) — darunter "Fastest for file-disjoint tasks." und
|
|
717
|
+
* "most precise decomposition", die beide sehr wohl einen Grund nennen, nämlich
|
|
718
|
+
* einen Vergleich. Mit der vierten Klasse: 9 von 29, 1 davon falsch (14 %).
|
|
719
|
+
*
|
|
720
|
+
* @param {import('./schema.mjs').AuqOption} option
|
|
721
|
+
* @param {{file: string, line: number}} at
|
|
722
|
+
* @returns {import('./schema.mjs').AuqFinding[]}
|
|
723
|
+
*/
|
|
724
|
+
function checkK4(option, at) {
|
|
725
|
+
if (option.isRecommended !== true) return [];
|
|
726
|
+
const description = str(option.description);
|
|
727
|
+
if (isPlaceholderOnly(description)) return [];
|
|
728
|
+
|
|
729
|
+
const matched = MARKER_CLASS_IDS.filter((id) => MARKER_CLASSES[id].pattern.test(description));
|
|
730
|
+
if (matched.length >= THRESHOLDS.K4.markersRequired) return [];
|
|
731
|
+
|
|
732
|
+
const classNames = MARKER_CLASS_IDS.map((id) => MARKER_CLASSES[id].title).join(', ');
|
|
733
|
+
return [
|
|
734
|
+
finding({
|
|
735
|
+
criterion: 'K4',
|
|
736
|
+
severity: 'fail',
|
|
737
|
+
target: 'option',
|
|
738
|
+
optionIndex: option.index,
|
|
739
|
+
...at,
|
|
740
|
+
message:
|
|
741
|
+
'Diese Option ist als Empfehlung markiert, nennt aber keinen Grund, keinen Preis, keine ' +
|
|
742
|
+
'Folge und keinen Vergleich. Eine Empfehlung ohne all das ist eine Behauptung, die der ' +
|
|
743
|
+
'Operator nur glauben oder ignorieren kann.',
|
|
744
|
+
measured: 0,
|
|
745
|
+
threshold: `mindestens ${THRESHOLDS.K4.markersRequired} von: ${classNames}`,
|
|
746
|
+
excerpt: `${str(option.label)} → ${description}`,
|
|
747
|
+
}),
|
|
748
|
+
];
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ---------------------------------------------------------------------------
|
|
752
|
+
// K5 — Header-Grenze (Hürde H1)
|
|
753
|
+
// ---------------------------------------------------------------------------
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* CODEPOINTS, nicht Bytes.
|
|
757
|
+
*
|
|
758
|
+
* `Buffer.byteLength('Evolve — Rev')` ist 14, die Kopfzeile hat aber 12 Zeichen:
|
|
759
|
+
* bytebasiert gemessen reißt sie eine Grenze, die sie nicht reißt, und der
|
|
760
|
+
* Bericht meldet eine Kopfzeile als abgeschnitten, die vollständig ankommt.
|
|
761
|
+
*
|
|
762
|
+
* @param {import('./schema.mjs').AuqQuestion} q
|
|
763
|
+
* @param {{file: string, line: number}} at
|
|
764
|
+
* @returns {{findings: import('./schema.mjs').AuqFinding[], hurdles: string[]}}
|
|
765
|
+
*/
|
|
766
|
+
function checkK5(q, at) {
|
|
767
|
+
const header = q.header;
|
|
768
|
+
if (typeof header !== 'string' || header.trim() === '') return { findings: [], hurdles: [] };
|
|
769
|
+
|
|
770
|
+
const literal = codepointLength(header);
|
|
771
|
+
|
|
772
|
+
if (HEADER_PLACEHOLDER_PATTERN.test(header)) {
|
|
773
|
+
const worst = literal + THRESHOLDS.K5.placeholderPad;
|
|
774
|
+
if (worst <= THRESHOLDS.K5.headerCharsFail) return { findings: [], hurdles: [] };
|
|
775
|
+
return {
|
|
776
|
+
findings: [
|
|
777
|
+
finding({
|
|
778
|
+
criterion: 'K5',
|
|
779
|
+
severity: 'warn',
|
|
780
|
+
target: 'header',
|
|
781
|
+
...at,
|
|
782
|
+
message:
|
|
783
|
+
`Die Kopfzeile enthält einen Platzhalter. Eingesetzt wird sie im ungünstigsten Fall ` +
|
|
784
|
+
`${worst} Zeichen lang und damit über ${THRESHOLDS.K5.headerCharsFail} abgeschnitten — ` +
|
|
785
|
+
'geprüft werden kann das erst zur Laufzeit.',
|
|
786
|
+
measured: worst,
|
|
787
|
+
threshold: THRESHOLDS.K5.headerCharsFail,
|
|
788
|
+
excerpt: header,
|
|
789
|
+
}),
|
|
790
|
+
],
|
|
791
|
+
hurdles: [],
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (literal <= THRESHOLDS.K5.headerCharsFail) return { findings: [], hurdles: [] };
|
|
796
|
+
|
|
797
|
+
return {
|
|
798
|
+
findings: [
|
|
799
|
+
finding({
|
|
800
|
+
criterion: 'K5',
|
|
801
|
+
severity: 'fail',
|
|
802
|
+
target: 'header',
|
|
803
|
+
...at,
|
|
804
|
+
message:
|
|
805
|
+
`Die Kopfzeile "${header}" hat ${literal} Zeichen. Das Tool zeigt nur ` +
|
|
806
|
+
`${THRESHOLDS.K5.headerCharsFail} — den Rest sieht der Operator nie.`,
|
|
807
|
+
measured: literal,
|
|
808
|
+
threshold: THRESHOLDS.K5.headerCharsFail,
|
|
809
|
+
hurdle: HURDLES.H1.id,
|
|
810
|
+
excerpt: header,
|
|
811
|
+
}),
|
|
812
|
+
],
|
|
813
|
+
hurdles: [HURDLES.H1.id],
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// ---------------------------------------------------------------------------
|
|
818
|
+
// K6 — Längenbudget (Optionen, Nutzlast, Optionsanzahl = Hürde H2)
|
|
819
|
+
// ---------------------------------------------------------------------------
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Optionen zählen PRO FRAGE, nie pro Block.
|
|
823
|
+
*
|
|
824
|
+
* Blockweise gezählt meldet `skills/plan/SKILL.md:137` 14 Optionen und damit
|
|
825
|
+
* einen H2-Bruch — es ist aber ein völlig legaler Vierfragen-Block mit je 3–4
|
|
826
|
+
* Optionen. Ein Bewerter, der hier blockweise zählt, meldet den saubersten
|
|
827
|
+
* mehrteiligen Dialog im Repo als schwersten Verstoß.
|
|
828
|
+
*
|
|
829
|
+
* @param {import('./schema.mjs').AuqQuestion} q
|
|
830
|
+
* @param {{file: string, line: number}} at
|
|
831
|
+
* @returns {{findings: import('./schema.mjs').AuqFinding[], hurdles: string[]}}
|
|
832
|
+
*/
|
|
833
|
+
function checkK6(q, at) {
|
|
834
|
+
const options = Array.isArray(q.options) ? q.options : [];
|
|
835
|
+
const findings = [];
|
|
836
|
+
const hurdles = new Set();
|
|
837
|
+
|
|
838
|
+
for (const option of options) {
|
|
839
|
+
const label = str(option.label);
|
|
840
|
+
const description = str(option.description);
|
|
841
|
+
const base = { target: /** @type {'option'} */ ('option'), optionIndex: option.index, ...at };
|
|
842
|
+
|
|
843
|
+
const descriptionChars = codepointLength(description);
|
|
844
|
+
if (descriptionChars > THRESHOLDS.K6.descriptionCharsFail) {
|
|
845
|
+
findings.push(
|
|
846
|
+
lengthFinding(
|
|
847
|
+
{
|
|
848
|
+
...base,
|
|
849
|
+
criterion: 'K6',
|
|
850
|
+
severity: 'fail',
|
|
851
|
+
message:
|
|
852
|
+
`Die Beschreibung hat ${descriptionChars} Zeichen. Ab ` +
|
|
853
|
+
`${THRESHOLDS.K6.descriptionCharsFail} liest der Operator sie nicht mehr zu Ende — ` +
|
|
854
|
+
'und entscheidet dann nach dem Label allein.' +
|
|
855
|
+
lowerBoundNote(q, description),
|
|
856
|
+
measured: descriptionChars,
|
|
857
|
+
threshold: THRESHOLDS.K6.descriptionCharsFail,
|
|
858
|
+
excerpt: description,
|
|
859
|
+
},
|
|
860
|
+
description,
|
|
861
|
+
),
|
|
862
|
+
);
|
|
863
|
+
} else if (descriptionChars > THRESHOLDS.K6.descriptionCharsWarn) {
|
|
864
|
+
findings.push(
|
|
865
|
+
lengthFinding(
|
|
866
|
+
{
|
|
867
|
+
...base,
|
|
868
|
+
criterion: 'K6',
|
|
869
|
+
severity: 'warn',
|
|
870
|
+
message:
|
|
871
|
+
`Die Beschreibung hat ${descriptionChars} Zeichen und wird ab ` +
|
|
872
|
+
`${THRESHOLDS.K6.descriptionCharsWarn} unhandlich.` +
|
|
873
|
+
lowerBoundNote(q, description),
|
|
874
|
+
measured: descriptionChars,
|
|
875
|
+
threshold: THRESHOLDS.K6.descriptionCharsWarn,
|
|
876
|
+
excerpt: description,
|
|
877
|
+
},
|
|
878
|
+
description,
|
|
879
|
+
),
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const labelChars = codepointLength(label);
|
|
884
|
+
if (labelChars > THRESHOLDS.K6.labelCharsWarn) {
|
|
885
|
+
findings.push(
|
|
886
|
+
lengthFinding(
|
|
887
|
+
{
|
|
888
|
+
...base,
|
|
889
|
+
criterion: 'K6',
|
|
890
|
+
severity: 'warn',
|
|
891
|
+
message:
|
|
892
|
+
`Das Label hat ${labelChars} Zeichen. Über ${THRESHOLDS.K6.labelCharsWarn} wird der ` +
|
|
893
|
+
'Knopftext zum Fließtext.' +
|
|
894
|
+
lowerBoundNote(q, label),
|
|
895
|
+
measured: labelChars,
|
|
896
|
+
threshold: THRESHOLDS.K6.labelCharsWarn,
|
|
897
|
+
excerpt: label,
|
|
898
|
+
},
|
|
899
|
+
label,
|
|
900
|
+
),
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Nutzlast der ganzen Frage: Fragetext plus alle Labels plus alle Beschreibungen.
|
|
906
|
+
const payloadParts = [str(q.question)];
|
|
907
|
+
for (const option of options) payloadParts.push(str(option.label), str(option.description));
|
|
908
|
+
const payloadText = payloadParts.join(' ');
|
|
909
|
+
const payloadChars = payloadParts.reduce((sum, part) => sum + codepointLength(part), 0);
|
|
910
|
+
|
|
911
|
+
if (payloadChars > THRESHOLDS.K6.questionPayloadFail) {
|
|
912
|
+
findings.push(
|
|
913
|
+
lengthFinding(
|
|
914
|
+
{
|
|
915
|
+
criterion: 'K6',
|
|
916
|
+
severity: 'fail',
|
|
917
|
+
target: 'question',
|
|
918
|
+
...at,
|
|
919
|
+
message:
|
|
920
|
+
`Die Frage bringt insgesamt ${payloadChars} Zeichen auf den Schirm (Fragetext und alle ` +
|
|
921
|
+
`Optionen). Ab ${THRESHOLDS.K6.questionPayloadFail} ist das keine Entscheidung mehr, ` +
|
|
922
|
+
'sondern eine Leseaufgabe.' +
|
|
923
|
+
lowerBoundNote(q, payloadText),
|
|
924
|
+
measured: payloadChars,
|
|
925
|
+
threshold: THRESHOLDS.K6.questionPayloadFail,
|
|
926
|
+
excerpt: str(q.question),
|
|
927
|
+
},
|
|
928
|
+
payloadText,
|
|
929
|
+
),
|
|
930
|
+
);
|
|
931
|
+
} else if (payloadChars > THRESHOLDS.K6.questionPayloadWarn) {
|
|
932
|
+
findings.push(
|
|
933
|
+
lengthFinding(
|
|
934
|
+
{
|
|
935
|
+
criterion: 'K6',
|
|
936
|
+
severity: 'warn',
|
|
937
|
+
target: 'question',
|
|
938
|
+
...at,
|
|
939
|
+
message:
|
|
940
|
+
`Die Frage bringt insgesamt ${payloadChars} Zeichen auf den Schirm. Ab ` +
|
|
941
|
+
`${THRESHOLDS.K6.questionPayloadWarn} wird die Auswahl mühsam.` +
|
|
942
|
+
lowerBoundNote(q, payloadText),
|
|
943
|
+
measured: payloadChars,
|
|
944
|
+
threshold: THRESHOLDS.K6.questionPayloadWarn,
|
|
945
|
+
excerpt: str(q.question),
|
|
946
|
+
},
|
|
947
|
+
payloadText,
|
|
948
|
+
),
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// --- Hürde H2, Teil 1: Optionsanzahl JE FRAGE ---------------------------
|
|
953
|
+
// NICHT PRÜFBAR ist nicht dasselbe wie VERSTOSSEN. parse.mjs hängt
|
|
954
|
+
// `optionCountUnknown: true` an eine Frage, deren Vorlage ein Ellipsen-
|
|
955
|
+
// Fragment enthält ("…up to 4 options per batch…") — die wahre Optionszahl
|
|
956
|
+
// steht dort schlicht nicht. Drei Fragen im Korpus sind betroffen
|
|
957
|
+
// (skills/reconcile/SKILL.md:246, skills/evolve/SKILL.md:251,
|
|
958
|
+
// agents/memory-proposal-collector.md:139). Ein FAIL wäre dort drei erfundene
|
|
959
|
+
// Verstöße — und Falsch-Positive sind für diesen Prüfer teurer als
|
|
960
|
+
// Falsch-Negative. Also ein Hinweis, der die Lücke benennt, und keine Hürde.
|
|
961
|
+
//
|
|
962
|
+
// Das Feld ist ADDITIV: es steht nicht im eingefrorenen `AuqQuestion`-Vertrag,
|
|
963
|
+
// `makeQuestion` kennt es nicht. Deshalb wird strikt auf `=== true` geprüft
|
|
964
|
+
// statt auf Wahrheitswert — fehlt das Feld, ist `undefined` kein "unbekannt".
|
|
965
|
+
const countUnknown = q.optionCountUnknown === true;
|
|
966
|
+
|
|
967
|
+
// `subject` ist hier bewusst der leere String: eine ZAHL hat keinen Text, der
|
|
968
|
+
// vor dem Kürzen geschützt werden müsste. So kann ein Sicherheitswort in
|
|
969
|
+
// irgendeiner Beschreibung die Hürde strukturell nicht aushebeln.
|
|
970
|
+
const count = options.length;
|
|
971
|
+
if (countUnknown) {
|
|
972
|
+
findings.push(
|
|
973
|
+
lengthFinding(
|
|
974
|
+
{
|
|
975
|
+
criterion: 'K6',
|
|
976
|
+
severity: 'warn',
|
|
977
|
+
target: 'question',
|
|
978
|
+
...at,
|
|
979
|
+
message:
|
|
980
|
+
`In der Vorlage stehen ${count} Optionen, aber sie bricht mit einer Auslassung ab — ` +
|
|
981
|
+
'wie viele es zur Laufzeit wirklich werden, steht nirgends. Die Grenze von ' +
|
|
982
|
+
`${THRESHOLDS.K6.optionsMin} bis ${THRESHOLDS.K6.optionsMax} Optionen ist hier nicht ` +
|
|
983
|
+
'prüfbar, nicht verletzt.',
|
|
984
|
+
measured: 'unbekannt',
|
|
985
|
+
threshold: `${THRESHOLDS.K6.optionsMin}-${THRESHOLDS.K6.optionsMax}`,
|
|
986
|
+
excerpt: str(q.question),
|
|
987
|
+
},
|
|
988
|
+
'',
|
|
989
|
+
),
|
|
990
|
+
);
|
|
991
|
+
} else if (count > THRESHOLDS.K6.optionsMax) {
|
|
992
|
+
findings.push(
|
|
993
|
+
lengthFinding(
|
|
994
|
+
{
|
|
995
|
+
criterion: 'K6',
|
|
996
|
+
severity: 'fail',
|
|
997
|
+
target: 'question',
|
|
998
|
+
...at,
|
|
999
|
+
message:
|
|
1000
|
+
`Diese Frage bietet ${count} Optionen an. Mehr als ${THRESHOLDS.K6.optionsMax} kann ` +
|
|
1001
|
+
'niemand gegeneinander abwägen — die hinteren werden überlesen.',
|
|
1002
|
+
measured: count,
|
|
1003
|
+
threshold: THRESHOLDS.K6.optionsMax,
|
|
1004
|
+
hurdle: HURDLES.H2.id,
|
|
1005
|
+
excerpt: str(q.question),
|
|
1006
|
+
},
|
|
1007
|
+
'',
|
|
1008
|
+
),
|
|
1009
|
+
);
|
|
1010
|
+
hurdles.add(HURDLES.H2.id);
|
|
1011
|
+
} else if (count < THRESHOLDS.K6.optionsMin) {
|
|
1012
|
+
findings.push(
|
|
1013
|
+
lengthFinding(
|
|
1014
|
+
{
|
|
1015
|
+
criterion: 'K6',
|
|
1016
|
+
severity: 'fail',
|
|
1017
|
+
target: 'question',
|
|
1018
|
+
...at,
|
|
1019
|
+
message:
|
|
1020
|
+
`Diese Frage bietet ${count} Option(en) an. Unter ${THRESHOLDS.K6.optionsMin} gibt es ` +
|
|
1021
|
+
'nichts zu entscheiden — dann ist es eine Mitteilung, keine Frage.',
|
|
1022
|
+
measured: count,
|
|
1023
|
+
threshold: THRESHOLDS.K6.optionsMin,
|
|
1024
|
+
hurdle: HURDLES.H2.id,
|
|
1025
|
+
excerpt: str(q.question),
|
|
1026
|
+
},
|
|
1027
|
+
'',
|
|
1028
|
+
),
|
|
1029
|
+
);
|
|
1030
|
+
hurdles.add(HURDLES.H2.id);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// --- Hürde H2, Teil 2: die Empfehlung gehört auf Platz 1 ----------------
|
|
1034
|
+
// Bewusst NICHT geprüft: "gar keine Empfehlung". Ob eine Option als empfohlen
|
|
1035
|
+
// erkannt wurde, hängt daran, dass der Parser den Marker im Label findet — ein
|
|
1036
|
+
// fehlendes `isRecommended` kann also genauso gut eine Parser-Lücke oder eine
|
|
1037
|
+
// bewusst empfehlungsfreie Frage sein. Daraus einen harten F-Bruch zu machen,
|
|
1038
|
+
// hieße die einzige Hürde mit gemessener 0-%-FP-Rate zu verwässern.
|
|
1039
|
+
const recommended = options.filter((o) => o && o.isRecommended === true);
|
|
1040
|
+
if (recommended.length > 1) {
|
|
1041
|
+
findings.push(
|
|
1042
|
+
lengthFinding(
|
|
1043
|
+
{
|
|
1044
|
+
criterion: 'K6',
|
|
1045
|
+
severity: 'fail',
|
|
1046
|
+
target: 'question',
|
|
1047
|
+
...at,
|
|
1048
|
+
message:
|
|
1049
|
+
`${recommended.length} Optionen sind als Empfehlung markiert. Genau eine gehört an die ` +
|
|
1050
|
+
'erste Stelle — zwei Empfehlungen sind keine Empfehlung.',
|
|
1051
|
+
measured: recommended.length,
|
|
1052
|
+
threshold: 1,
|
|
1053
|
+
hurdle: HURDLES.H2.id,
|
|
1054
|
+
excerpt: str(q.question),
|
|
1055
|
+
},
|
|
1056
|
+
'',
|
|
1057
|
+
),
|
|
1058
|
+
);
|
|
1059
|
+
hurdles.add(HURDLES.H2.id);
|
|
1060
|
+
} else if (!countUnknown && recommended.length === 1 && options.indexOf(recommended[0]) > 0) {
|
|
1061
|
+
const position = options.indexOf(recommended[0]) + 1;
|
|
1062
|
+
findings.push(
|
|
1063
|
+
lengthFinding(
|
|
1064
|
+
{
|
|
1065
|
+
criterion: 'K6',
|
|
1066
|
+
severity: 'fail',
|
|
1067
|
+
target: 'question',
|
|
1068
|
+
...at,
|
|
1069
|
+
message:
|
|
1070
|
+
`Die Empfehlung steht an Position ${position} statt an erster Stelle. Der Operator ` +
|
|
1071
|
+
'liest von oben und nimmt die erste Option als die gemeinte.',
|
|
1072
|
+
measured: position,
|
|
1073
|
+
threshold: 1,
|
|
1074
|
+
hurdle: HURDLES.H2.id,
|
|
1075
|
+
excerpt: str(recommended[0].label),
|
|
1076
|
+
},
|
|
1077
|
+
'',
|
|
1078
|
+
),
|
|
1079
|
+
);
|
|
1080
|
+
hurdles.add(HURDLES.H2.id);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
return { findings, hurdles: [...hurdles] };
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// ---------------------------------------------------------------------------
|
|
1087
|
+
// K7 — Unerklärter Bezeichner (Option)
|
|
1088
|
+
// ---------------------------------------------------------------------------
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* ABSOLUTE ZAHL, keine Dichte.
|
|
1092
|
+
*
|
|
1093
|
+
* Die gemessene Bezeichner-Dichte hat Median 0 und Maximum 0,44 und trennt
|
|
1094
|
+
* damit nichts. Schlimmer: eine Dichteschwelle versteckt `.orchestrator/session.lock`
|
|
1095
|
+
* in einem langen Satz — je mehr Prosa drumherum, desto unsichtbarer der
|
|
1096
|
+
* Bezeichner, obwohl der Operator ihn genauso nachschlagen muss.
|
|
1097
|
+
*
|
|
1098
|
+
* Sicherheitstexte sind hiervon NICHT befreit: K7 fügt Information hinzu und
|
|
1099
|
+
* nimmt keine weg. Deshalb läuft dieser Befund über `finding()`, nicht über
|
|
1100
|
+
* `lengthFinding()` — und `finding()` würde ein Längenkriterium ablehnen.
|
|
1101
|
+
*
|
|
1102
|
+
* @param {import('./schema.mjs').AuqOption} option
|
|
1103
|
+
* @param {{file: string, line: number}} at
|
|
1104
|
+
* @returns {import('./schema.mjs').AuqFinding[]}
|
|
1105
|
+
*/
|
|
1106
|
+
function checkK7(option, at) {
|
|
1107
|
+
const description = str(option.description);
|
|
1108
|
+
if (isPlaceholderOnly(description)) return [];
|
|
1109
|
+
|
|
1110
|
+
const unexplained = unexplainedIdentifiers(description);
|
|
1111
|
+
if (unexplained.length < THRESHOLDS.K7.unexplainedFailAtOrAbove) return [];
|
|
1112
|
+
|
|
1113
|
+
const named = unexplained.map((t) => `"${t}"`).join(', ');
|
|
1114
|
+
return [
|
|
1115
|
+
finding({
|
|
1116
|
+
criterion: 'K7',
|
|
1117
|
+
severity: 'fail',
|
|
1118
|
+
target: 'option',
|
|
1119
|
+
optionIndex: option.index,
|
|
1120
|
+
...at,
|
|
1121
|
+
message:
|
|
1122
|
+
`Diese Option nennt ${named}, ohne daneben zu sagen, was das ist. Der Operator müsste ` +
|
|
1123
|
+
'erst nachschlagen, um überhaupt zu wissen, worüber er entscheidet.',
|
|
1124
|
+
measured: unexplained.length,
|
|
1125
|
+
threshold: THRESHOLDS.K7.unexplainedFailAtOrAbove,
|
|
1126
|
+
excerpt: description,
|
|
1127
|
+
}),
|
|
1128
|
+
];
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// ---------------------------------------------------------------------------
|
|
1132
|
+
// K8 — "Geh selbst nachsehen" (Option)
|
|
1133
|
+
// ---------------------------------------------------------------------------
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Die ICH-FORM-AUSSCHLUSSREGEL ist der eigentliche Inhalt dieses Kriteriums.
|
|
1137
|
+
*
|
|
1138
|
+
* "I will inspect the worktree before re-running /close." enthält einen
|
|
1139
|
+
* Lese-Imperativ und einen Pfad in Reichweite — aber dort sieht der AGENT nach,
|
|
1140
|
+
* nicht der Operator. Ohne den Ausschluss meldet K8 genau die Option als
|
|
1141
|
+
* Verstoß, die dem Operator die Arbeit ABNIMMT.
|
|
1142
|
+
*
|
|
1143
|
+
* @param {string} text
|
|
1144
|
+
* @returns {Array<{imperative: string, target: string, sentence: string}>}
|
|
1145
|
+
*/
|
|
1146
|
+
function selfServeHits(text) {
|
|
1147
|
+
const hits = [];
|
|
1148
|
+
for (const sentence of sentencesOf(stripPlaceholders(text))) {
|
|
1149
|
+
const targets = identifierSpans(sentence).filter((s) => s.id === 'path' || s.id === 'issue-ref');
|
|
1150
|
+
if (targets.length === 0) continue;
|
|
1151
|
+
|
|
1152
|
+
const re = globalOf(READ_IMPERATIVE_PATTERN);
|
|
1153
|
+
let match = re.exec(sentence);
|
|
1154
|
+
while (match !== null) {
|
|
1155
|
+
if (match[0] === '') {
|
|
1156
|
+
re.lastIndex += 1;
|
|
1157
|
+
match = re.exec(sentence);
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1160
|
+
const before = sentence.slice(0, match.index);
|
|
1161
|
+
const after = match.index + match[0].length;
|
|
1162
|
+
if (!FIRST_PERSON_EXCLUSION_PATTERN.test(before)) {
|
|
1163
|
+
const target = targets.find((t) => t.start >= after);
|
|
1164
|
+
if (target !== undefined) {
|
|
1165
|
+
const gap = sentence.slice(after, target.start).replace(globalOf(PERIOD), '');
|
|
1166
|
+
if (codepointLength(gap) <= THRESHOLDS.K8.proximityChars) {
|
|
1167
|
+
hits.push({ imperative: match[0], target: target.token, sentence });
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
match = re.exec(sentence);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return hits;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* @param {import('./schema.mjs').AuqOption} option
|
|
1179
|
+
* @param {{file: string, line: number}} at
|
|
1180
|
+
* @returns {import('./schema.mjs').AuqFinding[]}
|
|
1181
|
+
*/
|
|
1182
|
+
function checkK8(option, at) {
|
|
1183
|
+
const description = str(option.description);
|
|
1184
|
+
if (isPlaceholderOnly(description)) return [];
|
|
1185
|
+
|
|
1186
|
+
const hits = selfServeHits(description);
|
|
1187
|
+
if (hits.length < THRESHOLDS.K8.failAtOrAbove) return [];
|
|
1188
|
+
|
|
1189
|
+
const first = hits[0];
|
|
1190
|
+
return [
|
|
1191
|
+
finding({
|
|
1192
|
+
criterion: 'K8',
|
|
1193
|
+
severity: 'fail',
|
|
1194
|
+
target: 'option',
|
|
1195
|
+
optionIndex: option.index,
|
|
1196
|
+
...at,
|
|
1197
|
+
message:
|
|
1198
|
+
`Diese Option verlangt, dass der Operator erst "${first.target}" öffnet, um entscheiden zu ` +
|
|
1199
|
+
'können. Was dort steht, gehört in die Option — sonst ist die Frage nicht beantwortbar, ' +
|
|
1200
|
+
'ohne die Arbeit zu unterbrechen.',
|
|
1201
|
+
measured: hits.length,
|
|
1202
|
+
threshold: THRESHOLDS.K8.failAtOrAbove,
|
|
1203
|
+
excerpt: first.sentence,
|
|
1204
|
+
}),
|
|
1205
|
+
];
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// ---------------------------------------------------------------------------
|
|
1209
|
+
// Punkte
|
|
1210
|
+
// ---------------------------------------------------------------------------
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* `points = 100 − Σ CRITERIA[k].weight` über jedes Kriterium mit mindestens
|
|
1214
|
+
* einem NICHT befreiten `fail`-Befund. Jedes Kriterium zieht höchstens EINMAL
|
|
1215
|
+
* ab — sonst bestraft dieselbe Schwäche eine Vier-Options-Frage doppelt so hart
|
|
1216
|
+
* wie eine Zwei-Options-Frage.
|
|
1217
|
+
*
|
|
1218
|
+
* Die zwei Filter (`severity`, `exempt`) sind bewusst UNABHÄNGIG: ein befreiter
|
|
1219
|
+
* Befund wird bereits in `lengthFinding()` auf `warn` herabgestuft, aber wenn
|
|
1220
|
+
* dort je etwas durchrutscht, hält der `exempt`-Filter hier trotzdem.
|
|
1221
|
+
*
|
|
1222
|
+
* @param {import('./schema.mjs').AuqFinding[]} findings
|
|
1223
|
+
* @returns {number}
|
|
1224
|
+
*/
|
|
1225
|
+
function pointsFor(findings) {
|
|
1226
|
+
const failed = new Set();
|
|
1227
|
+
for (const f of findings) {
|
|
1228
|
+
if (f.severity !== 'fail') continue;
|
|
1229
|
+
if (f.exempt !== null) continue;
|
|
1230
|
+
failed.add(f.criterion);
|
|
1231
|
+
}
|
|
1232
|
+
let deduction = 0;
|
|
1233
|
+
for (const criterion of failed) deduction += CRITERIA[criterion].weight;
|
|
1234
|
+
return Math.max(0, Math.min(MAX_POINTS, MAX_POINTS - deduction));
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// ---------------------------------------------------------------------------
|
|
1238
|
+
// Öffentliche API
|
|
1239
|
+
// ---------------------------------------------------------------------------
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Bewertet EINE Frage gegen alle acht Kriterien und beide Hürden.
|
|
1243
|
+
*
|
|
1244
|
+
* @param {import('./schema.mjs').AuqQuestion} question
|
|
1245
|
+
* @param {number} [questionIndex] 0-basierte Position der Frage in ihrem Block.
|
|
1246
|
+
* @returns {import('./schema.mjs').AuqScore}
|
|
1247
|
+
*/
|
|
1248
|
+
export function scoreQuestion(question, questionIndex = 0) {
|
|
1249
|
+
if (question === null || typeof question !== 'object') {
|
|
1250
|
+
throw new TypeError('auq/clarity: scoreQuestion braucht eine AuqQuestion');
|
|
1251
|
+
}
|
|
1252
|
+
const at = { file: question.file, line: question.line };
|
|
1253
|
+
const options = Array.isArray(question.options) ? question.options : [];
|
|
1254
|
+
|
|
1255
|
+
/** @type {Record<string, import('./schema.mjs').AuqFinding[]>} */
|
|
1256
|
+
const byCriterion = {};
|
|
1257
|
+
for (const id of CRITERION_IDS) byCriterion[id] = [];
|
|
1258
|
+
const hurdles = new Set();
|
|
1259
|
+
|
|
1260
|
+
byCriterion.K1.push(...checkK1(question, at));
|
|
1261
|
+
byCriterion.K2.push(...checkK2(question, at));
|
|
1262
|
+
|
|
1263
|
+
const k5 = checkK5(question, at);
|
|
1264
|
+
byCriterion.K5.push(...k5.findings);
|
|
1265
|
+
for (const h of k5.hurdles) hurdles.add(h);
|
|
1266
|
+
|
|
1267
|
+
const k6 = checkK6(question, at);
|
|
1268
|
+
byCriterion.K6.push(...k6.findings);
|
|
1269
|
+
for (const h of k6.hurdles) hurdles.add(h);
|
|
1270
|
+
|
|
1271
|
+
for (const option of options) {
|
|
1272
|
+
if (option === null || typeof option !== 'object') continue;
|
|
1273
|
+
byCriterion.K3.push(...checkK3(option, at));
|
|
1274
|
+
byCriterion.K4.push(...checkK4(option, at));
|
|
1275
|
+
byCriterion.K7.push(...checkK7(option, at));
|
|
1276
|
+
byCriterion.K8.push(...checkK8(option, at));
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// Stabile Reihenfolge nach Kriterium — ein Bericht, dessen Befunde bei jedem
|
|
1280
|
+
// Lauf anders sortiert sind, lässt sich nicht diffen.
|
|
1281
|
+
const findings = CRITERION_IDS.flatMap((id) => byCriterion[id]);
|
|
1282
|
+
|
|
1283
|
+
return makeScore({
|
|
1284
|
+
file: question.file,
|
|
1285
|
+
line: question.line,
|
|
1286
|
+
questionIndex,
|
|
1287
|
+
points: pointsFor(findings),
|
|
1288
|
+
hurdlesBroken: [...hurdles],
|
|
1289
|
+
findings,
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* Bewertet alle Fragen aller Blöcke, in Fundreihenfolge.
|
|
1295
|
+
*
|
|
1296
|
+
* `questionIndex` ist die Position INNERHALB des Blocks — nicht fortlaufend über
|
|
1297
|
+
* alle Blöcke, weil ein `AuqScore` sonst nicht mehr auf seine Frage zeigt.
|
|
1298
|
+
*
|
|
1299
|
+
* @param {import('./schema.mjs').AuqBlock[]} blocks
|
|
1300
|
+
* @returns {import('./schema.mjs').AuqScore[]}
|
|
1301
|
+
*/
|
|
1302
|
+
export function scoreBlocks(blocks) {
|
|
1303
|
+
if (!Array.isArray(blocks)) return [];
|
|
1304
|
+
const scores = [];
|
|
1305
|
+
for (const block of blocks) {
|
|
1306
|
+
if (block === null || typeof block !== 'object') continue;
|
|
1307
|
+
const questions = Array.isArray(block.questions) ? block.questions : [];
|
|
1308
|
+
questions.forEach((question, index) => {
|
|
1309
|
+
if (question === null || typeof question !== 'object') return;
|
|
1310
|
+
scores.push(scoreQuestion(question, index));
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
return scores;
|
|
1314
|
+
}
|