waffensachkunde

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

/ app tools paketstand.mjs

19,6 KB Rohdatei
app/tools/paketstand.mjs — 554 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 * Liest die `files:`-Listen der Plattformblöcke `win:` und `mac:`.
190 *
191 * ## Warum das eine eigene Auskunft ist
192 *
193 * electron-builder behandelt eine plattformeigene `files:`-Liste **nicht** als
194 * Ergänzung der obersten, sondern als deren Ersatz: Die oberste wird zu einem
195 * zweiten Kopierauftrag, die plattformeigene wird der Hauptvergleich. Besteht
196 * sie ausschließlich aus Ausschlussmustern, stellt `getMainFileMatchers` ihr
197 * `**\/*` voran (app-builder-lib/out/fileMatcher.js, `containsOnlyIgnore`) –
198 * und damit landet **alles** im Paket, was die oberste Liste gerade
199 * heraushalten sollte.
200 *
201 * Genau das ist bis Fassung 0.27.2 passiert: Beide Plattformblöcke enthielten
202 * nur je ein `!`-Muster für die fremden Fertigteile von better-sqlite3. Das
203 * ausgelieferte `app.asar` der Fassung 0.27.2 trug deshalb `src/` (200
204 * Dateien), `tests/` (81), `e2e/` (26), `tools/` (3), sämtliche
205 * Konfigurationsdateien und den HTML-Abdeckungsbericht des letzten
206 * Gate-Laufs (`coverage/`, 217 Dateien, 8.967.480 Byte) – während zwei Zeilen
207 * über der obersten Liste steht: „Nur die gebauten Bundles paketieren – die
208 * Quellen bleiben draußen.“
209 *
210 * Gelesen werden auch die `!`-Muster: Ob eine Liste **nur** aus ihnen besteht,
211 * ist die Bedingung, an der die Falle hängt.
212 *
213 * @param {string} text Inhalt von `electron-builder.yml`.
214 * @returns {Record<string, string[]>} Je Plattformschlüssel die Muster ihrer
215 * `files:`-Liste, in der Schreibweise der Datei. Plattformen ohne eigene
216 * Liste fehlen.
217 */
218 export function plattformDateilisten(text) {
219 const PLATTFORMEN = new Set(['win', 'mac', 'linux']);
220 /** @type {Record<string, string[]>} */ const listen = {};
221
222 /** @type {string | null} */ let plattform = null;
223 // Einzug der `files:`-Zeile im laufenden Plattformblock; -1 = nicht darin.
224 let listenEinzug = -1;
225
226 for (const zeile of text.split(/\r?\n/u)) {
227 const ohneEinzug = zeile.trimStart();
228 if (ohneEinzug === '' || ohneEinzug.startsWith('#')) {
229 continue;
230 }
231 const einzug = zeile.length - ohneEinzug.length;
232
233 if (einzug === 0) {
234 const name = /^([A-Za-z][A-Za-z0-9_]*):/u.exec(ohneEinzug)?.[1];
235 plattform = name !== undefined && PLATTFORMEN.has(name) ? name : null;
236 listenEinzug = -1;
237 continue;
238 }
239
240 if (plattform === null) {
241 continue;
242 }
243
244 if (listenEinzug < 0) {
245 if (/^files:\s*$/u.test(ohneEinzug)) {
246 listenEinzug = einzug;
247 listen[plattform] = [];
248 }
249 continue;
250 }
251
252 if (einzug <= listenEinzug) {
253 // Der nächste Schlüssel des Plattformblocks beendet die Liste.
254 listenEinzug = -1;
255 continue;
256 }
257
258 if (!ohneEinzug.startsWith('- ')) {
259 throw new Error(
260 `electron-builder.yml: unverstandene Zeile in ${plattform}.files: ${ohneEinzug}`,
261 );
262 }
263 listen[plattform]?.push(entwerten(ohneEinzug.slice(2).trim()));
264 }
265
266 return listen;
267 }
268
269 /**
270 * Macht aus einem Auslieferungsmuster den Pfad, dessen Alter zählt.
271 *
272 * `out/**\/*` wird zu `src`, `../content/katalog` zum Verzeichnis selbst.
273 *
274 * @param {string} appWurzel
275 * @param {string} muster
276 * @returns {string} Absoluter Pfad.
277 */
278 function pfadZuMuster(appWurzel, muster) {
279 const teile = muster.split('/');
280 /** @type {string[]} */ const fest = [];
281 for (const teil of teile) {
282 if (GLOB_ZEICHEN.test(teil)) {
283 break;
284 }
285 fest.push(teil);
286 }
287
288 if (fest.length === 0) {
289 throw new Error(
290 `electron-builder.yml: Muster ${muster} beginnt mit einem Platzhalter. ` +
291 'Es ließe sich nur auf das ganze Projektverzeichnis auflösen und ' +
292 'wäre als Altersvergleich wertlos.',
293 );
294 }
295
296 const ersatz = ERSATZ_FUER_ERZEUGNIS.get(fest[0]);
297 if (ersatz !== undefined) {
298 fest.splice(0, fest.length, ersatz);
299 }
300
301 const pfad = resolve(appWurzel, ...fest);
302 if (!existsSync(pfad)) {
303 throw new Error(
304 `electron-builder.yml verweist auf ${muster}, unter ${pfad} liegt aber nichts. ` +
305 'Entweder ist der Bauplan veraltet oder der Altersvergleich in ' +
306 'tools/paketstand.mjs löst ihn falsch auf – beides muss auffallen.',
307 );
308 }
309 return pfad;
310 }
311
312 /**
313 * Das Verzeichnis, aus dem electron-builder die Bauzutaten holt.
314 *
315 * Das Programmsymbol liegt weder unter `files:` noch unter `extraResources:`
316 * – es steht in `directories.buildResources` und wird von electron-builder
317 * in die exe und in das Installationsprogramm eingebaut. Genau deshalb fehlte
318 * es hier zuerst: Wer das Symbol änderte, bekam ein Paket gemeldet, das „so
319 * jung wie der Quelltext“ sei, und trug das alte Bild weiter. Dieselbe
320 * Fehlerklasse wie damals bei `content/`, nur eine Tür weiter.
321 *
322 * Gelesen wird auch das aus dem Bauplan statt es hier aufzuschreiben. Zwei
323 * Listen desselben Sachverhalts laufen beim nächsten Umbau auseinander.
324 *
325 * @param {string} text Inhalt von `electron-builder.yml`.
326 * @returns {string | null} Der Verzeichnisname, oder `null`, wenn keiner steht.
327 */
328 export function bauressourcenAusBauplan(text) {
329 let imBlock = false;
330 for (const zeile of text.split(/\r?\n/u)) {
331 const ohneEinzug = zeile.trimStart();
332 if (ohneEinzug === '' || ohneEinzug.startsWith('#')) {
333 continue;
334 }
335 if (zeile.length === ohneEinzug.length) {
336 // Oberste Ebene: ein neuer Schlüssel beendet den laufenden Block.
337 imBlock = /^directories:/u.test(ohneEinzug);
338 continue;
339 }
340 if (imBlock) {
341 const treffer = /^buildResources:\s*(\S+)/u.exec(ohneEinzug);
342 if (treffer) {
343 return treffer[1];
344 }
345 }
346 }
347 return null;
348 }
349
350 /**
351 * Alle Pfade, deren Alter über die Aktualität des Pakets entscheidet.
352 *
353 * Eigene Funktion und nicht in `paketstand()` versteckt, weil sie für sich
354 * prüfbar sein muss: Sie ist die Stelle, an der Bauplan und Vergleich
355 * auseinanderlaufen könnten. `paketstand()` beantwortet sie nicht, wenn gar
356 * kein Paket dasteht – ein Test, der nur über `paketstand()` ginge, prüfte
357 * die Auflösung an einem Arbeitsplatz ohne `release/` also gar nicht.
358 *
359 * @param {string} appWurzel Das Verzeichnis `app/`.
360 * @returns {{ quelltext: string[], inhalte: string[] }} Absolute Pfade.
361 */
362 export function ausgelieferteQuellen(appWurzel) {
363 const bauplan = join(appWurzel, 'electron-builder.yml');
364 if (!existsSync(bauplan)) {
365 throw new Error(
366 `Kein Bauplan unter ${bauplan}. Ohne ihn ist nicht zu sagen, was das Paket ` +
367 'ausliefert – und ein Altersvergleich, der das nicht weiß, prüft nichts.',
368 );
369 }
370
371 const text = readFileSync(bauplan, 'utf8');
372 const muster = musterAusBauplan(text);
373 const quelltext = muster.programm.map((m) => pfadZuMuster(appWurzel, m));
374
375 /*
376 Der Bauplan selbst gehört dazu.
377
378 Er sagt nicht nur, **was** ausgeliefert wird, sondern bestimmt auch, **wie**:
379 Ziele, Dateilisten, ASAR-Entpackung, die Angaben des Store-Pakets. Wer
380 daran etwas ändert, hat ein anderes Paket vor sich – bis Fassung 0.24.1
381 galt der alte Beleg trotzdem weiter, weil die Wache jeden Pfad ansah,
382 den der Bauplan nennt, nur nicht ihn selbst. Derselbe blinde Fleck wie
383 bei den Bauressourcen (7.23), eine Ebene höher.
384 */
385 quelltext.push(bauplan);
386
387 const bauressourcen = bauressourcenAusBauplan(text);
388 if (bauressourcen !== null) {
389 quelltext.push(pfadZuMuster(appWurzel, bauressourcen));
390 }
391
392 return {
393 quelltext,
394 inhalte: muster.inhalte.map((m) => pfadZuMuster(appWurzel, m)),
395 };
396 }
397
398 /**
399 * @typedef {object} Aenderung
400 * @property {number} zeit Änderungszeit in Millisekunden; 0, wenn nichts da war.
401 * @property {string} pfad Die Datei, von der sie stammt; leer bei 0.
402 */
403
404 /**
405 * Jüngste Änderung unterhalb eines Pfades – samt der Datei, die sie trägt.
406 *
407 * Die Datei mitzuführen kostet nichts und macht aus „irgendetwas ist neuer“
408 * eine nachprüfbare Aussage: Wer die Meldung liest, weiß sofort, ob er neu
409 * bauen muss oder ob er selbst eine Datei angefasst hat.
410 *
411 * @param {string} pfad Datei oder Verzeichnis.
412 * @returns {Aenderung}
413 */
414 function juengsteAenderung(pfad) {
415 const eigenschaften = statSync(pfad);
416 if (!eigenschaften.isDirectory()) {
417 return { zeit: eigenschaften.mtimeMs, pfad };
418 }
419
420 /** @type {Aenderung} */ let juengste = { zeit: 0, pfad: '' };
421 for (const eintrag of readdirSync(pfad, { withFileTypes: true })) {
422 if (UEBERGANGEN.has(eintrag.name)) {
423 continue;
424 }
425 const kind = juengsteAenderung(join(pfad, eintrag.name));
426 if (kind.zeit > juengste.zeit) {
427 juengste = kind;
428 }
429 }
430 return juengste;
431 }
432
433 /**
434 * Jüngste Änderung über mehrere Pfade hinweg.
435 *
436 * @param {string[]} pfade
437 * @returns {Aenderung}
438 */
439 function juengsteUeber(pfade) {
440 /** @type {Aenderung} */ let juengste = { zeit: 0, pfad: '' };
441 for (const pfad of pfade) {
442 const kandidat = juengsteAenderung(pfad);
443 if (kandidat.zeit > juengste.zeit) {
444 juengste = kandidat;
445 }
446 }
447 return juengste;
448 }
449
450 /**
451 * Pfad, wie ihn ein Mensch im Projekt sucht: relativ zur Projektwurzel.
452 *
453 * @param {string} appWurzel
454 * @param {string} pfad
455 * @returns {string}
456 */
457 function lesbar(appWurzel, pfad) {
458 return relative(dirname(appWurzel), pfad).replaceAll('\\', '/');
459 }
460
461 /**
462 * @typedef {object} Paketstand
463 * @property {boolean} vorhanden Ob überhaupt ein gepacktes Paket dasteht.
464 * @property {boolean} veraltet Ob es älter ist als das, was es ausliefert.
465 * @property {number} alterSekunden Um wie viel es zurückliegt; 0, wenn aktuell.
466 * @property {'quelltext' | 'inhalte' | 'beides' | null} ursache
467 * Was weitergewandert ist. `null`, solange nichts veraltet ist.
468 * @property {string} juengsteQuelle
469 * Die Datei, die den Ausschlag gibt, relativ zur Projektwurzel; leer, wenn
470 * das Paket aktuell ist oder fehlt.
471 * @property {string} grund Ein Satz, der den Zustand benennt.
472 */
473
474 /**
475 * Vergleicht das gepackte Paket mit allem, was es ausliefert.
476 *
477 * @param {string} appWurzel Das Verzeichnis `app/`.
478 * @returns {Paketstand}
479 */
480 export function paketstand(appWurzel) {
481 const exe = join(appWurzel, 'release', 'win-unpacked', 'Waffensachkunde Lernsoftware.exe');
482
483 if (!existsSync(exe)) {
484 return {
485 vorhanden: false,
486 veraltet: false,
487 alterSekunden: 0,
488 ursache: null,
489 juengsteQuelle: '',
490 grund:
491 'Kein gepacktes Paket vorhanden. Die Prüfungen gegen das gebaute Paket ' +
492 'wurden übersprungen – zuerst `npm run dist:win` ausführen.',
493 };
494 }
495
496 const quellen = ausgelieferteQuellen(appWurzel);
497 const quelltext = juengsteUeber(quellen.quelltext);
498 const inhalte = juengsteUeber(quellen.inhalte);
499
500 const gepacktAm = statSync(exe).mtimeMs;
501 const grenze = gepacktAm + TOLERANZ_MS;
502 const quelltextNeuer = quelltext.zeit > grenze;
503 const inhalteNeuer = inhalte.zeit > grenze;
504
505 if (!quelltextNeuer && !inhalteNeuer) {
506 return {
507 vorhanden: true,
508 veraltet: false,
509 alterSekunden: 0,
510 ursache: null,
511 juengsteQuelle: '',
512 grund: 'Das gepackte Paket ist so jung wie Quelltext und mitgelieferte Inhalte.',
513 };
514 }
515
516 const juengste = quelltext.zeit >= inhalte.zeit ? quelltext : inhalte;
517 const alterSekunden = Math.round((juengste.zeit - gepacktAm) / 1000);
518 const quelle = lesbar(appWurzel, juengste.pfad);
519
520 /* Der Grundtext sagt weiterhin, WARUM übersprungen wird – und jetzt auch,
521 WAS weitergewandert ist. Der Unterschied ist keine Feinheit: „Quelltext“
522 heißt neu bauen, „Inhalte“ heißt, dass eine Änderung an content/ noch
523 nicht im Paket steckt. Beim ersten Auftreten dieses Falls stand in der
524 Meldung nichts davon, weil der Vergleich content/ gar nicht ansah. */
525 /** @type {'quelltext' | 'inhalte' | 'beides'} */
526 let ursache = 'beides';
527 if (!inhalteNeuer) {
528 ursache = 'quelltext';
529 } else if (!quelltextNeuer) {
530 ursache = 'inhalte';
531 }
532
533 /* „das Programm“ statt „der Quelltext“, seit auch das Verzeichnis mit dem
534 Programmsymbol mitzählt: Ein Symbol ist kein Quelltext, steckt aber
535 genauso in der gebauten exe. Die Meldung nennt ohnehin die Datei, die den
536 Ausschlag gibt – daran sieht man sofort, welcher Fall vorliegt. */
537 const benennung = {
538 quelltext: `das Programm (${quelle})`,
539 inhalte: `die mitgelieferten Inhalte (${quelle})`,
540 beides: `Programm und mitgelieferte Inhalte (jüngste Änderung: ${quelle})`,
541 }[ursache];
542
543 return {
544 vorhanden: true,
545 veraltet: true,
546 alterSekunden,
547 ursache,
548 juengsteQuelle: quelle,
549 grund:
550 `Das gepackte Paket ist ${String(alterSekunden)} s älter als ${benennung}. ` +
551 'Die Prüfungen dagegen würden die vorige Fassung messen und grün melden; ' +
552 'sie wurden deshalb übersprungen – zuerst `npm run dist:win` ausführen.',
553 };
554 }