session-orchestrator 3.20.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/030-wave-execution.mdc +10 -8
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +515 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +7 -5
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/eli5.md +33 -0
- package/commands/release.md +62 -0
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +76 -97
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/pi/prompts/release.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +10 -9
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- 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/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +267 -77
- 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/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +432 -7
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/learnings/select.mjs +157 -3
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- 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/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- 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/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +164 -58
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +219 -11
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +680 -30
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +983 -107
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +64 -0
- package/scripts/validate-wave-scope.mjs +286 -12
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +49 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/SKILL.md +28 -24
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +73 -59
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +20 -7
- package/skills/npm-publish/SKILL.md +23 -51
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +42 -12
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +97 -22
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +82 -36
- 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/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/wave-loop.md +64 -12
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/lib/mission-status-schema.mjs +0 -114
- 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,825 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* auq-audit.mjs — die CLI der AUQ-Klarheitsmessung (#1107).
|
|
4
|
+
*
|
|
5
|
+
* ## Warum es dieses Programm gibt
|
|
6
|
+
*
|
|
7
|
+
* Der Operator hat gesagt, die Fragen, die dieses System ihm stellt, seien zu
|
|
8
|
+
* technisch, zu lang und nicht entscheidbar. Ohne Zahl ist jede Verbesserung
|
|
9
|
+
* daran eine Behauptung. Dieses Programm liefert die Zahl.
|
|
10
|
+
*
|
|
11
|
+
* Es MISST NICHTS SELBST. Es verkabelt drei fertige Module und fügt deren
|
|
12
|
+
* Ergebnis zu einem Bericht zusammen:
|
|
13
|
+
*
|
|
14
|
+
* scripts/lib/auq/parse.mjs findet die Vorlagen (parseRepo)
|
|
15
|
+
* scripts/lib/auq/clarity.mjs bewertet sie (scoreBlocks)
|
|
16
|
+
* scripts/lib/auq/schema.mjs der eingefrorene Vertrag + validateReport
|
|
17
|
+
*
|
|
18
|
+
* Eine Schwelle, ein Kriterium oder eine Note in DIESER Datei wäre ein zweiter
|
|
19
|
+
* Ort für dieselbe Regel. Was hier steht, ist ausschließlich: Argumente,
|
|
20
|
+
* Auswahl, Darstellung, Ausgang.
|
|
21
|
+
*
|
|
22
|
+
* ## Namensabweichung, bewusst dokumentiert
|
|
23
|
+
*
|
|
24
|
+
* Der Modulkopf von `schema.mjs` nennt die CLI `scripts/auq-clarity.mjs`. Der
|
|
25
|
+
* Dateiname ist `auq-audit.mjs` — so lautet der vergebene Datei-Scope. Der
|
|
26
|
+
* VERTRAG (`buildReport({ blocks, scores, corpus, head, dirty }) -> AuqReport`)
|
|
27
|
+
* ist unverändert eingehalten; nur der Dateiname weicht ab.
|
|
28
|
+
*
|
|
29
|
+
* ## Zwei Fallen dieses Repos, gegen die hier gebaut ist
|
|
30
|
+
*
|
|
31
|
+
* 1. **`console.log` + `process.exit()` verwirft stdout oberhalb von ~64 KiB.**
|
|
32
|
+
* stdout ist auf einer Pipe unter macOS asynchron; was nicht mehr in den
|
|
33
|
+
* Kernel-Puffer passt, liegt in der libuv-Warteschlange und wird von
|
|
34
|
+
* `process.exit()` weggeworfen. Der `--json`-Umschlag dieses Programms ist
|
|
35
|
+
* über 1 MB groß und reißt diese Grenze bei JEDEM Lauf. Deshalb geht JEDE
|
|
36
|
+
* stdout-Ausgabe durch `writeStdoutLineSync` (`scripts/lib/io.mjs`), das
|
|
37
|
+
* synchron schreibt und EAGAIN wiederholt.
|
|
38
|
+
* 2. **`no-console` steht in `eslint.config.js` auf `off`.** Der Linter fängt
|
|
39
|
+
* eine nach stdout gerutschte Diagnose NICHT. Die Trennung ist deshalb
|
|
40
|
+
* strukturell: `out()` schreibt nach stdout, `note()` nach stderr, und es
|
|
41
|
+
* gibt keinen dritten Weg.
|
|
42
|
+
*
|
|
43
|
+
* ## Warum `--strict` standardmäßig AUS ist
|
|
44
|
+
*
|
|
45
|
+
* Nur H1 und H2 haben eine gemessene Falsch-Positiv-Rate von 0 %; alle anderen
|
|
46
|
+
* Kriterien liegen zwischen 14 % und 25 %. Ein Prüfer, der darauf hart sperrt,
|
|
47
|
+
* meckert bei jeder vierten korrekten Frage — und wird abgeschaltet. Dann ist
|
|
48
|
+
* auch jede richtig gefundene Schwäche weg. Ohne `--strict` ist ein Lauf mit
|
|
49
|
+
* hunderten Befunden deshalb immer noch exit 0.
|
|
50
|
+
*
|
|
51
|
+
* @see .claude/rules/ask-via-tool.md — AUQ-001..005, die gemessene Regel
|
|
52
|
+
* @see .claude/rules/cli-design.md — JSON-first, stdout/stderr, Exit-Codes
|
|
53
|
+
* @see scripts/lib/tests-src-ratio.mjs — Vorbild für Umschlag, `head` und `dirty`
|
|
54
|
+
* @see Issue #1107
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import { execFileSync } from 'node:child_process';
|
|
58
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
59
|
+
import path from 'node:path';
|
|
60
|
+
import { fileURLToPath } from 'node:url';
|
|
61
|
+
import { parseArgs as nodeParseArgs } from 'node:util';
|
|
62
|
+
|
|
63
|
+
import { scoreBlocks } from './lib/auq/clarity.mjs';
|
|
64
|
+
import {
|
|
65
|
+
CRITERIA,
|
|
66
|
+
CRITERION_IDS,
|
|
67
|
+
GRADES,
|
|
68
|
+
HURDLES,
|
|
69
|
+
HURDLE_IDS,
|
|
70
|
+
POPULATIONS,
|
|
71
|
+
SCHEMA_VERSION,
|
|
72
|
+
emptyCorpus,
|
|
73
|
+
emptySummary,
|
|
74
|
+
truncateExcerpt,
|
|
75
|
+
validateReport,
|
|
76
|
+
} from './lib/auq/schema.mjs';
|
|
77
|
+
import { corpusKindOf, parseRepo } from './lib/auq/parse.mjs';
|
|
78
|
+
import { writeStdoutLineSync } from './lib/io.mjs';
|
|
79
|
+
|
|
80
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
81
|
+
const REPO_ROOT_DEFAULT = path.resolve(HERE, '..');
|
|
82
|
+
|
|
83
|
+
/** Wie viele der schlechtesten Fragen der Bericht ohne `--top` zeigt. */
|
|
84
|
+
const DEFAULT_TOP = 10;
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Exit-Codes
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `.claude/rules/cli-design.md` vergibt 0/1/2. Der vierte Code ist die
|
|
92
|
+
* Gate-Entscheidung und steht bewusst NEBEN dem Eingabefehler: eine gerissene
|
|
93
|
+
* Hürde ist kein Bedienfehler, und wer dieses Programm in CI hängt, muss die
|
|
94
|
+
* beiden auseinanderhalten können. In `--help` dokumentiert (cli-design.md:
|
|
95
|
+
* "Document non-standard exit codes in --help").
|
|
96
|
+
*/
|
|
97
|
+
export const EXIT = Object.freeze({
|
|
98
|
+
ok: 0,
|
|
99
|
+
/** Eingabefehler: unbekannte Flagge, unlesbarer Pfad, kaputter Wert. */
|
|
100
|
+
usage: 1,
|
|
101
|
+
/** Systemfehler: der eigene Bericht verletzt sein eigenes Schema. */
|
|
102
|
+
system: 2,
|
|
103
|
+
/** Nur mit `--strict`: mindestens eine harte Grenze ist gerissen. */
|
|
104
|
+
gate: 3,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Die gesamte Exit-Politik als reine Funktion.
|
|
109
|
+
*
|
|
110
|
+
* Sie steht hier getrennt, weil genau hier der teure Fehler sitzt: ein
|
|
111
|
+
* `validateReport`, dessen Verdikt gelesen und dann ignoriert wird, und ein
|
|
112
|
+
* `--strict`, das verkabelt aussieht und nichts tut. Beides ist an einer
|
|
113
|
+
* reinen Funktion prüfbar, an einem `process.exit()` mitten in `main()` nicht.
|
|
114
|
+
*
|
|
115
|
+
* @param {{reportValid: boolean, strict: boolean, hurdlesBroken: number}} rec
|
|
116
|
+
* @returns {number}
|
|
117
|
+
*/
|
|
118
|
+
export function decideExit({ reportValid, strict, hurdlesBroken }) {
|
|
119
|
+
if (!reportValid) return EXIT.system;
|
|
120
|
+
if (strict && hurdlesBroken > 0) return EXIT.gate;
|
|
121
|
+
return EXIT.ok;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Ausgabekanäle — die Trennung, die der Linter hier nicht erzwingt
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Nach stdout. IMMER synchron: der `--json`-Umschlag liegt weit über dem
|
|
130
|
+
* 64-KiB-Pipe-Puffer, und `console.log` + `process.exit()` würde den Rest
|
|
131
|
+
* verwerfen (siehe Modulkopf, Falle 1).
|
|
132
|
+
*
|
|
133
|
+
* @param {string} line
|
|
134
|
+
*/
|
|
135
|
+
function out(line) {
|
|
136
|
+
const res = writeStdoutLineSync(line);
|
|
137
|
+
if (!res.ok && res.error !== 'EPIPE') {
|
|
138
|
+
// stdout ist der einzige Kanal, den wir gerade verloren haben — also den
|
|
139
|
+
// anderen nehmen, statt still zu enden.
|
|
140
|
+
process.stderr.write(`auq-audit: stdout-Schreibfehler (${res.error})\n`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Nach stderr. Diagnose, Fortschritt, Fehler — nie nach stdout, sonst ist der
|
|
146
|
+
* `--json`-Umschlag unparsebar.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} line
|
|
149
|
+
*/
|
|
150
|
+
function note(line) {
|
|
151
|
+
process.stderr.write(`${line}\n`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Klartext für den Operator
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Operator-lesbare Überschrift je Kriterium.
|
|
160
|
+
*
|
|
161
|
+
* Warum nicht `CRITERIA[k].title`: das ist der INTERNE Kurzname ("Payload-Form",
|
|
162
|
+
* "Header-Grenze"). Ein Bericht über Verständlichkeit, der mit "K7: 21 Befunde"
|
|
163
|
+
* überschreibt, ist genau die Ironie, an der er scheitert. Die Zuordnung fällt
|
|
164
|
+
* auf `CRITERIA[k].title` zurück, falls die Registry ein Kriterium bekommt, das
|
|
165
|
+
* hier fehlt — dann steht ein Kurzname da, aber keine Lücke.
|
|
166
|
+
*/
|
|
167
|
+
const CRITERION_LABELS = Object.freeze({
|
|
168
|
+
K1: 'Frage ist ein Bericht statt einer Frage',
|
|
169
|
+
K2: 'Sehr langer Satz (nur ein Hinweis)',
|
|
170
|
+
K3: 'Beschreibung wiederholt nur das Label',
|
|
171
|
+
K4: 'Empfehlung ohne Grund, Preis oder Folge',
|
|
172
|
+
K5: 'Kopfzeile zu lang — wird abgeschnitten',
|
|
173
|
+
K6: 'Zu lang oder zu viele Optionen',
|
|
174
|
+
K7: 'Fachbegriffe ohne Erklärung',
|
|
175
|
+
K8: 'Verlangt, erst selbst nachzusehen',
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/** Klartext je Herkunftsklasse. */
|
|
179
|
+
const POPULATION_LABELS = Object.freeze({
|
|
180
|
+
A: 'Claude Code — echter Frage-Aufruf',
|
|
181
|
+
B: 'Codex / Cursor — nummerierte Liste',
|
|
182
|
+
'C-mdc': 'Cursor-Regeldateien (.mdc)',
|
|
183
|
+
'C-mjs': 'Skript-Code (.mjs)',
|
|
184
|
+
'C-prose': 'nur im Fließtext beschrieben',
|
|
185
|
+
'C-hybrid': 'Mischform aus beidem',
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
/** Klartext je Note. */
|
|
189
|
+
const GRADE_LABELS = Object.freeze({
|
|
190
|
+
A: 'sehr gut',
|
|
191
|
+
B: 'gut',
|
|
192
|
+
C: 'brauchbar',
|
|
193
|
+
D: 'schwach',
|
|
194
|
+
F: 'durchgefallen',
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/** @param {string} id */
|
|
198
|
+
function criterionLabel(id) {
|
|
199
|
+
return CRITERION_LABELS[id] ?? CRITERIA[id]?.title ?? id;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** @param {string} id */
|
|
203
|
+
function populationLabel(id) {
|
|
204
|
+
return POPULATION_LABELS[id] ?? id;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// git-Anker: WANN wurde gemessen, und ist die Zahl reproduzierbar
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Kurzer HEAD-SHA, oder `null` ausserhalb eines git-Repos.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} root
|
|
215
|
+
* @returns {string|null}
|
|
216
|
+
*/
|
|
217
|
+
function headRef(root) {
|
|
218
|
+
try {
|
|
219
|
+
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
220
|
+
cwd: root,
|
|
221
|
+
encoding: 'utf8',
|
|
222
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
223
|
+
}).trim();
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Weicht irgendeine GETRACKTE Datei vom Index ab?
|
|
231
|
+
*
|
|
232
|
+
* Tragend, nicht Deko: die Dateiliste kommt aus dem git-INDEX, der Inhalt aber
|
|
233
|
+
* aus dem ARBEITSBAUM. Auf einem schmutzigen Baum ist das Paar (`head`,
|
|
234
|
+
* Notenverteilung) eine Behauptung, die niemand an diesem SHA reproduzieren
|
|
235
|
+
* kann — der Bericht sagt das dann ausdrücklich.
|
|
236
|
+
*
|
|
237
|
+
* FAIL-CLOSED: kann git nicht antworten (kein Repo), gilt `true`. "Unbekannt"
|
|
238
|
+
* ist kein Anker, und `false` würde Reproduzierbarkeit behaupten, die niemand
|
|
239
|
+
* geprüft hat.
|
|
240
|
+
*
|
|
241
|
+
* `--no-optional-locks` verhindert, dass ein beiläufiges `git status` den
|
|
242
|
+
* `.git/index` neu schreibt und damit eine parallele Session anschiebt
|
|
243
|
+
* (PSA-007) — dieselbe Flagge, derselbe Grund wie in tests-src-ratio.mjs.
|
|
244
|
+
*
|
|
245
|
+
* @param {string} root
|
|
246
|
+
* @returns {boolean}
|
|
247
|
+
*/
|
|
248
|
+
function isDirty(root) {
|
|
249
|
+
try {
|
|
250
|
+
const outp = execFileSync(
|
|
251
|
+
'git',
|
|
252
|
+
['--no-optional-locks', 'status', '--porcelain', '--untracked-files=no'],
|
|
253
|
+
{ cwd: root, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
254
|
+
);
|
|
255
|
+
return outp.split('\n').some((l) => l.trim() !== '');
|
|
256
|
+
} catch {
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Der Umschlag
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Ein Befund zählt gegen die Punkte, wenn er `fail` ist UND nicht befreit.
|
|
267
|
+
* Dieselbe Bedingung wie in `clarity.pointsFor` — hier nur zum ZÄHLEN, nie zum
|
|
268
|
+
* Neuberechnen einer Note.
|
|
269
|
+
*
|
|
270
|
+
* @param {import('./lib/auq/schema.mjs').AuqFinding} f
|
|
271
|
+
*/
|
|
272
|
+
function isCountedFailure(f) {
|
|
273
|
+
return f.severity === 'fail' && f.exempt === null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Fügt den Bericht zusammen. MISST NICHTS — `summary` wird ausschließlich aus
|
|
278
|
+
* den übergebenen `scores` hochgezählt.
|
|
279
|
+
*
|
|
280
|
+
* `summary` ist bewusst ABGELEITET und kein Parameter: eine von Hand
|
|
281
|
+
* mitgeführte Zusammenfassung ist ein Defekt in Wartestellung.
|
|
282
|
+
*
|
|
283
|
+
* @param {{blocks: import('./lib/auq/schema.mjs').AuqBlock[],
|
|
284
|
+
* scores: import('./lib/auq/schema.mjs').AuqScore[],
|
|
285
|
+
* corpus: Record<string, number>,
|
|
286
|
+
* head: string|null,
|
|
287
|
+
* dirty: boolean,
|
|
288
|
+
* warnings?: string[],
|
|
289
|
+
* filters?: object,
|
|
290
|
+
* measuredAt?: string}} rec
|
|
291
|
+
* @returns {import('./lib/auq/schema.mjs').AuqReport}
|
|
292
|
+
*/
|
|
293
|
+
export function buildReport(rec) {
|
|
294
|
+
const blocks = Array.isArray(rec?.blocks) ? rec.blocks : [];
|
|
295
|
+
const scores = Array.isArray(rec?.scores) ? rec.scores : [];
|
|
296
|
+
const summary = emptySummary();
|
|
297
|
+
|
|
298
|
+
for (const score of scores) {
|
|
299
|
+
if (Object.prototype.hasOwnProperty.call(summary.grades, score.grade)) {
|
|
300
|
+
summary.grades[score.grade] += 1;
|
|
301
|
+
}
|
|
302
|
+
for (const finding of score.findings) {
|
|
303
|
+
const bucket = summary.byCriterion[finding.criterion];
|
|
304
|
+
if (bucket !== undefined) bucket[finding.severity] += 1;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
schemaVersion: SCHEMA_VERSION,
|
|
310
|
+
measuredAt: typeof rec?.measuredAt === 'string' ? rec.measuredAt : new Date().toISOString(),
|
|
311
|
+
head: typeof rec?.head === 'string' ? rec.head : null,
|
|
312
|
+
dirty: rec?.dirty !== false,
|
|
313
|
+
corpus: { ...emptyCorpus(), ...(rec?.corpus ?? {}) },
|
|
314
|
+
blocks,
|
|
315
|
+
scores,
|
|
316
|
+
summary,
|
|
317
|
+
// Zwei additive Felder ausserhalb des AuqReport-Kerns. `validateReport`
|
|
318
|
+
// ignoriert unbekannte Schlüssel, der Vertrag bleibt also eingehalten.
|
|
319
|
+
// Beide verhindern eine FALSCHE LESART, nicht bloß Bequemlichkeit:
|
|
320
|
+
// warnings — die Extraktion hat etwas übersprungen; ohne diese Liste sähe
|
|
321
|
+
// ein Nicht-Befund wie ein Bestehen aus.
|
|
322
|
+
// filters — eine gefilterte Zahl ist NICHT die Baseline. Wer sie später
|
|
323
|
+
// zitiert, muss sehen können, worüber gemessen wurde.
|
|
324
|
+
warnings: Array.isArray(rec?.warnings) ? [...rec.warnings] : [],
|
|
325
|
+
filters: rec?.filters ?? null,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Auswahl
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Wendet `--population` / `--file` / `--criterion` auf die BLÖCKE an.
|
|
335
|
+
*
|
|
336
|
+
* Alle drei sind Selektoren auf FRAGEN, nie auf Befunde: würde `--criterion`
|
|
337
|
+
* die Befundliste einer Frage beschneiden, käme eine Note heraus, die es nicht
|
|
338
|
+
* gibt. Eine Frage wird deshalb entweder ganz gezeigt oder gar nicht.
|
|
339
|
+
*
|
|
340
|
+
* @param {import('./lib/auq/schema.mjs').AuqBlock[]} blocks
|
|
341
|
+
* @param {{populations: string[], files: string[], criteria: string[]}} filters
|
|
342
|
+
* @returns {import('./lib/auq/schema.mjs').AuqBlock[]}
|
|
343
|
+
*/
|
|
344
|
+
function applyFilters(blocks, filters) {
|
|
345
|
+
const { populations, files, criteria } = filters;
|
|
346
|
+
if (populations.length === 0 && files.length === 0 && criteria.length === 0) return blocks;
|
|
347
|
+
|
|
348
|
+
const matchesFile = (file) =>
|
|
349
|
+
files.length === 0 || files.some((f) => file === f || file.startsWith(`${f.replace(/\/$/u, '')}/`));
|
|
350
|
+
|
|
351
|
+
const out2 = [];
|
|
352
|
+
for (const block of blocks) {
|
|
353
|
+
const kept = block.questions.filter((q) => {
|
|
354
|
+
if (populations.length > 0 && !populations.includes(q.population)) return false;
|
|
355
|
+
if (!matchesFile(q.file)) return false;
|
|
356
|
+
if (criteria.length > 0) {
|
|
357
|
+
// Eine Frage bleibt, wenn sie MINDESTENS EINEN Befund des gesuchten
|
|
358
|
+
// Kriteriums trägt — bewertet wird sie danach unverändert.
|
|
359
|
+
const score = scoreBlocks([{ ...block, questions: [q] }])[0];
|
|
360
|
+
if (!score || !score.findings.some((f) => criteria.includes(f.criterion))) return false;
|
|
361
|
+
}
|
|
362
|
+
return true;
|
|
363
|
+
});
|
|
364
|
+
if (kept.length > 0) out2.push({ ...block, questions: kept });
|
|
365
|
+
}
|
|
366
|
+
return out2;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// Kennzahlen für den lesbaren Bericht
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Bestanden heißt: KEINE harte Grenze gerissen UND kein Fehler-Befund.
|
|
375
|
+
*
|
|
376
|
+
* Diese Definition erfindet keine neue Schwelle — sie liest nur zusammen, was
|
|
377
|
+
* `clarity.mjs` bereits entschieden hat. Der Bericht schreibt sie ausdrücklich
|
|
378
|
+
* hin, damit die Leitzahl nicht unbeschriftet dasteht.
|
|
379
|
+
*
|
|
380
|
+
* @param {import('./lib/auq/schema.mjs').AuqScore} score
|
|
381
|
+
*/
|
|
382
|
+
function passes(score) {
|
|
383
|
+
return score.hurdlesBroken.length === 0 && !score.findings.some(isCountedFailure);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** @param {number} part @param {number} whole */
|
|
387
|
+
function pct(part, whole) {
|
|
388
|
+
if (whole === 0) return ' 0 %';
|
|
389
|
+
return `${String(Math.round((part / whole) * 100)).padStart(3, ' ')} %`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Rechtsbündige Zahl in fester Breite. */
|
|
393
|
+
function num(n, width) {
|
|
394
|
+
return String(n).padStart(width, ' ');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Linksbündiger Text in fester Breite. */
|
|
398
|
+
function pad(s, width) {
|
|
399
|
+
const chars = [...String(s)];
|
|
400
|
+
return chars.length >= width ? String(s) : String(s) + ' '.repeat(width - chars.length);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Rangfolge der schlechtesten Fragen: erst die schlechteste Note, dann die
|
|
405
|
+
* wenigsten Punkte, dann die meisten Fehler, zuletzt Datei/Zeile — damit zwei
|
|
406
|
+
* Läufe über denselben Baum dieselbe Reihenfolge liefern und sich diffen lassen.
|
|
407
|
+
*
|
|
408
|
+
* Die Note steht VOR den Punkten, weil eine gerissene harte Grenze die Note
|
|
409
|
+
* übersteuert: eine F-Frage mit 50 Punkten ist nach dem Urteil des Bewerters
|
|
410
|
+
* schlechter als eine D-Frage mit denselben 50 Punkten, und eine Liste, die das
|
|
411
|
+
* umdreht, widerspricht der Tabelle darüber.
|
|
412
|
+
*
|
|
413
|
+
* @param {import('./lib/auq/schema.mjs').AuqScore[]} scores
|
|
414
|
+
*/
|
|
415
|
+
function worstFirst(scores) {
|
|
416
|
+
const rank = (g) => GRADES.indexOf(g);
|
|
417
|
+
return [...scores].sort((a, b) => {
|
|
418
|
+
if (a.grade !== b.grade) return rank(b.grade) - rank(a.grade);
|
|
419
|
+
if (a.points !== b.points) return a.points - b.points;
|
|
420
|
+
const af = a.findings.filter(isCountedFailure).length;
|
|
421
|
+
const bf = b.findings.filter(isCountedFailure).length;
|
|
422
|
+
if (af !== bf) return bf - af;
|
|
423
|
+
if (a.file !== b.file) return a.file < b.file ? -1 : 1;
|
|
424
|
+
return a.line - b.line;
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Findet die Frage zu einem Ergebnis — für Zitat und Herkunft. */
|
|
429
|
+
function questionOf(blocks, score) {
|
|
430
|
+
for (const block of blocks) {
|
|
431
|
+
for (let i = 0; i < block.questions.length; i++) {
|
|
432
|
+
const q = block.questions[i];
|
|
433
|
+
if (q.file === score.file && q.line === score.line && i === score.questionIndex) return q;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// Der lesbare Bericht
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Baut den menschenlesbaren Bericht.
|
|
445
|
+
*
|
|
446
|
+
* Dieser Text ist das erste Erzeugnis dieser Messung, das der Operator liest,
|
|
447
|
+
* und er handelt davon, dass Ausgaben lesbar sein sollen. Deshalb: deutsch,
|
|
448
|
+
* keine Kriteriums-Kürzel als Überschrift, keine nackte unbeschriftete Zahl,
|
|
449
|
+
* keine Bedeutung allein über Farbe oder Zeichen, jede Tabelle mit Kopf — und
|
|
450
|
+
* kurz, weil ein Bericht, der nicht zu Ende gelesen wird, nichts misst.
|
|
451
|
+
*
|
|
452
|
+
* @param {import('./lib/auq/schema.mjs').AuqReport} report
|
|
453
|
+
* @param {{top: number}} opts
|
|
454
|
+
* @returns {string}
|
|
455
|
+
*/
|
|
456
|
+
export function renderHuman(report, opts) {
|
|
457
|
+
const L = [];
|
|
458
|
+
const scores = report.scores;
|
|
459
|
+
const total = scores.length;
|
|
460
|
+
const passed = scores.filter(passes).length;
|
|
461
|
+
const failed = total - passed;
|
|
462
|
+
|
|
463
|
+
L.push('AUQ-Klarheitsmessung — wie verständlich sind die Fragen, die dieses System stellt?');
|
|
464
|
+
const anchor = report.head ? `HEAD ${report.head}` : 'kein git-Anker';
|
|
465
|
+
L.push(
|
|
466
|
+
`Gemessen am ${report.measuredAt} · ${anchor} · Arbeitsbaum: ${report.dirty ? 'GEÄNDERT' : 'sauber'}`,
|
|
467
|
+
);
|
|
468
|
+
if (report.dirty) {
|
|
469
|
+
L.push(
|
|
470
|
+
' Achtung: der Arbeitsbaum weicht vom letzten Commit ab. Diese Zahlen sind an diesem',
|
|
471
|
+
' Stand NICHT reproduzierbar — für eine zitierfähige Baseline auf sauberem Baum messen.',
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
if (report.filters) {
|
|
475
|
+
const f = report.filters;
|
|
476
|
+
const parts = [];
|
|
477
|
+
if (f.populations?.length) parts.push(`Herkunft: ${f.populations.join(', ')}`);
|
|
478
|
+
if (f.files?.length) parts.push(`Dateien: ${f.files.join(', ')}`);
|
|
479
|
+
if (f.criteria?.length) parts.push(`Kriterien: ${f.criteria.join(', ')}`);
|
|
480
|
+
if (parts.length > 0) {
|
|
481
|
+
L.push(` Eingegrenzt auf ${parts.join(' · ')} — das ist NICHT die Gesamtzahl des Repos.`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
L.push('');
|
|
485
|
+
|
|
486
|
+
// --- Die Leitzahl -------------------------------------------------------
|
|
487
|
+
L.push(
|
|
488
|
+
`${passed} von ${total} Fragen bestehen (${pct(passed, total).trim()}). ` +
|
|
489
|
+
`${failed} bestehen nicht (${pct(failed, total).trim()}).`,
|
|
490
|
+
);
|
|
491
|
+
L.push(
|
|
492
|
+
' Bestanden heißt: keine harte Grenze gerissen und kein einziger Fehler-Befund.',
|
|
493
|
+
);
|
|
494
|
+
L.push('');
|
|
495
|
+
|
|
496
|
+
if (total === 0) {
|
|
497
|
+
L.push('Keine Frage-Vorlage gefunden. Prüfe die Eingrenzung oder den Pfad.');
|
|
498
|
+
return L.join('\n');
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// --- Noten --------------------------------------------------------------
|
|
502
|
+
L.push('Noten (Punkte von 100; eine gerissene harte Grenze ergibt immer die schlechteste Note)');
|
|
503
|
+
L.push(` ${pad('Note', 22)}${num('Fragen', 7)} Anteil`);
|
|
504
|
+
for (const g of GRADES) {
|
|
505
|
+
const n = report.summary.grades[g] ?? 0;
|
|
506
|
+
L.push(` ${pad(`${g} — ${GRADE_LABELS[g] ?? ''}`, 22)}${num(n, 7)} ${pct(n, total)}`);
|
|
507
|
+
}
|
|
508
|
+
L.push('');
|
|
509
|
+
|
|
510
|
+
// --- Harte Grenzen ------------------------------------------------------
|
|
511
|
+
const brokenBy = {};
|
|
512
|
+
for (const h of HURDLE_IDS) brokenBy[h] = 0;
|
|
513
|
+
let anyHurdle = 0;
|
|
514
|
+
for (const s of scores) {
|
|
515
|
+
if (s.hurdlesBroken.length > 0) anyHurdle += 1;
|
|
516
|
+
for (const h of s.hurdlesBroken) brokenBy[h] = (brokenBy[h] ?? 0) + 1;
|
|
517
|
+
}
|
|
518
|
+
L.push('Harte Grenzen — das Werkzeug selbst schneidet hier ab, das ist keine Stilfrage');
|
|
519
|
+
L.push(` ${pad("Grenze", 56)}${num('Fragen', 7)} Anteil`);
|
|
520
|
+
for (const h of HURDLE_IDS) {
|
|
521
|
+
L.push(` ${pad(HURDLES[h].title, 56)}${num(brokenBy[h], 7)} ${pct(brokenBy[h], total)}`);
|
|
522
|
+
}
|
|
523
|
+
L.push(` ${pad("mindestens eine der beiden gerissen", 56)}${num(anyHurdle, 7)} ${pct(anyHurdle, total)}`);
|
|
524
|
+
L.push('');
|
|
525
|
+
|
|
526
|
+
// --- Befunde je Kriterium -----------------------------------------------
|
|
527
|
+
L.push('Was schiefgeht — Fehler ziehen Punkte ab, Hinweise nicht');
|
|
528
|
+
L.push(` ${pad('Befund', 46)}${num('Fehler', 8)}${num('Hinweise', 10)}`);
|
|
529
|
+
const rows = CRITERION_IDS.map((k) => ({ k, ...report.summary.byCriterion[k] })).sort(
|
|
530
|
+
(a, b) => b.fail - a.fail || b.warn - a.warn,
|
|
531
|
+
);
|
|
532
|
+
for (const r of rows) {
|
|
533
|
+
if (r.fail === 0 && r.warn === 0) continue;
|
|
534
|
+
L.push(` ${pad(criterionLabel(r.k), 46)}${num(r.fail, 8)}${num(r.warn, 10)}`);
|
|
535
|
+
}
|
|
536
|
+
L.push('');
|
|
537
|
+
|
|
538
|
+
// --- Nach Herkunft ------------------------------------------------------
|
|
539
|
+
const byPop = new Map();
|
|
540
|
+
for (const s of scores) {
|
|
541
|
+
const q = questionOf(report.blocks, s);
|
|
542
|
+
const p = q?.population ?? 'unbekannt';
|
|
543
|
+
const rec = byPop.get(p) ?? { total: 0, passed: 0, hurdles: 0 };
|
|
544
|
+
rec.total += 1;
|
|
545
|
+
if (passes(s)) rec.passed += 1;
|
|
546
|
+
if (s.hurdlesBroken.length > 0) rec.hurdles += 1;
|
|
547
|
+
byPop.set(p, rec);
|
|
548
|
+
}
|
|
549
|
+
L.push('Nach Herkunft — wo die Frage steht, entscheidet, welches Werkzeug sie stellt');
|
|
550
|
+
L.push(` ${pad('Herkunft', 38)}${num('Fragen', 7)}${num('bestehen', 10)} Anteil`);
|
|
551
|
+
for (const p of POPULATIONS) {
|
|
552
|
+
const rec = byPop.get(p);
|
|
553
|
+
if (!rec) continue;
|
|
554
|
+
L.push(
|
|
555
|
+
` ${pad(`${p} — ${populationLabel(p)}`, 38)}${num(rec.total, 7)}${num(rec.passed, 10)} ` +
|
|
556
|
+
`${pct(rec.passed, rec.total)}`,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
L.push('');
|
|
560
|
+
|
|
561
|
+
// --- Die schlechtesten Fragen -------------------------------------------
|
|
562
|
+
const worst = worstFirst(scores).slice(0, Math.max(0, opts.top));
|
|
563
|
+
if (worst.length > 0) {
|
|
564
|
+
L.push(`Die ${worst.length} schlechtesten Fragen — Datei und Zeile zum Nachschlagen`);
|
|
565
|
+
worst.forEach((s, i) => {
|
|
566
|
+
const q = questionOf(report.blocks, s);
|
|
567
|
+
const hurdleNote =
|
|
568
|
+
s.hurdlesBroken.length > 0 ? ` · harte Grenze gerissen: ${s.hurdlesBroken.join(', ')}` : '';
|
|
569
|
+
L.push(` ${num(i + 1, 2)}. ${s.file}:${s.line} — Note ${s.grade}, ${s.points} Punkte${hurdleNote}`);
|
|
570
|
+
if (q) L.push(` Frage: „${truncateExcerpt(q.question)}"`);
|
|
571
|
+
const reasons = [...new Set(s.findings.filter(isCountedFailure).map((f) => criterionLabel(f.criterion)))];
|
|
572
|
+
if (reasons.length > 0) L.push(` Fehler: ${reasons.join(' · ')}`);
|
|
573
|
+
});
|
|
574
|
+
L.push('');
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// --- Leseprobleme -------------------------------------------------------
|
|
578
|
+
if (report.warnings.length > 0) {
|
|
579
|
+
L.push(`Beim Einlesen übersprungen oder auffällig (${report.warnings.length})`);
|
|
580
|
+
for (const w of report.warnings.slice(0, 10)) L.push(` - ${w}`);
|
|
581
|
+
if (report.warnings.length > 10) L.push(` … und ${report.warnings.length - 10} weitere`);
|
|
582
|
+
L.push('');
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
L.push('Vollständige Daten je Frage und Befund: dasselbe Kommando mit --json.');
|
|
586
|
+
return L.join('\n');
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ---------------------------------------------------------------------------
|
|
590
|
+
// Argumente
|
|
591
|
+
// ---------------------------------------------------------------------------
|
|
592
|
+
|
|
593
|
+
const USAGE = 'Aufruf: node scripts/auq-audit.mjs [<repo-wurzel>] [--json] [--strict] [--top N] [--criterion K7] [--population A] [--file <pfad>]';
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* @param {string[]} argv
|
|
597
|
+
* @returns {{error: string|null, help: boolean, version: boolean, json: boolean,
|
|
598
|
+
* strict: boolean, top: number, criteria: string[], populations: string[],
|
|
599
|
+
* files: string[], root: string}}
|
|
600
|
+
*/
|
|
601
|
+
export function parseCliArgs(argv) {
|
|
602
|
+
const base = {
|
|
603
|
+
error: null,
|
|
604
|
+
help: false,
|
|
605
|
+
version: false,
|
|
606
|
+
json: false,
|
|
607
|
+
strict: false,
|
|
608
|
+
top: DEFAULT_TOP,
|
|
609
|
+
criteria: [],
|
|
610
|
+
populations: [],
|
|
611
|
+
files: [],
|
|
612
|
+
root: REPO_ROOT_DEFAULT,
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
let parsed;
|
|
616
|
+
try {
|
|
617
|
+
parsed = nodeParseArgs({
|
|
618
|
+
args: argv,
|
|
619
|
+
allowPositionals: true,
|
|
620
|
+
strict: true,
|
|
621
|
+
options: {
|
|
622
|
+
json: { type: 'boolean', default: false },
|
|
623
|
+
strict: { type: 'boolean', default: false },
|
|
624
|
+
help: { type: 'boolean', default: false },
|
|
625
|
+
version: { type: 'boolean', default: false },
|
|
626
|
+
top: { type: 'string' },
|
|
627
|
+
criterion: { type: 'string', multiple: true },
|
|
628
|
+
population: { type: 'string', multiple: true },
|
|
629
|
+
file: { type: 'string', multiple: true },
|
|
630
|
+
},
|
|
631
|
+
});
|
|
632
|
+
} catch (err) {
|
|
633
|
+
return { ...base, error: err?.message ?? String(err) };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const v = parsed.values;
|
|
637
|
+
const criteria = v.criterion ?? [];
|
|
638
|
+
for (const c of criteria) {
|
|
639
|
+
if (!CRITERION_IDS.includes(c)) {
|
|
640
|
+
return { ...base, error: `unbekanntes Kriterium: ${c} (erlaubt: ${CRITERION_IDS.join(', ')})` };
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const populations = v.population ?? [];
|
|
644
|
+
for (const p of populations) {
|
|
645
|
+
if (!POPULATIONS.includes(p)) {
|
|
646
|
+
return { ...base, error: `unbekannte Herkunft: ${p} (erlaubt: ${POPULATIONS.join(', ')})` };
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
let top = DEFAULT_TOP;
|
|
651
|
+
if (v.top !== undefined) {
|
|
652
|
+
const n = Number(v.top);
|
|
653
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
654
|
+
return { ...base, error: `--top braucht eine ganze Zahl >= 0 (bekommen: ${v.top})` };
|
|
655
|
+
}
|
|
656
|
+
top = n;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (parsed.positionals.length > 1) {
|
|
660
|
+
return { ...base, error: `höchstens eine Repo-Wurzel erlaubt (bekommen: ${parsed.positionals.length})` };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return {
|
|
664
|
+
...base,
|
|
665
|
+
help: v.help === true,
|
|
666
|
+
version: v.version === true,
|
|
667
|
+
json: v.json === true,
|
|
668
|
+
strict: v.strict === true,
|
|
669
|
+
top,
|
|
670
|
+
criteria: [...criteria],
|
|
671
|
+
populations: [...populations],
|
|
672
|
+
files: (v.file ?? []).map((f) => f.replace(/\\/gu, '/').replace(/^\.\//u, '')),
|
|
673
|
+
root: parsed.positionals[0] ? path.resolve(parsed.positionals[0]) : REPO_ROOT_DEFAULT,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function printHelp() {
|
|
678
|
+
out(USAGE);
|
|
679
|
+
out('');
|
|
680
|
+
out('Misst, wie verständlich die Fragen sind, die dieses System dem Operator stellt.');
|
|
681
|
+
out('Findet jede Frage-Vorlage im Repo, benotet sie gegen acht Kriterien und zwei');
|
|
682
|
+
out('harte Grenzen, und gibt eine Notenverteilung aus. Rein deterministisch, kein LLM.');
|
|
683
|
+
out('');
|
|
684
|
+
out(' --json maschinenlesbarer Umschlag auf stdout, sonst nichts');
|
|
685
|
+
out(' --strict Ausgang 3, wenn mindestens eine harte Grenze gerissen ist');
|
|
686
|
+
out(` --top N die N schlechtesten Fragen zeigen (Vorgabe: ${DEFAULT_TOP})`);
|
|
687
|
+
out(` --criterion K7 nur Fragen mit einem Befund dieses Kriteriums (erlaubt: ${CRITERION_IDS.join(', ')})`);
|
|
688
|
+
out(` --population A nur diese Herkunft (erlaubt: ${POPULATIONS.join(', ')})`);
|
|
689
|
+
out(' --file <pfad> nur diese Datei oder dieses Verzeichnis (repo-relativ)');
|
|
690
|
+
out(' --help / --version');
|
|
691
|
+
out('');
|
|
692
|
+
out(' <repo-wurzel> Wurzel des zu messenden Repos (Vorgabe: dieses Repo)');
|
|
693
|
+
out('');
|
|
694
|
+
out('Alle drei Eingrenzungen wirken auf FRAGEN, nie auf Befunde: eine Frage wird ganz');
|
|
695
|
+
out('gezeigt oder gar nicht. Eine eingegrenzte Zahl ist nie die Gesamtzahl des Repos —');
|
|
696
|
+
out('der Umschlag führt die gesetzten Eingrenzungen deshalb im Feld "filters" mit.');
|
|
697
|
+
out('');
|
|
698
|
+
out('Warum --strict standardmäßig aus ist: nur die beiden harten Grenzen haben eine');
|
|
699
|
+
out('gemessene Falsch-Positiv-Rate von 0 %, die übrigen Kriterien liegen zwischen 14 %');
|
|
700
|
+
out('und 25 %. Ein Prüfer, der darauf sperrt, meckert korrekte Fragen an und wird');
|
|
701
|
+
out('abgeschaltet — dann ist auch jeder echte Befund weg.');
|
|
702
|
+
out('');
|
|
703
|
+
out('Ausgang: 0 Erfolg · 1 Eingabefehler · 2 Systemfehler (Bericht verletzt sein');
|
|
704
|
+
out('eigenes Schema) · 3 nur mit --strict: harte Grenze gerissen.');
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ---------------------------------------------------------------------------
|
|
708
|
+
// main
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
|
|
711
|
+
function main() {
|
|
712
|
+
const args = parseCliArgs(process.argv.slice(2));
|
|
713
|
+
|
|
714
|
+
if (args.error) {
|
|
715
|
+
note(`Fehler: ${args.error}`);
|
|
716
|
+
note(USAGE);
|
|
717
|
+
process.exit(EXIT.usage);
|
|
718
|
+
}
|
|
719
|
+
if (args.help) {
|
|
720
|
+
printHelp();
|
|
721
|
+
process.exit(EXIT.ok);
|
|
722
|
+
}
|
|
723
|
+
if (args.version) {
|
|
724
|
+
// Die Version des Umschlag-Schemas ist die, an der ein Konsument hängt —
|
|
725
|
+
// die Paketversion steht daneben, weil `--version` sie erwartet.
|
|
726
|
+
out(`auq-audit ${pkgVersion()} (Umschlag ${SCHEMA_VERSION})`);
|
|
727
|
+
process.exit(EXIT.ok);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (!existsSync(args.root) || !statSync(args.root).isDirectory()) {
|
|
731
|
+
note(`Fehler: Repo-Wurzel nicht lesbar: ${args.root}`);
|
|
732
|
+
process.exit(EXIT.usage);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Nennt `--file` ausschließlich konkrete Korpus-DATEIEN, werden genau die
|
|
736
|
+
// gelesen und `git ls-files` gar nicht erst gerufen — so ist die CLI auch auf
|
|
737
|
+
// einem Verzeichnis ohne git-Repo benutzbar (und genau so testbar). Sobald
|
|
738
|
+
// ein Verzeichnis-PRÄFIX dabei ist, braucht es die Aufzählung; die
|
|
739
|
+
// Eingrenzung übernimmt dann `applyFilters`.
|
|
740
|
+
const explicit = args.files.filter((f) => corpusKindOf(f) !== null);
|
|
741
|
+
const prefixes = args.files.filter((f) => corpusKindOf(f) === null);
|
|
742
|
+
let files;
|
|
743
|
+
if (args.files.length > 0 && prefixes.length === 0) {
|
|
744
|
+
const missing = explicit.filter((f) => !existsSync(path.join(args.root, f)));
|
|
745
|
+
if (missing.length > 0) {
|
|
746
|
+
note(`Fehler: Datei nicht gefunden: ${missing.join(', ')}`);
|
|
747
|
+
process.exit(EXIT.usage);
|
|
748
|
+
}
|
|
749
|
+
files = explicit;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const parsedRepo = parseRepo({ repoRoot: args.root, files });
|
|
753
|
+
const blocks = applyFilters(parsedRepo.blocks, {
|
|
754
|
+
populations: args.populations,
|
|
755
|
+
files: args.files,
|
|
756
|
+
criteria: args.criteria,
|
|
757
|
+
});
|
|
758
|
+
const scores = scoreBlocks(blocks);
|
|
759
|
+
|
|
760
|
+
// `corpus` wird aus den GEFILTERTEN Blöcken neu gezählt, sonst behauptete der
|
|
761
|
+
// Umschlag eine Grundgesamtheit, die im Bericht gar nicht steht.
|
|
762
|
+
const corpus = emptyCorpus();
|
|
763
|
+
for (const b of blocks) {
|
|
764
|
+
const p = b.questions[0]?.population;
|
|
765
|
+
if (p !== undefined) corpus[p] += 1;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const hasFilters =
|
|
769
|
+
args.populations.length > 0 || args.files.length > 0 || args.criteria.length > 0;
|
|
770
|
+
|
|
771
|
+
// Ist der Bericht eingegrenzt, müssen die Lesewarnungen mitgehen: eine
|
|
772
|
+
// Warnung über eine Datei, die im Bericht gar nicht vorkommt, liest sich wie
|
|
773
|
+
// ein Mangel der gezeigten Auswahl. Jede Warnung beginnt mit `datei:zeile:`.
|
|
774
|
+
const keptFiles = new Set(blocks.flatMap((b) => b.questions.map((q) => q.file)));
|
|
775
|
+
const warnings = hasFilters
|
|
776
|
+
? parsedRepo.warnings.filter((w) => [...keptFiles].some((f) => w.startsWith(`${f}:`)))
|
|
777
|
+
: parsedRepo.warnings;
|
|
778
|
+
|
|
779
|
+
const report = buildReport({
|
|
780
|
+
blocks,
|
|
781
|
+
scores,
|
|
782
|
+
corpus,
|
|
783
|
+
head: headRef(args.root),
|
|
784
|
+
dirty: isDirty(args.root),
|
|
785
|
+
warnings,
|
|
786
|
+
filters: hasFilters
|
|
787
|
+
? { populations: args.populations, files: args.files, criteria: args.criteria }
|
|
788
|
+
: null,
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
// Ein Bericht, der sein eigenes Schema verletzt, ist schlimmer als keiner:
|
|
792
|
+
// er sieht aus wie eine Messung und ist eine. Deshalb VOR der Ausgabe prüfen
|
|
793
|
+
// und im Fehlerfall gar keinen Umschlag schreiben.
|
|
794
|
+
const verdict = validateReport(report);
|
|
795
|
+
if (!verdict.ok) {
|
|
796
|
+
note('Fehler: der erzeugte Bericht verletzt sein eigenes Schema — nichts ausgegeben.');
|
|
797
|
+
for (const e of verdict.errors.slice(0, 20)) note(` - ${e}`);
|
|
798
|
+
if (verdict.errors.length > 20) note(` … und ${verdict.errors.length - 20} weitere`);
|
|
799
|
+
process.exit(decideExit({ reportValid: false, strict: args.strict, hurdlesBroken: 0 }));
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (args.json) out(JSON.stringify(report, null, 2));
|
|
803
|
+
else out(renderHuman(report, { top: args.top }));
|
|
804
|
+
|
|
805
|
+
const hurdlesBroken = scores.filter((s) => s.hurdlesBroken.length > 0).length;
|
|
806
|
+
if (args.strict && hurdlesBroken > 0) {
|
|
807
|
+
note(`--strict: ${hurdlesBroken} Frage(n) reißen eine harte Grenze.`);
|
|
808
|
+
}
|
|
809
|
+
process.exit(decideExit({ reportValid: true, strict: args.strict, hurdlesBroken }));
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** Paketversion, oder `0.0.0` wenn package.json nicht lesbar ist. */
|
|
813
|
+
function pkgVersion() {
|
|
814
|
+
try {
|
|
815
|
+
return JSON.parse(readFileSync(path.join(REPO_ROOT_DEFAULT, 'package.json'), 'utf8')).version;
|
|
816
|
+
} catch {
|
|
817
|
+
return '0.0.0';
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const invokedDirectly =
|
|
822
|
+
process.argv[1] !== undefined &&
|
|
823
|
+
path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
824
|
+
|
|
825
|
+
if (invokedDirectly) main();
|