#!/usr/bin/env node /** * Validate the current release candidate with fresh suite and model-eval runs. * Machine-readable smoke evidence, content hashes, version agreement, style, * and a clean worktree remain checked. Development screenshots, recordings, * planning notes, and the former standalone repository handover are not * release prerequisites. * * Usage: node tools/release-gate.mjs [--out verification/w6/release.json] */ import { createHash } from 'node:crypto'; import { execFile, spawn } from 'node:child_process'; import { existsSync, statSync } from 'node:fs'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { ATTRIBUTION_PATTERNS, LONG_DASH, NAME_PATTERN, RULE_FILE_PATTERN, WATCHED_NAMES, } from './lib/forbidden-names.mjs'; const execFileAsync = promisify(execFile); const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const DEFAULT_OUT = join(ROOT, 'verification', 'w6', 'release.json'); const log = (...parts) => console.log('[release-gate]', ...parts); /** * Die Fassung steht NICHT hier. * * Bis zum 2026-08-29 stand an dieser Stelle `const EXPECTED_VERSION = '1.0.0'`, * und das Gate verglich package.json gegen diese Zahl. Das war eine zweite * Stelle, an der die Fassung dieses Produkts behauptet wurde, und sie wurde in * dem Augenblick falsch, in dem der Eigentuemer die erste aenderte (v0.0.1, * "CodeAtlas ist nicht v1.0.0"). Ein Gate, das dem Eigentuemer seine * Versionsnummer vorschreibt, prueft nicht das Produkt, sondern seine eigene * Erinnerung. * * Die Zahl hat genau einen Ort: package.json. Was ein Gate pruefen kann und * soll, ist die UEBEREINSTIMMUNG: zeigt die gebaute Oberflaeche dieselbe * Fassung, die dort steht? Das ist eine Frage an zwei unabhaengig entstandene * Angaben (die Datei und der im Browser gemessene Chip aus * verification/w6/airgap.json), und genau diese Frage haette den Widerspruch * gefunden, den die feste Zahl verursacht hat. */ const AIRGAP_ARTIFACT = 'verification/w6/airgap.json'; /** * Historische Release-Gate-Flaeche fuer die Diagnose im Bericht. * * Ein Pfad ausserhalb dieser Liste bleibt im Bericht sichtbar. Der heutige * Release-Contract ist strenger: schon jeder Dirty-Pfad macht das Gate rot. */ const RELEASE_GATE_SURFACE = [ 'tools/record-demo.mjs', 'tools/release-gate.mjs', 'tools/lib/', 'verification/w6/', 'tests/scaffold/w6b.test.mjs', 'package.json', /* * Die Erweiterung des Nachtrags vom 2026-08-29: die Code-Fixes zu den * Befunden des unabhaengigen Audits laufen in DIESEM Zyklus und damit * ueber diese Aenderungsflaeche. Sie stehen einzeln da und nicht als * `src/`, weil eine Flaeche, die den ganzen Quelltext freigibt, keine * Flaeche mehr ist. */ 'tools/eval-check.mjs', 'tools/eval-llm.mjs', 'src/twin/strings.ts', 'src/twin/twin-view-model.test.ts', 'src/compiler/question-classifier.ts', 'src/compiler/question-classifier.test.ts', 'src/app/AtlasChrome.tsx', 'src/app/keyboard.ts', 'src/app/keyboard.test.ts', 'src/App.tsx', 'src/i18n/messages.ts', 'src/styles/terminal.css', 'src/tours/TourCard.tsx', 'src/tours/TourCard.test.tsx', 'src/tours/tour-player.ts', 'src/tours/tour-player.test.ts', 'src/why/why-model.ts', 'src/traces/bug-wizard-strings.ts', 'src/impact/impact-strings.ts', 'src/llm/strings.ts', 'src/llm/strings.test.ts', 'llm/start.sh', 'tools/smoke-w6-full.mjs', 'tools/smoke-w5a.mjs', 'tools/smoke-w5b.mjs', 'docs/adr/0001-modellwahl.md', 'THIRD_PARTY.md', ]; /** * Woran eine Zeile erkennbar von der Regel handelt statt eine Urheberschaft zu * behaupten. Wortgleich mit tools/style-gate.mjs. */ const RULE_SENTENCE = /\b(keine|kein|nicht|no|nie|never|verboten|forbidden|untersagt)\b|attribution|treffer/i; /** * Berichte, die der Stilscan nicht als Quelltext bewertet. Der Grund steht im * Kopf dieser Datei. */ const REPORTS_NOT_SCANNED = [ 'verification/w6/stylegate.json', 'verification/w6/evalcheck.json', 'verification/w6/release.json', ]; /** Die zwei und nur zwei Dateien, die dieser Gate-Lauf selbst erzeugt. */ const GENERATED_PROOF_FILES = [ 'verification/w6/evalcheck.json', 'verification/w6/release.json', ]; /** * Jede verification-Datei aller Phasen, ausgeschrieben. * * verification/w6/release.json fehlt hier mit Absicht: das ist die Datei, die * dieser Lauf schreibt, also sein Ergebnis und nicht sein Gegenstand. */ const EXPECTED_ARTIFACTS = { w0: [ 'verification/w0/spike.json', ], w1: [ 'verification/w1/netdeny.json', 'verification/w1/provider.json', 'verification/w1/scaffold.json', ], w2: [ 'verification/w2/netdeny-w2b.json', 'verification/w2/netdeny.json', 'verification/w2/reader.json', 'verification/w2/twin.json', ], w3: [ 'verification/w3/galaxy.json', 'verification/w3/netdeny.json', ], w4: [ 'verification/w4/bugwizard.json', 'verification/w4/coverage.json', 'verification/w4/flow.json', 'verification/w4/hierarchy.json', 'verification/w4/impact.json', 'verification/w4/netdeny-w4b.json', 'verification/w4/netdeny-w4c.json', 'verification/w4/netdeny-w4d.json', 'verification/w4/netdeny-w4e.json', 'verification/w4/netdeny.json', 'verification/w4/tours.json', ], w5: [ 'verification/w5/chat.json', 'verification/w5/eval.json', 'verification/w5/flowfix.json', 'verification/w5/models.json', 'verification/w5/netdeny-w5a.json', 'verification/w5/netdeny-w5b.json', 'verification/w5/netdeny-w5c.json', 'verification/w5/sidecar.json', ], w6: [ 'verification/w6/airgap.json', 'verification/w6/budgets.json', 'verification/w6/evalcheck.json', 'verification/w6/freshclone.json', 'verification/w6/netdeny.json', 'verification/w6/stylegate.json', ], 'w6-walk': [ ], 'w6-demo': [ 'verification/w6/demo/demo.json', ], 'w6-audit': [ 'verification/w6/audit.json', ], /* * W7a, nachgetragen am 2026-08-29. Die Liste stammte bis hierher aus W6b und * kannte die Artefakte des laufenden Zyklus nicht; sie waeren beim naechsten * Lauf als `unlistedArtifacts` erschienen, also als Dateien, die im Baum * liegen und von niemandem erwartet werden. Genau dagegen steht der zweite * Absatz im Kopf dieser Datei: was fehlen KANN, muss benannt sein. */ w7: [ 'verification/w7/help.json', 'verification/w7/netdeny.json', 'verification/w7/promises.json', ], /* Jede Erweiterung nach W7 steht ebenfalls statisch hier. Der Lauf liest * keine Soll-Liste aus Git: Git darf nur die tatsaechlich vorgefundenen * Dateien gegen diese ausgeschriebene Inventur vergleichen. */ 'w7-current': [ 'verification/w7/chat.json', 'verification/w7/netdeny-w7b.json', 'verification/w7/netdeny-w7c.json', 'verification/w7/search.json', ], w8: [ 'verification/w8/layout.json', 'verification/w8/netdeny.json', ], w8b: [ 'verification/w8b/netdeny.json', 'verification/w8b/ux.json', ], w8c: [ 'verification/w8c/netdeny.json', 'verification/w8c/pseudocode.json', ], w9: [ 'verification/w9/edges.json', 'verification/w9/netdeny-w9.json', ], w10: [ 'verification/w10/models.json', 'verification/w10/netdeny.json', ], w10b: [ 'verification/w10b/fixes.json', 'verification/w10b/netdeny.json', ], w11: [ 'verification/w11/agents.json', 'verification/w11/motion.json', 'verification/w11/netdeny-w11b.json', 'verification/w11/netdeny.json', ], w12: [ 'verification/w12/buttons.json', 'verification/w12/netdeny.json', ], w12a: [ 'verification/w12a/command-overlay.json', 'verification/w12a/netdeny.json', ], w13: [ 'verification/w13/netdeny.json', 'verification/w13/reader.json', ], w14: [ 'verification/w14/before-levels.json.gz.b64', 'verification/w14/netdeny.json', 'verification/w14/symbols.json', ], w15: [ 'verification/w15/hybrid.json', 'verification/w15/netdeny.json', ], }; /** Ein Kommando fahren und alles einsammeln, was es sagt. */ function run(command, args, options = {}) { const started = Date.now(); return new Promise((done) => { const child = spawn(command, args, { cwd: options.cwd ?? ROOT, env: { ...process.env, NO_UPDATE_NOTIFIER: '1', npm_config_update_notifier: 'false', npm_config_audit: 'false', npm_config_fund: 'false', CI: '1', }, stdio: ['ignore', 'pipe', 'pipe'], }); let out = ''; child.stdout.on('data', (chunk) => { out += chunk.toString(); }); child.stderr.on('data', (chunk) => { out += chunk.toString(); }); child.on('error', (error) => done({ code: 127, out: out + error.message, ms: Date.now() - started })); child.on('close', (code) => done({ code: code ?? 1, out, ms: Date.now() - started })); }); } const tail = (text, lines = 12) => text.trim().split('\n').slice(-lines).map((line) => line.trimEnd()); async function git(args) { const { stdout } = await execFileAsync('git', args, { cwd: ROOT, maxBuffer: 32 * 1024 * 1024 }); return stdout; } /** Alles, was in dieses Repository gehoert: verfolgt plus neu und nicht ignoriert. */ async function repositoryFiles() { const listed = async (args) => (await git(args)).split('\n').map((line) => line.trim()).filter(Boolean); const tracked = await listed(['ls-files']); const untracked = await listed(['ls-files', '--others', '--exclude-standard']); return [...new Set([...tracked, ...untracked])].sort(); } /** Ein Text, oder null, wenn die Datei binaer ist oder nicht gelesen werden kann. */ async function textOf(relativePath) { const absolute = join(ROOT, relativePath); if (!existsSync(absolute)) { return null; } try { if (statSync(absolute).isDirectory()) { return null; } const buffer = await readFile(absolute); if (buffer.includes(0)) { return null; } return buffer.toString('utf8'); } catch { return null; } } /** * Lange Striche und Attributionen im ganzen Baum, mit denselben Mustern wie das * Stil-Gate. Warum hier noch einmal gescannt wird, steht im Kopf dieser Datei. */ async function scanStyle(files) { const dashHits = []; const dashExceptions = []; const attributionHits = []; const attributionExceptions = []; const patternHits = []; let textFiles = 0; let binaryFiles = 0; const quoteExempt = (path) => path.startsWith('verification/') && path.endsWith('.json'); for (const path of files) { if (REPORTS_NOT_SCANNED.includes(path)) { continue; } const text = await textOf(path); if (text === null) { binaryFiles += 1; continue; } textFiles += 1; const lines = text.split('\n'); for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; const where = { file: path, line: index + 1, text: line.trim().slice(0, 160) }; if (LONG_DASH.test(line)) { if (quoteExempt(path)) { dashExceptions.push(where); } else { dashHits.push(where); } } for (const { name, pattern } of ATTRIBUTION_PATTERNS) { if (pattern.test(line)) { patternHits.push({ ...where, pattern: name }); } } if (NAME_PATTERN.test(line)) { const claimsAuthorship = ATTRIBUTION_PATTERNS.some((entry) => entry.pattern.test(line)); const namesTheRule = RULE_SENTENCE.test(line) || RULE_FILE_PATTERN.test(line); if (!claimsAuthorship && namesTheRule) { attributionExceptions.push(where); } else { attributionHits.push(where); } } } } return { dashHits, dashExceptions, attributionHits, attributionExceptions, patternHits, textFiles, binaryFiles, skipped: REPORTS_NOT_SCANNED, }; } /** * Wo ein datiertes Protokoll liegt und keine lebende Angabe. * * verification/ ist das Aktenschrank dieses Projekts: jede Datei darin haelt * fest, was an einem Tag gemessen wurde. Sie beschreibt einen Stand, statt * einen zu behaupten, und darf darum veralten. Die Begruendung in voller Laenge * steht im Kopf dieser Datei. */ const PROTOCOL_PREFIX = 'verification/'; /** * Zahlen ueber Tests, die die LEBENDE Doku behauptet. * * Gesucht wird eine Zahl unmittelbar vor einem Testwort, in allen verfolgten * Markdown-Dateien dieses Projekts (der cbm-Klon ist fremder Text und * gitignoriert), ausser in den datierten Protokollen unter verification/. * Findet der Scan nichts, ist das kein Mangel: dann behauptet die lebende Doku * keine Zahl, und die gemessene steht hier als einzige. * * Die uebergangenen Dateien kommen mit zurueck und in den Bericht. Eine * Ausnahme, die man dem Bericht nicht ansieht, ist keine Ausnahme, sondern ein * blinder Fleck. */ async function documentedTestClaims() { const claims = []; const skippedProtocols = []; const files = (await git(['ls-files', '*.md'])).split('\n') .map((line) => line.trim()) .filter(Boolean); const pattern = /(\d+)\s+(?:eingefrorene\s+|gruene\s+)?(?:Abnahme-?|Unit-?|Scaffold-?|Akzeptanz-?)?[Tt]ests?\b/; for (const file of files) { const text = await textOf(file); if (text === null) { continue; } const isProtocol = file.startsWith(PROTOCOL_PREFIX); const lines = text.split('\n'); for (let index = 0; index < lines.length; index += 1) { const match = pattern.exec(lines[index]); if (match === null) { continue; } const where = { file, line: index + 1, claimed: Number(match[1]), text: lines[index].trim().slice(0, 160), }; if (isProtocol) { skippedProtocols.push(where); } else { claims.push(where); } } } return { claims, skippedProtocols }; } async function main() { const totalStarted = Date.now(); const args = process.argv.slice(2); let out = DEFAULT_OUT; for (let i = 0; i < args.length; i += 1) { if (args[i] === '--out') { out = resolve(args[i + 1]); i += 1; } } const failures = []; const extras = {}; /* * ------------------------------------ 0a. Der Arbeitsbaum, als Erstes * * Vor allem anderen, weil dieser Lauf selbst schreibt: die Eval-Regression * gleich unten legt verification/w6/evalcheck.json ab, und wer den Baum * danach misst, misst seine eigene Spur. `cleanTree` soll den Baum * beschreiben, den dieses Gate VORGEFUNDEN hat, denn genau das fragt der * Contract ("sauberer Baum nach dem Commit"). Gemessen wird darum hier, in * der ersten Zeile, und alles, was der Lauf danach schreibt, kann diese * Zahl nicht mehr faerben. */ const status = (await git(['status', '--porcelain=v1', '-uall'])) .split('\n').map((line) => line.trimEnd()).filter(Boolean); const dirtyPaths = status.map((line) => ({ code: line.slice(0, 2).trim(), path: line.slice(3) })); const unexpectedDirtyPaths = dirtyPaths .filter((entry) => !RELEASE_GATE_SURFACE.some((prefix) => entry.path.startsWith(prefix))) .map((entry) => `${entry.code} ${entry.path}`); const cleanTree = dirtyPaths.length === 0; log(`Arbeitsbaum beim Start: ${dirtyPaths.length} Pfade offen, davon ` + `${unexpectedDirtyPaths.length} ausserhalb der Aenderungsflaeche dieses Zyklus`); /* * ----------------------------------------- 0b. Die Eval als Regression * * Weit vorne, aus zwei Gruenden. Erstens dauert sie ein paar Minuten, und * ein Gate, das erst nach zehn billigen Pruefungen an der teuren scheitert, * ist ein Gate, das man zehnmal umsonst gefahren hat. Zweitens schreibt * sie verification/w6/evalcheck.json, und die Artefaktliste unten zaehlt * genau diese Datei: liefe sie danach, meldete der erste Lauf auf einer * frischen Maschine ein fehlendes Artefakt, das er selbst gleich erzeugt. */ log('npm run eval:check'); const evalCheck = await run('npm', ['run', 'eval:check']); const evalCheckPass = evalCheck.code === 0; extras.evalCheck = { exit: evalCheck.code, ms: evalCheck.ms, out: tail(evalCheck.out, 16) }; const evalCheckFile = join(ROOT, 'verification', 'w6', 'evalcheck.json'); const evalcheck = { path: GENERATED_PROOF_FILES[0], sha256: '', generatedAt: '', }; if (existsSync(evalCheckFile)) { try { const raw = await readFile(evalCheckFile, 'utf8'); const parsed = JSON.parse(raw); evalcheck.sha256 = createHash('sha256').update(raw).digest('hex'); evalcheck.generatedAt = typeof parsed.generatedAt === 'string' ? parsed.generatedAt : ''; extras.evalCheck.comparisons = parsed.comparisons ?? []; extras.evalCheck.reasons = parsed.reasons ?? []; } catch (err) { extras.evalCheck.readError = err && err.message ? err.message : String(err); } } log(`Eval-Regression: exit ${evalCheck.code} nach ${evalCheck.ms} ms`); for (const entry of extras.evalCheck.comparisons ?? []) { log(` ${entry.name}: passRate ${entry.measuredPassRate} gegen aufgezeichnete ` + `${entry.recordedPassRate}, Zitattreue ${entry.measuredCitation}`); } const sourceHead = (await git(['rev-parse', 'HEAD'])).trim(); // ------------------------------------------------------ 2. Die Artefakte const missingArtifacts = []; const artifactList = []; for (const [phase, paths] of Object.entries(EXPECTED_ARTIFACTS)) { for (const path of paths) { const absolute = join(ROOT, path); const there = existsSync(absolute); const bytes = there ? statSync(absolute).size : 0; artifactList.push({ phase, path, exists: there, bytes }); if (!there || bytes === 0) { missingArtifacts.push(path); } } } const listedPaths = new Set(artifactList.map((entry) => entry.path)); const files = await repositoryFiles(); const unlistedArtifacts = files.filter( (path) => path.startsWith('verification/') && !/\.(?:png|jpe?g|webm|mp4|md)$/.test(path) && !listedPaths.has(path) && relative(ROOT, out) !== path, ); const allSmokeArtifactsPresent = missingArtifacts.length === 0 && unlistedArtifacts.length === 0; log(`Artefakte: ${artifactList.length} erwartet, ${missingArtifacts.length} fehlen, ` + `${unlistedArtifacts.length} liegen ungelistet daneben`); for (const path of missingArtifacts) { log(' fehlt:', path); } for (const path of unlistedArtifacts) { log(' ungelistet:', path); } // ------------------------------------------------------- 3. Der Stilscan const style = await scanStyle(files); const attributionHits = style.attributionHits.length + style.patternHits.length; const dashHitsOutsideDocumentedQuotes = style.dashHits.length; log(`Stil: ${attributionHits} Attribution (${style.attributionExceptions.length} Verbotssaetze ` + `ausgenommen), ${dashHitsOutsideDocumentedQuotes} lange Striche ausserhalb dokumentierter ` + `Zitate (${style.dashExceptions.length} dokumentiert)`); // 5. Der Arbeitsbaum steht schon gemessen da, ganz oben in 0a. Der Grund // steht dort: dieser Lauf schreibt selbst, und er soll seine eigene Spur // nicht als Befund melden. // ------------------------------------------------------- 6. Die Fassung // // Gefragt wird nicht "ist es die Zahl, die hier steht", sondern "sagen die // Datei und die gebaute Oberflaeche dasselbe". Fehlt das Beweisartefakt, // ist die Frage unbeantwortet und nicht beantwortet: dann faellt das Gate // mit dem Grund, statt eine Uebereinstimmung anzunehmen. const pkg = JSON.parse(await readFile(join(ROOT, 'package.json'), 'utf8')); const expectedChip = `v${pkg.version}`; const airgapFile = join(ROOT, AIRGAP_ARTIFACT); let shownChip = ''; let versionReadError = ''; if (existsSync(airgapFile)) { try { shownChip = JSON.parse(await readFile(airgapFile, 'utf8')).versionChipShown ?? ''; } catch (err) { versionReadError = err && err.message ? err.message : String(err); } } else { versionReadError = `${AIRGAP_ARTIFACT} fehlt`; } const versionMatches = shownChip === expectedChip; extras.version = { packageVersion: pkg.version, expectedChip, shownChip, measuredIn: AIRGAP_ARTIFACT, readError: versionReadError, }; log(`package.json version: ${pkg.version}; der gemessene Chip sagt ` + `"${shownChip || versionReadError}"`); // ------------------------------------------------------ 7. Die Unit-Suite log('npm run test:unit'); const unit = await run('npm', ['run', 'test:unit']); const unitPass = unit.code === 0; const unitTests = Number(/Tests\s+(\d+)\s+passed/.exec(unit.out)?.[1] ?? Number.NaN); const unitFiles = Number(/Test Files\s+(\d+)\s+passed/.exec(unit.out)?.[1] ?? Number.NaN); extras.unit = { exit: unit.code, ms: unit.ms, tests: unitTests, files: unitFiles, out: tail(unit.out, 12), }; log(`Unit-Suite: exit ${unit.code}, ${unitTests} Tests in ${unitFiles} Dateien nach ${unit.ms} ms`); // --------------------------------------------- 8. Die Zahlen in der Doku const { claims, skippedProtocols } = await documentedTestClaims(); log(`Testzahlen in der Doku: ${claims.length} lebende Angaben, ` + `${skippedProtocols.length} in datierten Protokollen uebergangen`); const buildReport = (fullSuitePass, scaffold, testCountSync, note) => ({ fullSuitePass, unitPass, evalCheckPass, allSmokeArtifactsPresent, testCountSync, attributionHits, dashHitsOutsideDocumentedQuotes, cleanTree, version: pkg.version, sourceHead, generatedProofFiles: GENERATED_PROOF_FILES, evalcheck, versionMatches, missingArtifacts, unlistedArtifacts, artifactsChecked: artifactList.length, unexpectedDirtyPaths, dirtyPaths, style: { attributionMatches: style.attributionHits, attributionPatternMatches: style.patternHits, documentedAttributionExceptions: style.attributionExceptions.length, dashMatches: style.dashHits, documentedQuoteExceptions: style.dashExceptions.length, /* * Die gesuchten Namen stehen hier NICHT ausgeschrieben, und das ist * kein Versteck, sondern die Vermeidung eines Fussangels: ein * Bericht, der sie ausschreibt, ist selbst eine Fundstelle fuer den * naechsten Scan. Genau das passiert dem Stil-Gate mit seinem * eigenen Bericht, und es loest es damit, dass es ihn nicht liest. * Aufgeloest nachzulesen sind sie dort (namesWatched) und in einer * Zeile mit `node -p`. */ namesWatchedCount: WATCHED_NAMES.length, namesWatchedSource: 'tools/lib/forbidden-names.mjs', reportsNotRead: REPORTS_NOT_SCANNED, textFiles: style.textFiles, binaryFiles: style.binaryFiles, }, artifacts: artifactList, scaffold, note, generatedAt: new Date().toISOString(), durationMs: Date.now() - totalStarted, extras, }); /* * 9. Die eigene Datei, VOR dem Abnahmelauf. Warum das kein Zirkelschluss * ist, steht im Kopf dieser Datei. */ const measuredCounts = { scaffoldTests: Number.NaN, scaffoldPass: Number.NaN, scaffoldFail: Number.NaN, unitTests, unitFiles, documentedClaims: claims, skippedProtocolClaims: skippedProtocols, protocolPrefix: PROTOCOL_PREFIX, mismatches: [], synced: true, method: 'Gemessen aus der TAP-Ausgabe von node --test tests/scaffold/ und aus der Zusammenfassung ' + 'von vitest, beide in diesem Lauf gefahren. Verglichen wird gegen jede Zahl, die eine ' + 'verfolgte Markdown-Datei ausserhalb von ' + PROTOCOL_PREFIX + ' unmittelbar vor einem ' + 'Testwort nennt. Die Zahlen in den datierten Protokollen darunter stehen als ' + 'skippedProtocolClaims daneben und werden nicht verglichen: sie beschreiben den Stand ' + 'ihres Tages und behaupten keinen von heute.', }; await mkdir(dirname(out), { recursive: true }); await writeFile( out, JSON.stringify(buildReport(true, { seeded: true }, measuredCounts, 'Vorabfassung: sie liegt hier, waehrend der Abnahmelauf sie liest. Die Endfassung ' + 'ueberschreibt sie mit dem gemessenen Ergebnis.'), null, 2) + '\n', 'utf8', ); /* Der oeffentliche Skriptweg ist Teil des Vertrags: aktuelle Node-Versionen * behandeln das Verzeichnis nicht mehr als Testdatei-Glob. */ log('npm run test'); const suite = await run('npm', ['run', 'test']); const fullSuitePass = suite.code === 0; const tapCount = (label) => Number( new RegExp(`^(?:#\\s*|ℹ\\s*)${label}\\s+(\\d+)\\s*$`, 'mi') .exec(suite.out)?.[1] ?? Number.NaN, ); const scaffoldTests = tapCount('tests'); const scaffoldPassCount = tapCount('pass'); const scaffoldFail = tapCount('fail'); const failedTests = [...new Set([ ...(suite.out.match(/^not ok \d+ - .*$/gm) ?? []) .map((line) => line.replace(/^not ok \d+ - /, '').trim()), ...(suite.out.match(/^✖\s+.+$/gm) ?? []) .map((line) => line.replace(/^✖\s+/, '').replace(/\s+\([\d.]+ms\)$/, '').trim()), ])]; log(`Abnahmesuite: exit ${suite.code}, ${scaffoldPassCount}/${scaffoldTests} gruen, ` + `${scaffoldFail} rot nach ${suite.ms} ms`); for (const name of failedTests) { log(' rot:', name); } measuredCounts.scaffoldTests = scaffoldTests; measuredCounts.scaffoldPass = scaffoldPassCount; measuredCounts.scaffoldFail = scaffoldFail; measuredCounts.mismatches = claims.filter( (claim) => claim.claimed !== scaffoldTests && claim.claimed !== unitTests, ); measuredCounts.synced = measuredCounts.mismatches.length === 0; if (claims.length === 0) { measuredCounts.note = 'Keine lebende verfolgte Markdown-Datei nennt eine Testzahl. Die gemessenen Zahlen ' + 'stehen darum hier als einzige Quelle; es gibt keine Angabe, von der sie abweichen ' + `koennten. ${skippedProtocols.length} Zahlen stehen in datierten Protokollen unter ` + `${PROTOCOL_PREFIX} und beschreiben ihren eigenen Tag.`; } const scaffold = { exit: suite.code, ms: suite.ms, tests: scaffoldTests, pass: scaffoldPassCount, fail: scaffoldFail, failedTests, out: tail(suite.out, 12), }; await writeFile( out, JSON.stringify(buildReport(fullSuitePass, scaffold, measuredCounts, 'Endfassung: jede Zahl in dieser Datei ist in diesem Lauf gemessen.'), null, 2) + '\n', 'utf8', ); log('geschrieben:', out); if (!fullSuitePass) { failures.push(`die Abnahmesuite ist rot (${scaffoldFail} von ${scaffoldTests})`); } if (!unitPass) { failures.push('die Unit-Suite ist rot'); } if (!evalCheckPass) { const why = (extras.evalCheck.reasons ?? []).join('; '); failures.push(`die Eval-Regression ist rot${why.length > 0 ? `: ${why}` : ''}`); } if (!allSmokeArtifactsPresent) { failures.push(`${missingArtifacts.length} Artefakte fehlen: ${missingArtifacts.join(', ')}`); } if (!cleanTree) { failures.push(`${dirtyPaths.length} Dirty-Pfade im Release-Kandidaten`); } if (unlistedArtifacts.length > 0) { failures.push(`${unlistedArtifacts.length} Artefakte sind nicht in EXPECTED_ARTIFACTS aufgefuehrt`); } if (attributionHits !== 0) { failures.push(`${attributionHits} Attributions-Treffer`); } if (dashHitsOutsideDocumentedQuotes !== 0) { failures.push(`${dashHitsOutsideDocumentedQuotes} lange Striche ausserhalb dokumentierter Zitate`); } if (!versionMatches) { failures.push( `die gebaute Oberflaeche zeigt "${shownChip || versionReadError}", ` + `package.json sagt ${expectedChip}`, ); } if (!measuredCounts.synced) { failures.push(`${measuredCounts.mismatches.length} Testzahlen in der Doku passen nicht`); } if (unexpectedDirtyPaths.length > 0) { failures.push(`${unexpectedDirtyPaths.length} geaenderte Pfade ausserhalb der Aenderungsflaeche`); } if (failures.length > 0) { console.error('[release-gate] Das Release-Gate ist NICHT durch:'); for (const reason of failures) { console.error(' -', reason); } process.exitCode = 1; return; } log(`Release-Gate durch: v${pkg.version} ist freigegeben.`); } main().catch((err) => { console.error('[release-gate] unerwarteter Fehler:', err); process.exitCode = 1; });