waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app tools paketstand.mjs

16,7 KB Rohdatei
app/tools/paketstand.mjs — 473 Zeilen
1 /**
2 * Wie aktuell ist das gepackte Paket unter `release/win-unpacked/`?
3 *
4 * ## Warum es diese Datei gibt
5 *
6 * `e2e/gepackt.spec.ts` prüft die **gepackte** Anwendung – die Auflösung der
7 * Pfade unter `resources/`, die Versionsanzeige, den Start aus dem Paket
8 * heraus. Diese Prüfungen liefen bisher gegen das, was gerade in
9 * `release/win-unpacked/` lag, gleich wie alt es war.
10 *
11 * Das hat in diesem Projekt **zweimal** einen echten Fehler verdeckt:
12 *
13 * 1. Beim Umbau der Reifekennzahl hing die Prüfung weiter am Wortlaut
14 * „0 von 575 Fragen sicher“, den es nicht mehr gab.
15 * 2. Als der Systemzustand den Quellort des Fragenkatalogs bekam, wurde der
16 * Locator `.statusliste code` mehrdeutig.
17 *
18 * Beide Male meldete `npm run gate` grün, und beide Male fiel es erst beim
19 * nächsten Paketbau auf – weil das Paket bis dahin die Änderung gar nicht
20 * enthielt.
21 *
22 * `bauPruefen()` in `e2e/electron-hilfe.ts` führt dieses Argument für `out/`
23 * schon selbst: „Ein Bau von gestern gegen den Quelltext von heute ist
24 * schlimmer als gar keiner: Der Lauf ist grün und misst die falsche
25 * Anwendung.“ Auf das Paket wurde es nie angewandt.
26 *
27 * ## Warum der Vergleich nicht bei `src/` aufhört
28 *
29 * Die erste Fassung verglich das Paket allein mit `app/src`. Das ist die
30 * halbe Lieferung: Der **Inhalt** – Fragenkatalog, Erklärungen, Glossar,
31 * Prüfzeichen – liegt gar nicht unter `app/src`, sondern unter `content/`
32 * und wird über `extraResources` in `electron-builder.yml` mitgepackt. Wer
33 * eine Erklärung ändert und dann prüft, bekam ein Paket gemeldet, das „so
34 * jung wie der Quelltext“ sei – und die Prüfungen maßen die vorige
35 * Fassung der Inhalte und meldeten grün. Genau die Fehlerklasse, gegen die
36 * diese Datei angelegt wurde, nur eine Tür weiter.
37 *
38 * Deshalb liest der Vergleich die Liste der mitgelieferten Pfade **aus
39 * `electron-builder.yml`** statt sie hier noch einmal aufzuschreiben. Zwei
40 * Listen desselben Inhalts laufen beim nächsten Umbau auseinander, und dann
41 * schweigt die Wache wieder. Was electron-builder packt, wird geprüft; was
42 * dazukommt, wird ohne Zutun mitgeprüft.
43 *
44 * ## Warum eine eigene Datei und kein zweiter Abgleich
45 *
46 * Zwei Stellen brauchen dieselbe Antwort: die Prüfung selbst, damit sie sich
47 * mit Grund überspringt, und `gate-stempel.mjs`, damit der Gate-Bericht sagt,
48 * was er **nicht** geprüft hat. Zwei Umsetzungen desselben Vergleichs liefen
49 * irgendwann auseinander, und dann widerspräche der Bericht dem Lauf.
50 *
51 * Als `.mjs` und nicht als TypeScript, weil `gate-stempel.mjs` sie ohne
52 * Übersetzungsschritt lädt.
53 */
54
55 import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
56 import { dirname, join, relative, resolve } from 'node:path';
57
58 /**
59 * Toleranz gegen die Auflösung des Dateisystems.
60 *
61 * Derselbe Wert wie in `bauPruefen()`: Ein Paketbau schreibt seine Ausgaben
62 * nicht in derselben Millisekunde, in der er die Quellen liest.
63 */
64 const TOLERANZ_MS = 2000;
65
66 /** Verzeichnisse, die für den Vergleich nichts beitragen. */
67 const UEBERGANGEN = new Set(['node_modules', '.git', 'out', 'release', 'test-results']);
68
69 /**
70 * Was `out/` für den Vergleich ersetzt.
71 *
72 * `electron-builder.yml` packt `out/**` – das ist aber kein Quelltext,
73 * sondern das Erzeugnis von `electron-vite build` aus `src/`. Auf `out/`
74 * selbst zu schauen ginge zweimal daneben: `npm run gate` baut `out/` in
75 * jedem Lauf neu, das Paket sähe also unmittelbar nach jedem Gate veraltet
76 * aus; und die Frage, ob der Quelltext weitergewandert ist, beantwortet es
77 * ohnehin nicht. `src/` ist die ehrliche Entsprechung.
78 */
79 const ERSATZ_FUER_ERZEUGNIS = new Map([['out', 'src']]);
80
81 /** Zeichen, an denen electron-builder einen Glob erkennt. */
82 const GLOB_ZEICHEN = /[*?[\]{}]/u;
83
84 /**
85 * Nimmt einer YAML-Skalarzeile Anführungszeichen und Zeilenkommentar ab.
86 *
87 * @param {string} roh
88 * @returns {string}
89 */
90 function entwerten(roh) {
91 const wert = roh.trim();
92
93 for (const anfuehrung of ["'", '"']) {
94 if (wert.startsWith(anfuehrung)) {
95 const ende = wert.indexOf(anfuehrung, 1);
96 if (ende < 0) {
97 throw new Error(`electron-builder.yml: unbeendetes Anführungszeichen in ${wert}`);
98 }
99 return wert.slice(1, ende);
100 }
101 }
102
103 const kommentar = wert.indexOf(' #');
104 return kommentar < 0 ? wert : wert.slice(0, kommentar).trim();
105 }
106
107 /**
108 * Liest die beiden Listen aus `electron-builder.yml`, die etwas ausliefern.
109 *
110 * Bewusst ein winziger, **strenger** Ausschnitt von YAML statt einer
111 * Abhängigkeit: `js-yaml` liegt zwar unter `node_modules`, aber nur als
112 * Beipack von electron-builder – ein Import darauf wäre eine nicht erklärte
113 * Abhängigkeit. Der Ausschnitt versteht genau die Formen, die diese Datei
114 * heute enthält, und **wirft** bei allem anderen. Ein lauter Fehler beim
115 * nächsten Umbau ist der Punkt: Eine Wache, die eine neue Schreibweise still
116 * überliest, bewacht wieder nichts.
117 *
118 * @param {string} text Inhalt von `electron-builder.yml`.
119 * @returns {{ programm: string[], inhalte: string[] }} Muster, wie sie in der
120 * Datei stehen: `programm` aus `files:`, `inhalte` aus `extraResources:`.
121 */
122 export function musterAusBauplan(text) {
123 /** @type {string[]} */ const programm = [];
124 /** @type {string[]} */ const inhalte = [];
125
126 /** @type {'files' | 'extraResources' | null} */
127 let block = null;
128 // Einzug der `- `-Zeilen des laufenden Blocks; -1, solange unbekannt.
129 let eintragsEinzug = -1;
130
131 for (const zeile of text.split(/\r?\n/u)) {
132 const ohneEinzug = zeile.trimStart();
133 if (ohneEinzug === '' || ohneEinzug.startsWith('#')) {
134 continue;
135 }
136 const einzug = zeile.length - ohneEinzug.length;
137
138 // Oberste Ebene: ein neuer Schlüssel beendet den laufenden Block.
139 if (einzug === 0) {
140 const schluessel = /^([A-Za-z][A-Za-z0-9_]*):/u.exec(ohneEinzug);
141 const name = schluessel?.[1];
142 block = name === 'files' || name === 'extraResources' ? name : null;
143 eintragsEinzug = -1;
144 continue;
145 }
146
147 if (block === null) {
148 continue;
149 }
150
151 if (!ohneEinzug.startsWith('- ')) {
152 // Eigenschaft eines Eintrags (`to:`, `filter:`) – liefert selbst nichts.
153 if (eintragsEinzug >= 0 && einzug > eintragsEinzug) {
154 continue;
155 }
156 throw new Error(`electron-builder.yml: unverstandene Zeile im Block ${block}: ${ohneEinzug}`);
157 }
158
159 if (eintragsEinzug < 0) {
160 eintragsEinzug = einzug;
161 }
162 if (einzug > eintragsEinzug) {
163 // Unterliste eines Eintrags, etwa `filter:` – kein eigener Pfad.
164 continue;
165 }
166 if (einzug < eintragsEinzug) {
167 throw new Error(
168 `electron-builder.yml: Listeneintrag mit fremdem Einzug im Block ${block}: ${ohneEinzug}`,
169 );
170 }
171
172 const wert = ohneEinzug.slice(2).trim();
173 // Beide Blöcke erlauben sowohl `- pfad` als auch `- from: pfad`.
174 const ausFrom = /^from:\s*(.+)$/u.exec(wert);
175 const muster = entwerten(ausFrom ? ausFrom[1] : wert);
176
177 // Ausschlussmuster liefern nichts aus und tragen zum Alter nichts bei.
178 if (muster.startsWith('!')) {
179 continue;
180 }
181
182 (block === 'files' ? programm : inhalte).push(muster);
183 }
184
185 return { programm, inhalte };
186 }
187
188 /**
189 * Macht aus einem Auslieferungsmuster den Pfad, dessen Alter zählt.
190 *
191 * `out/**\/*` wird zu `src`, `../content/katalog` zum Verzeichnis selbst.
192 *
193 * @param {string} appWurzel
194 * @param {string} muster
195 * @returns {string} Absoluter Pfad.
196 */
197 function pfadZuMuster(appWurzel, muster) {
198 const teile = muster.split('/');
199 /** @type {string[]} */ const fest = [];
200 for (const teil of teile) {
201 if (GLOB_ZEICHEN.test(teil)) {
202 break;
203 }
204 fest.push(teil);
205 }
206
207 if (fest.length === 0) {
208 throw new Error(
209 `electron-builder.yml: Muster ${muster} beginnt mit einem Platzhalter. ` +
210 'Es ließe sich nur auf das ganze Projektverzeichnis auflösen und ' +
211 'wäre als Altersvergleich wertlos.',
212 );
213 }
214
215 const ersatz = ERSATZ_FUER_ERZEUGNIS.get(fest[0]);
216 if (ersatz !== undefined) {
217 fest.splice(0, fest.length, ersatz);
218 }
219
220 const pfad = resolve(appWurzel, ...fest);
221 if (!existsSync(pfad)) {
222 throw new Error(
223 `electron-builder.yml verweist auf ${muster}, unter ${pfad} liegt aber nichts. ` +
224 'Entweder ist der Bauplan veraltet oder der Altersvergleich in ' +
225 'tools/paketstand.mjs löst ihn falsch auf – beides muss auffallen.',
226 );
227 }
228 return pfad;
229 }
230
231 /**
232 * Das Verzeichnis, aus dem electron-builder die Bauzutaten holt.
233 *
234 * Das Programmsymbol liegt weder unter `files:` noch unter `extraResources:`
235 * – es steht in `directories.buildResources` und wird von electron-builder
236 * in die exe und in das Installationsprogramm eingebaut. Genau deshalb fehlte
237 * es hier zuerst: Wer das Symbol änderte, bekam ein Paket gemeldet, das „so
238 * jung wie der Quelltext“ sei, und trug das alte Bild weiter. Dieselbe
239 * Fehlerklasse wie damals bei `content/`, nur eine Tür weiter.
240 *
241 * Gelesen wird auch das aus dem Bauplan statt es hier aufzuschreiben. Zwei
242 * Listen desselben Sachverhalts laufen beim nächsten Umbau auseinander.
243 *
244 * @param {string} text Inhalt von `electron-builder.yml`.
245 * @returns {string | null} Der Verzeichnisname, oder `null`, wenn keiner steht.
246 */
247 export function bauressourcenAusBauplan(text) {
248 let imBlock = false;
249 for (const zeile of text.split(/\r?\n/u)) {
250 const ohneEinzug = zeile.trimStart();
251 if (ohneEinzug === '' || ohneEinzug.startsWith('#')) {
252 continue;
253 }
254 if (zeile.length === ohneEinzug.length) {
255 // Oberste Ebene: ein neuer Schlüssel beendet den laufenden Block.
256 imBlock = /^directories:/u.test(ohneEinzug);
257 continue;
258 }
259 if (imBlock) {
260 const treffer = /^buildResources:\s*(\S+)/u.exec(ohneEinzug);
261 if (treffer) {
262 return treffer[1];
263 }
264 }
265 }
266 return null;
267 }
268
269 /**
270 * Alle Pfade, deren Alter über die Aktualität des Pakets entscheidet.
271 *
272 * Eigene Funktion und nicht in `paketstand()` versteckt, weil sie für sich
273 * prüfbar sein muss: Sie ist die Stelle, an der Bauplan und Vergleich
274 * auseinanderlaufen könnten. `paketstand()` beantwortet sie nicht, wenn gar
275 * kein Paket dasteht – ein Test, der nur über `paketstand()` ginge, prüfte
276 * die Auflösung an einem Arbeitsplatz ohne `release/` also gar nicht.
277 *
278 * @param {string} appWurzel Das Verzeichnis `app/`.
279 * @returns {{ quelltext: string[], inhalte: string[] }} Absolute Pfade.
280 */
281 export function ausgelieferteQuellen(appWurzel) {
282 const bauplan = join(appWurzel, 'electron-builder.yml');
283 if (!existsSync(bauplan)) {
284 throw new Error(
285 `Kein Bauplan unter ${bauplan}. Ohne ihn ist nicht zu sagen, was das Paket ` +
286 'ausliefert – und ein Altersvergleich, der das nicht weiß, prüft nichts.',
287 );
288 }
289
290 const text = readFileSync(bauplan, 'utf8');
291 const muster = musterAusBauplan(text);
292 const quelltext = muster.programm.map((m) => pfadZuMuster(appWurzel, m));
293
294 /*
295 Der Bauplan selbst gehört dazu.
296
297 Er sagt nicht nur, **was** ausgeliefert wird, sondern bestimmt auch, **wie**:
298 Ziele, Dateilisten, ASAR-Entpackung, die Angaben des Store-Pakets. Wer
299 daran etwas ändert, hat ein anderes Paket vor sich – bis Fassung 0.24.1
300 galt der alte Beleg trotzdem weiter, weil die Wache jeden Pfad ansah,
301 den der Bauplan nennt, nur nicht ihn selbst. Derselbe blinde Fleck wie
302 bei den Bauressourcen (7.23), eine Ebene höher.
303 */
304 quelltext.push(bauplan);
305
306 const bauressourcen = bauressourcenAusBauplan(text);
307 if (bauressourcen !== null) {
308 quelltext.push(pfadZuMuster(appWurzel, bauressourcen));
309 }
310
311 return {
312 quelltext,
313 inhalte: muster.inhalte.map((m) => pfadZuMuster(appWurzel, m)),
314 };
315 }
316
317 /**
318 * @typedef {object} Aenderung
319 * @property {number} zeit Änderungszeit in Millisekunden; 0, wenn nichts da war.
320 * @property {string} pfad Die Datei, von der sie stammt; leer bei 0.
321 */
322
323 /**
324 * Jüngste Änderung unterhalb eines Pfades – samt der Datei, die sie trägt.
325 *
326 * Die Datei mitzuführen kostet nichts und macht aus „irgendetwas ist neuer“
327 * eine nachprüfbare Aussage: Wer die Meldung liest, weiß sofort, ob er neu
328 * bauen muss oder ob er selbst eine Datei angefasst hat.
329 *
330 * @param {string} pfad Datei oder Verzeichnis.
331 * @returns {Aenderung}
332 */
333 function juengsteAenderung(pfad) {
334 const eigenschaften = statSync(pfad);
335 if (!eigenschaften.isDirectory()) {
336 return { zeit: eigenschaften.mtimeMs, pfad };
337 }
338
339 /** @type {Aenderung} */ let juengste = { zeit: 0, pfad: '' };
340 for (const eintrag of readdirSync(pfad, { withFileTypes: true })) {
341 if (UEBERGANGEN.has(eintrag.name)) {
342 continue;
343 }
344 const kind = juengsteAenderung(join(pfad, eintrag.name));
345 if (kind.zeit > juengste.zeit) {
346 juengste = kind;
347 }
348 }
349 return juengste;
350 }
351
352 /**
353 * Jüngste Änderung über mehrere Pfade hinweg.
354 *
355 * @param {string[]} pfade
356 * @returns {Aenderung}
357 */
358 function juengsteUeber(pfade) {
359 /** @type {Aenderung} */ let juengste = { zeit: 0, pfad: '' };
360 for (const pfad of pfade) {
361 const kandidat = juengsteAenderung(pfad);
362 if (kandidat.zeit > juengste.zeit) {
363 juengste = kandidat;
364 }
365 }
366 return juengste;
367 }
368
369 /**
370 * Pfad, wie ihn ein Mensch im Projekt sucht: relativ zur Projektwurzel.
371 *
372 * @param {string} appWurzel
373 * @param {string} pfad
374 * @returns {string}
375 */
376 function lesbar(appWurzel, pfad) {
377 return relative(dirname(appWurzel), pfad).replaceAll('\\', '/');
378 }
379
380 /**
381 * @typedef {object} Paketstand
382 * @property {boolean} vorhanden Ob überhaupt ein gepacktes Paket dasteht.
383 * @property {boolean} veraltet Ob es älter ist als das, was es ausliefert.
384 * @property {number} alterSekunden Um wie viel es zurückliegt; 0, wenn aktuell.
385 * @property {'quelltext' | 'inhalte' | 'beides' | null} ursache
386 * Was weitergewandert ist. `null`, solange nichts veraltet ist.
387 * @property {string} juengsteQuelle
388 * Die Datei, die den Ausschlag gibt, relativ zur Projektwurzel; leer, wenn
389 * das Paket aktuell ist oder fehlt.
390 * @property {string} grund Ein Satz, der den Zustand benennt.
391 */
392
393 /**
394 * Vergleicht das gepackte Paket mit allem, was es ausliefert.
395 *
396 * @param {string} appWurzel Das Verzeichnis `app/`.
397 * @returns {Paketstand}
398 */
399 export function paketstand(appWurzel) {
400 const exe = join(appWurzel, 'release', 'win-unpacked', 'Waffensachkunde Lernsoftware.exe');
401
402 if (!existsSync(exe)) {
403 return {
404 vorhanden: false,
405 veraltet: false,
406 alterSekunden: 0,
407 ursache: null,
408 juengsteQuelle: '',
409 grund:
410 'Kein gepacktes Paket vorhanden. Die Prüfungen gegen das gebaute Paket ' +
411 'wurden übersprungen – zuerst `npm run dist:win` ausführen.',
412 };
413 }
414
415 const quellen = ausgelieferteQuellen(appWurzel);
416 const quelltext = juengsteUeber(quellen.quelltext);
417 const inhalte = juengsteUeber(quellen.inhalte);
418
419 const gepacktAm = statSync(exe).mtimeMs;
420 const grenze = gepacktAm + TOLERANZ_MS;
421 const quelltextNeuer = quelltext.zeit > grenze;
422 const inhalteNeuer = inhalte.zeit > grenze;
423
424 if (!quelltextNeuer && !inhalteNeuer) {
425 return {
426 vorhanden: true,
427 veraltet: false,
428 alterSekunden: 0,
429 ursache: null,
430 juengsteQuelle: '',
431 grund: 'Das gepackte Paket ist so jung wie Quelltext und mitgelieferte Inhalte.',
432 };
433 }
434
435 const juengste = quelltext.zeit >= inhalte.zeit ? quelltext : inhalte;
436 const alterSekunden = Math.round((juengste.zeit - gepacktAm) / 1000);
437 const quelle = lesbar(appWurzel, juengste.pfad);
438
439 /* Der Grundtext sagt weiterhin, WARUM übersprungen wird – und jetzt auch,
440 WAS weitergewandert ist. Der Unterschied ist keine Feinheit: „Quelltext“
441 heißt neu bauen, „Inhalte“ heißt, dass eine Änderung an content/ noch
442 nicht im Paket steckt. Beim ersten Auftreten dieses Falls stand in der
443 Meldung nichts davon, weil der Vergleich content/ gar nicht ansah. */
444 /** @type {'quelltext' | 'inhalte' | 'beides'} */
445 let ursache = 'beides';
446 if (!inhalteNeuer) {
447 ursache = 'quelltext';
448 } else if (!quelltextNeuer) {
449 ursache = 'inhalte';
450 }
451
452 /* „das Programm“ statt „der Quelltext“, seit auch das Verzeichnis mit dem
453 Programmsymbol mitzählt: Ein Symbol ist kein Quelltext, steckt aber
454 genauso in der gebauten exe. Die Meldung nennt ohnehin die Datei, die den
455 Ausschlag gibt – daran sieht man sofort, welcher Fall vorliegt. */
456 const benennung = {
457 quelltext: `das Programm (${quelle})`,
458 inhalte: `die mitgelieferten Inhalte (${quelle})`,
459 beides: `Programm und mitgelieferte Inhalte (jüngste Änderung: ${quelle})`,
460 }[ursache];
461
462 return {
463 vorhanden: true,
464 veraltet: true,
465 alterSekunden,
466 ursache,
467 juengsteQuelle: quelle,
468 grund:
469 `Das gepackte Paket ist ${String(alterSekunden)} s älter als ${benennung}. ` +
470 'Die Prüfungen dagegen würden die vorige Fassung messen und grün melden; ' +
471 'sie wurden deshalb übersprungen – zuerst `npm run dist:win` ausführen.',
472 };
473 }