@dzhechkov/harness-core 0.3.124 → 0.3.126

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/src/release.ts ADDED
@@ -0,0 +1,692 @@
1
+ /**
2
+ * Verified-release engine (`dz release`, feature release-verified, ADR-001).
3
+ *
4
+ * VERIFY phase of the DETECT→VERIFY→ANALYZE→RELEASE conveyor (grounded in open-claude-code
5
+ * ADR-003 nightly-verified-release): four HARD gates — tests / audit / syntax / smoke-boot —
6
+ * planned and classified here as PURE functions over injected data, executed only by the CLI.
7
+ *
8
+ * Architecture contract (ADR-001, D1–D4):
9
+ * - NO `node:child_process` anywhere in this file — the engine plans commands as DATA
10
+ * (`GateStep.cmd` strings a test can assert, `publishArgv` precedent) and classifies
11
+ * injected execution results. The CLI (`cmdRelease`) is the single executor.
12
+ * - The only fs access lives in {@link collectPackageFacts} (readFileSync/readdirSync/statSync,
13
+ * `discoverPackages` precedent); everything downstream of the facts is pure.
14
+ * - The existing publish gates (guard, claim-check, signature, provenance, files-whitelist)
15
+ * are NEVER duplicated here: a green release hands off to the untouched `dz publish`,
16
+ * and an anti-duplication test greps every planned command for gate keywords.
17
+ * - Fail-closed: any `fail` ⇒ `publishAction: 'blocked'`; a planned-but-unexecuted step is a
18
+ * FAILURE (an under-executed plan can never pass); all-skip is NOT `proceed` (nothing
19
+ * verified is not verified).
20
+ *
21
+ * @packageDocumentation
22
+ */
23
+
24
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
25
+ import type { Dirent } from 'node:fs';
26
+ import { join } from 'node:path';
27
+
28
+ import { discoverPackages, orderByDependencies } from './publish.js';
29
+
30
+ /** The four HARD verify gates, in execution order. */
31
+ export type ReleaseGateId = 'tests' | 'audit' | 'syntax' | 'smoke';
32
+
33
+ /** Order the CLI executes and the verdict reports gates in. */
34
+ export const RELEASE_GATE_ORDER: readonly ReleaseGateId[] = ['tests', 'audit', 'syntax', 'smoke'];
35
+
36
+ /**
37
+ * Classified failure kinds. Classifications are DATA, not prose (AM-1/2/3/4): the report and
38
+ * the auto-issue name the class, and tests pin each class to its triggering input.
39
+ */
40
+ export type ReleaseFailureClass =
41
+ | 'EXIT_NONZERO' // generic: a planned command exited non-zero
42
+ | 'TIMEOUT' // a non-smoke step hit its timeout
43
+ | 'VULNS_HIGH' // audit ran and found >= high advisories (AM-1)
44
+ | 'AUDIT_ERROR' // audit could NOT run (network/registry/lockfile) — still a gate failure (AM-1)
45
+ | 'STALE_DIST' // dist/ older than src/ — never checked/booted as-is (AM-3)
46
+ | 'MISSING_DIST' // a "build" script is declared but dist/ has no JS — unbuilt, never verifiable (AM-10)
47
+ | 'SMOKE_TIMEOUT' // smoke-boot hit its timeout (AM-4)
48
+ | 'MISSING_BIN' // package.json bin points at a file that does not exist on disk
49
+ | 'UNEXECUTED_STEP'; // planned step has no execution record — under-executed plan
50
+
51
+ /** Skip kinds — surfaced per-package, never aggregated into PASS wording (AM-2). */
52
+ export type ReleaseSkipClass = 'SKIP_NO_TEST_SCRIPT' | 'SKIP_NO_ARTIFACTS';
53
+
54
+ /** One `bin` entry of a package, resolved to an absolute path. */
55
+ export interface ReleaseBinEntry {
56
+ readonly name: string;
57
+ /** Absolute path to the bin file (the `./`-less package.json shape is normalized). */
58
+ readonly path: string;
59
+ readonly exists: boolean;
60
+ }
61
+
62
+ /** Facts about one publishable package — the injected input of the pure planner. */
63
+ export interface ReleasePackageFacts {
64
+ readonly name: string;
65
+ readonly dir: string;
66
+ readonly version: string;
67
+ readonly hasTestScript: boolean;
68
+ /** `scripts.build` declared — the AM-10 discriminator between "unbuilt" and "template-only". */
69
+ readonly hasBuildScript: boolean;
70
+ readonly bins: readonly ReleaseBinEntry[];
71
+ /** `dist/**\/*.js` files, relative to the package dir. */
72
+ readonly distJs: readonly string[];
73
+ /** Newest src/ mtime > newest dist/ mtime (only set when both dirs exist) — AM-3 input. */
74
+ readonly srcNewerThanDist?: boolean | undefined;
75
+ }
76
+
77
+ /** One concrete verification step — data, not action. */
78
+ export interface GateStep {
79
+ readonly id: string;
80
+ readonly gate: ReleaseGateId;
81
+ readonly pkg?: string | undefined;
82
+ /** The exact command the CLI will execute; empty for `synthetic-fail` steps. */
83
+ readonly cmd: string;
84
+ readonly cwd: string;
85
+ readonly timeoutMs: number;
86
+ readonly reason: string;
87
+ /**
88
+ * `exec` (default): the CLI runs `cmd`. `synthetic-fail`: the PLAN already knows this step
89
+ * fails (missing bin, stale dist) — classification sees it without any execution.
90
+ */
91
+ readonly kind?: 'exec' | 'synthetic-fail' | undefined;
92
+ /** For `synthetic-fail` steps: the failure class the verdict must carry. */
93
+ readonly failClass?: ReleaseFailureClass | undefined;
94
+ /**
95
+ * Smoke steps run in a THROWAWAY cwd (AM-4): skills bins are installers that mutate
96
+ * `.claude/` on default action. Scope honesty: the temp cwd only diverts RELATIVE-path
97
+ * writes; a bin resolving the workspace via env/__dirname can still reach it — inherent
98
+ * to executing bins at all, which is the point of the smoke gate.
99
+ */
100
+ readonly tempCwd?: boolean | undefined;
101
+ }
102
+
103
+ /** An honestly-reported skip (e.g. a package with no `test` script). */
104
+ export interface GateSkip {
105
+ readonly gate: ReleaseGateId;
106
+ readonly pkg: string;
107
+ readonly reason: string;
108
+ readonly class: ReleaseSkipClass;
109
+ }
110
+
111
+ /** The full plan for one release run: ordered steps + per-package skip records. */
112
+ export interface GatePlan {
113
+ readonly steps: readonly GateStep[];
114
+ readonly skips: readonly GateSkip[];
115
+ /** Package names in the release set (dependency order). */
116
+ readonly packages: readonly string[];
117
+ }
118
+
119
+ /** The CLI's record of running one exec step. */
120
+ export interface GateExecution {
121
+ readonly stepId: string;
122
+ readonly exitCode: number;
123
+ readonly stdout: string;
124
+ readonly stderr: string;
125
+ readonly durationMs: number;
126
+ readonly timedOut?: boolean | undefined;
127
+ }
128
+
129
+ /** One classified failure inside a gate. */
130
+ export interface GateFailure {
131
+ readonly pkg?: string | undefined;
132
+ readonly reason: string;
133
+ readonly class: ReleaseFailureClass;
134
+ }
135
+
136
+ /** Per-gate verdict. `skip` = the gate had nothing to execute (still not a pass). */
137
+ export interface GateResult {
138
+ readonly gate: ReleaseGateId;
139
+ readonly status: 'pass' | 'fail' | 'skip';
140
+ readonly passed: number;
141
+ readonly failures: readonly GateFailure[];
142
+ readonly skips: readonly GateSkip[];
143
+ }
144
+
145
+ /** The verdict — single input for report/issue/tag/handoff decisions. */
146
+ export interface ReleaseVerdict {
147
+ readonly gates: readonly GateResult[];
148
+ readonly ok: boolean;
149
+ readonly blockedBy: readonly string[];
150
+ readonly skipped: readonly GateSkip[];
151
+ /** Fail-closed decision point: `'proceed'` iff every gate is clean AND something ran. */
152
+ readonly publishAction: 'proceed' | 'blocked';
153
+ readonly timestamp: string;
154
+ }
155
+
156
+ /** Default per-step timeouts (NFR-4: a hung child is a classified failure, not a hung release). */
157
+ export const RELEASE_TIMEOUTS = {
158
+ testMs: 600_000,
159
+ auditMs: 120_000,
160
+ syntaxMs: 30_000,
161
+ smokeMs: 20_000,
162
+ } as const;
163
+
164
+ /* ------------------------------------------------------------------ */
165
+ /* DETECT — facts collection (the only fs in this file) */
166
+ /* ------------------------------------------------------------------ */
167
+
168
+ /** Recursively list `*.js` files under `dir`, returned relative to `base`. */
169
+ function listJsFiles(base: string, dir: string): string[] {
170
+ const out: string[] = [];
171
+ let entries: Dirent[];
172
+ try {
173
+ entries = readdirSync(dir, { withFileTypes: true });
174
+ } catch {
175
+ return out; // unreadable dir → no files (dist absence is reported by the plan, not here)
176
+ }
177
+ for (const e of entries) {
178
+ const full = join(dir, e.name);
179
+ if (e.isDirectory()) out.push(...listJsFiles(base, full));
180
+ else if (e.isFile() && e.name.endsWith('.js')) out.push(full.slice(base.length + 1));
181
+ }
182
+ return out;
183
+ }
184
+
185
+ /** Newest file mtime (ms) under `dir`, recursively; 0 when empty/unreadable. */
186
+ function newestMtime(dir: string): number {
187
+ let newest = 0;
188
+ let entries: Dirent[];
189
+ try {
190
+ entries = readdirSync(dir, { withFileTypes: true });
191
+ } catch {
192
+ return newest;
193
+ }
194
+ for (const e of entries) {
195
+ const full = join(dir, e.name);
196
+ try {
197
+ if (e.isDirectory()) newest = Math.max(newest, newestMtime(full));
198
+ else if (e.isFile()) newest = Math.max(newest, statSync(full).mtimeMs);
199
+ } catch {
200
+ /* raced/unreadable entry — skip */
201
+ }
202
+ }
203
+ return newest;
204
+ }
205
+
206
+ /**
207
+ * Gather {@link ReleasePackageFacts} for the release set: `discoverPackages` +
208
+ * `orderByDependencies` (imported from publish — reuse, never copy: G9) plus each package's
209
+ * `scripts.test` / `bin` / `dist/**\/*.js` and dist-vs-src staleness (AM-3 input).
210
+ *
211
+ * `filter` mirrors `dz publish --filter` substring semantics (name OR dir); an explicitly
212
+ * empty filter is REJECTED (throws) — "match all on empty" was the publish P0 this mirrors.
213
+ *
214
+ * Failure contract (load-bearing path — fail FAST, not open): a corrupt `package.json`
215
+ * throws up to the caller; a missing/foreign root degrades to `[]` per the
216
+ * `discoverPackages` contract (the CLI reports "no publishable packages" and exits non-zero).
217
+ */
218
+ export function collectPackageFacts(monorepoRoot: string, filter?: readonly string[]): ReleasePackageFacts[] {
219
+ if (filter !== undefined && filter.length === 0) {
220
+ throw new Error('release: --filter requires a non-empty list of package-name substrings (empty would match ALL packages)');
221
+ }
222
+ const discovered = discoverPackages(monorepoRoot);
223
+ const selected =
224
+ filter === undefined ? discovered : discovered.filter((p) => filter.some((f) => p.name.includes(f) || p.dir.includes(f)));
225
+ const ordered = orderByDependencies(selected);
226
+
227
+ return ordered.map((p) => {
228
+ const pkgJson = JSON.parse(readFileSync(join(p.dir, 'package.json'), 'utf-8')) as {
229
+ scripts?: Record<string, string>;
230
+ bin?: string | Record<string, string>;
231
+ name?: string;
232
+ };
233
+ const bins: ReleaseBinEntry[] = [];
234
+ if (typeof pkgJson.bin === 'string') {
235
+ // `"bin": "cli.js"` — bin name defaults to the package basename; path may lack `./` (G3).
236
+ const rel = pkgJson.bin.replace(/^\.\//, '');
237
+ const abs = join(p.dir, rel);
238
+ bins.push({ name: p.name.split('/').pop() ?? p.name, path: abs, exists: existsSync(abs) });
239
+ } else if (pkgJson.bin !== undefined && pkgJson.bin !== null && typeof pkgJson.bin === 'object') {
240
+ for (const [name, relRaw] of Object.entries(pkgJson.bin)) {
241
+ const rel = String(relRaw).replace(/^\.\//, '');
242
+ const abs = join(p.dir, rel);
243
+ bins.push({ name, path: abs, exists: existsSync(abs) });
244
+ }
245
+ }
246
+ const distDir = join(p.dir, 'dist');
247
+ const srcDir = join(p.dir, 'src');
248
+ const distJs = existsSync(distDir) ? listJsFiles(p.dir, distDir).sort() : [];
249
+ let srcNewerThanDist: boolean | undefined;
250
+ if (existsSync(distDir) && existsSync(srcDir)) {
251
+ srcNewerThanDist = newestMtime(srcDir) > newestMtime(distDir);
252
+ }
253
+ return {
254
+ name: p.name,
255
+ dir: p.dir,
256
+ version: p.version,
257
+ hasTestScript: typeof pkgJson.scripts?.['test'] === 'string' && pkgJson.scripts['test'].trim().length > 0,
258
+ hasBuildScript: typeof pkgJson.scripts?.['build'] === 'string' && pkgJson.scripts['build'].trim().length > 0,
259
+ bins,
260
+ distJs,
261
+ srcNewerThanDist,
262
+ };
263
+ });
264
+ }
265
+
266
+ /**
267
+ * AM-8: affected-package selection is a PURE function of an injected changed-file list.
268
+ * `null` (diff unavailable), an empty list, or a list matching zero packages all FAIL OPEN
269
+ * to the full set — a release can never pass on zero verified packages.
270
+ */
271
+ export function selectAffectedPackages(
272
+ changedFiles: readonly string[] | null,
273
+ facts: readonly ReleasePackageFacts[],
274
+ ): ReleasePackageFacts[] {
275
+ if (changedFiles === null || changedFiles.length === 0) return [...facts];
276
+ const norm = (s: string): string => s.replace(/\\/g, '/');
277
+ const affected = facts.filter((f) => {
278
+ const dir = norm(f.dir).replace(/\/$/, '');
279
+ const tail = dir.split('/').slice(-3).join('/'); // packages/@dzhechkov/<name>
280
+ return changedFiles.some((file) => {
281
+ const nf = norm(String(file));
282
+ return nf.startsWith(dir + '/') || nf === dir || nf.includes(tail + '/');
283
+ });
284
+ });
285
+ return affected.length === 0 ? [...facts] : affected;
286
+ }
287
+
288
+ /* ------------------------------------------------------------------ */
289
+ /* VERIFY — pure gate planning */
290
+ /* ------------------------------------------------------------------ */
291
+
292
+ export interface PlanReleaseGatesOptions {
293
+ readonly monorepoRoot: string;
294
+ /** pnpm is the workspace manager here (AM-1); npm audit only when no pnpm lockfile. */
295
+ readonly pnpmLockPresent: boolean;
296
+ /**
297
+ * AM-11: the audit gate scopes to PRODUCTION dependencies by default — a dev-only advisory
298
+ * (e.g. a vite chain nothing ships) making every release permanently red is a false gate,
299
+ * and a false gate kills trust in the real one. `true` (CLI `--audit-dev`) widens to all deps.
300
+ */
301
+ readonly includeDevDeps?: boolean | undefined;
302
+ readonly testTimeoutMs?: number | undefined;
303
+ readonly auditTimeoutMs?: number | undefined;
304
+ readonly syntaxTimeoutMs?: number | undefined;
305
+ readonly smokeTimeoutMs?: number | undefined;
306
+ }
307
+
308
+ /**
309
+ * Plan the four gates from injected facts. Pure: same facts ⇒ byte-identical plan; nothing
310
+ * is executed; every command is an assertable string. Anti-duplication (ADR D1): no step may
311
+ * re-enact a publish gate — the dedicated test greps `cmd`s for guard/claim/sign/provenance.
312
+ */
313
+ export function planReleaseGates(facts: readonly ReleasePackageFacts[], opts: PlanReleaseGatesOptions): GatePlan {
314
+ const steps: GateStep[] = [];
315
+ const skips: GateSkip[] = [];
316
+ const t = {
317
+ tests: opts.testTimeoutMs ?? RELEASE_TIMEOUTS.testMs,
318
+ audit: opts.auditTimeoutMs ?? RELEASE_TIMEOUTS.auditMs,
319
+ syntax: opts.syntaxTimeoutMs ?? RELEASE_TIMEOUTS.syntaxMs,
320
+ smoke: opts.smokeTimeoutMs ?? RELEASE_TIMEOUTS.smokeMs,
321
+ };
322
+
323
+ // Gate 1 — tests: the package's FULL suite via its own `test` script (pnpm test → vitest run).
324
+ for (const f of facts) {
325
+ if (f.hasTestScript) {
326
+ steps.push({
327
+ id: `tests:${f.name}`,
328
+ gate: 'tests',
329
+ pkg: f.name,
330
+ cmd: 'pnpm test',
331
+ cwd: f.dir,
332
+ timeoutMs: t.tests,
333
+ reason: 'full package test suite must pass',
334
+ kind: 'exec',
335
+ });
336
+ } else {
337
+ // AM-2: an explicit, named skip — never a silent pass.
338
+ skips.push({
339
+ gate: 'tests',
340
+ pkg: f.name,
341
+ reason: 'no "test" script in package.json — nothing was verified for this package',
342
+ class: 'SKIP_NO_TEST_SCRIPT',
343
+ });
344
+ }
345
+ }
346
+
347
+ // Gate 2 — audit: ONE workspace-level step (AM-1: pnpm primary; npm only without pnpm-lock).
348
+ const dev = opts.includeDevDeps === true;
349
+ steps.push({
350
+ id: 'audit:workspace',
351
+ gate: 'audit',
352
+ cmd: opts.pnpmLockPresent
353
+ ? `pnpm audit${dev ? '' : ' --prod'} --audit-level high`
354
+ : `npm audit${dev ? '' : ' --omit=dev'} --audit-level=high`,
355
+ cwd: opts.monorepoRoot,
356
+ timeoutMs: t.audit,
357
+ reason: dev
358
+ ? 'no >=high advisories across ALL workspace dependencies (dev included via --audit-dev)'
359
+ : 'no >=high advisories in production dependencies (dev-only chains excluded — widen with --audit-dev)',
360
+ kind: 'exec',
361
+ });
362
+
363
+ // Gates 3+4 — per package. AM-3: a stale dist is NEVER checked/booted as-is.
364
+ for (const f of facts) {
365
+ if (f.srcNewerThanDist === true) {
366
+ steps.push({
367
+ id: `syntax:${f.name}:stale-dist`,
368
+ gate: 'syntax',
369
+ pkg: f.name,
370
+ cmd: '',
371
+ cwd: f.dir,
372
+ timeoutMs: 0,
373
+ reason: 'dist/ is OLDER than src/ — rebuild before release; a stale dist is not checked as-is',
374
+ kind: 'synthetic-fail',
375
+ failClass: 'STALE_DIST',
376
+ });
377
+ if (f.bins.length > 0) {
378
+ steps.push({
379
+ id: `smoke:${f.name}:stale-dist`,
380
+ gate: 'smoke',
381
+ pkg: f.name,
382
+ cmd: '',
383
+ cwd: f.dir,
384
+ timeoutMs: 0,
385
+ reason: 'dist/ is OLDER than src/ — rebuild before release; a stale bin is not booted as-is',
386
+ kind: 'synthetic-fail',
387
+ failClass: 'STALE_DIST',
388
+ });
389
+ }
390
+ continue;
391
+ }
392
+
393
+ // AM-10 — the fail-closed INVERSE of AM-3: a package that DECLARES a build but has zero
394
+ // dist JS was never built — zero syntax/smoke steps must read as a FAILURE, never as a
395
+ // clean gate (the dead-SKIP_NO_ARTIFACTS defect Step-8 QE + the delivery gate both caught).
396
+ // A pack with no build script, no artifacts and no bins is a template-only pack: an honest
397
+ // NAMED skip (AM-2), never a silent zero-step pass.
398
+ if (f.distJs.length === 0) {
399
+ if (f.hasBuildScript === true) {
400
+ steps.push({
401
+ id: `syntax:${f.name}:missing-dist`,
402
+ gate: 'syntax',
403
+ pkg: f.name,
404
+ cmd: '',
405
+ cwd: f.dir,
406
+ timeoutMs: 0,
407
+ reason: 'package declares a "build" script but dist/ contains no JS — build before release; an unbuilt package must be impossible to ship',
408
+ kind: 'synthetic-fail',
409
+ failClass: 'MISSING_DIST',
410
+ });
411
+ } else if (f.bins.length === 0) {
412
+ skips.push({
413
+ gate: 'syntax',
414
+ pkg: f.name,
415
+ reason: 'no dist/ JS, no bin, no build script — template-only pack; nothing to syntax-check or boot',
416
+ class: 'SKIP_NO_ARTIFACTS',
417
+ });
418
+ }
419
+ }
420
+
421
+ // Gate 3 — syntax: node --check every dist/**/*.js and every existing bin file (deduped).
422
+ const checked = new Set<string>();
423
+ for (const rel of f.distJs) {
424
+ const abs = join(f.dir, rel);
425
+ checked.add(abs);
426
+ steps.push({
427
+ id: `syntax:${f.name}:${rel}`,
428
+ gate: 'syntax',
429
+ pkg: f.name,
430
+ cmd: `node --check "${abs}"`,
431
+ cwd: f.dir,
432
+ timeoutMs: t.syntax,
433
+ reason: `dist file must parse (${rel})`,
434
+ kind: 'exec',
435
+ });
436
+ }
437
+ for (const bin of f.bins) {
438
+ if (bin.exists && !checked.has(bin.path)) {
439
+ checked.add(bin.path);
440
+ steps.push({
441
+ id: `syntax:${f.name}:bin:${bin.name}`,
442
+ gate: 'syntax',
443
+ pkg: f.name,
444
+ cmd: `node --check "${bin.path}"`,
445
+ cwd: f.dir,
446
+ timeoutMs: t.syntax,
447
+ reason: `bin file must parse (${bin.name})`,
448
+ kind: 'exec',
449
+ });
450
+ }
451
+ }
452
+
453
+ // Gate 4 — smoke-boot: node <bin> --help, DIRECT node (never npx: signals reach the wrapper,
454
+ // not the child), temp cwd + timeout (AM-4). A missing bin file is a synthetic MISSING_BIN.
455
+ for (const bin of f.bins) {
456
+ if (!bin.exists) {
457
+ steps.push({
458
+ id: `smoke:${f.name}:${bin.name}:missing`,
459
+ gate: 'smoke',
460
+ pkg: f.name,
461
+ cmd: '',
462
+ cwd: f.dir,
463
+ timeoutMs: 0,
464
+ reason: `bin "${bin.name}" points at ${bin.path} which does not exist — build before release`,
465
+ kind: 'synthetic-fail',
466
+ failClass: 'MISSING_BIN',
467
+ });
468
+ } else {
469
+ steps.push({
470
+ id: `smoke:${f.name}:${bin.name}`,
471
+ gate: 'smoke',
472
+ pkg: f.name,
473
+ cmd: `node "${bin.path}" --help`,
474
+ cwd: f.dir,
475
+ timeoutMs: t.smoke,
476
+ reason: `bin "${bin.name}" must boot (--help, exit 0)`,
477
+ kind: 'exec',
478
+ tempCwd: true,
479
+ });
480
+ }
481
+ }
482
+ }
483
+
484
+ return { steps, skips, packages: facts.map((f) => f.name) };
485
+ }
486
+
487
+ /* ------------------------------------------------------------------ */
488
+ /* VERIFY — pure classification */
489
+ /* ------------------------------------------------------------------ */
490
+
491
+ /**
492
+ * AM-1: split an audit non-zero exit into VULNS_HIGH (advisories found) vs AUDIT_ERROR
493
+ * (audit could not run). BOTH block (fail-closed either way); only the message differs, so a
494
+ * misclassification is cosmetic, never a false pass. Unrecognized output ⇒ AUDIT_ERROR — we
495
+ * never claim "vulnerabilities found" from output we cannot read.
496
+ */
497
+ function classifyAuditFailure(output: string): { cls: ReleaseFailureClass; reason: string } {
498
+ const text = String(output ?? '');
499
+ const looksLikeError =
500
+ /(ERR_PNPM|npm ERR!|ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|audit endpoint|registry .*(unreachable|error)|no .*lockfile|missing .*lockfile|cannot audit)/i.test(
501
+ text,
502
+ );
503
+ const looksLikeVulns = /\d+\s+vulnerabilit(y|ies)|severity\s*[:>]|\bhigh\b.*\bvulnerabilit|advisor(y|ies)\b/i.test(text);
504
+ if (looksLikeVulns && !looksLikeError) {
505
+ return { cls: 'VULNS_HIGH', reason: 'audit found >=high advisories — fix or consciously fall back to plain dz publish' };
506
+ }
507
+ return {
508
+ cls: 'AUDIT_ERROR',
509
+ reason: 'audit could not complete (network/registry/lockfile) — a gate that cannot run is NOT a passed gate',
510
+ };
511
+ }
512
+
513
+ /**
514
+ * One-line detail for an AUDIT failure: prefer the line that actually SUMMARIZES the
515
+ * advisories (pnpm/npm print it to stdout) over execSync's generic stderr "Command failed…".
516
+ */
517
+ function auditDetailLine(stdout: unknown, stderr: unknown): string {
518
+ const all = `${stdout == null ? '' : String(stdout)}\n${stderr == null ? '' : String(stderr)}`;
519
+ const summary = all
520
+ .split('\n')
521
+ .map((l) => l.trim())
522
+ .find((l) => /\d+\s+vulnerabilit|severity|advisor/i.test(l));
523
+ return (summary ?? firstLine(stdout, stderr)).slice(0, 200);
524
+ }
525
+
526
+ /**
527
+ * First non-empty output line, for one-line failure reasons; hostile input coerced safely.
528
+ * Exported so the CLI reuses it for gh/tag periphery messages (G9 reuse-never-copy).
529
+ */
530
+ export function firstOutputLine(...chunks: readonly unknown[]): string {
531
+ return firstLine(...chunks);
532
+ }
533
+
534
+ function firstLine(...chunks: readonly unknown[]): string {
535
+ for (const c of chunks) {
536
+ const s = c == null ? '' : String(c);
537
+ const line = s.split('\n').find((l) => l.trim().length > 0);
538
+ if (line !== undefined) return line.trim().slice(0, 200);
539
+ }
540
+ return '';
541
+ }
542
+
543
+ /**
544
+ * Merge plan + executions into the {@link ReleaseVerdict} — the single fail-closed decision
545
+ * point (ADR load-bearing property):
546
+ *
547
+ * - any `fail` ⇒ `publishAction: 'blocked'`, `ok: false`;
548
+ * - a planned exec step with NO execution record ⇒ `UNEXECUTED_STEP` failure;
549
+ * - all-skip (nothing executed anywhere) ⇒ NOT `proceed` — nothing verified is not verified;
550
+ * - never throws on hostile input (`formatPublishError` discipline).
551
+ */
552
+ export function classifyGateExecutions(
553
+ plan: GatePlan,
554
+ executions: readonly GateExecution[],
555
+ now: Date = new Date(),
556
+ ): ReleaseVerdict {
557
+ const byId = new Map<string, GateExecution>();
558
+ for (const e of executions ?? []) {
559
+ if (e != null && typeof e.stepId === 'string') byId.set(e.stepId, e);
560
+ }
561
+
562
+ const gates: GateResult[] = RELEASE_GATE_ORDER.map((gate) => {
563
+ const gateSteps = (plan?.steps ?? []).filter((s) => s?.gate === gate);
564
+ const gateSkips = (plan?.skips ?? []).filter((s) => s?.gate === gate);
565
+ const failures: GateFailure[] = [];
566
+ let passed = 0;
567
+
568
+ for (const step of gateSteps) {
569
+ try {
570
+ if (step.kind === 'synthetic-fail') {
571
+ failures.push({ pkg: step.pkg, reason: step.reason, class: step.failClass ?? 'EXIT_NONZERO' });
572
+ continue;
573
+ }
574
+ const exec = byId.get(step.id);
575
+ if (exec === undefined) {
576
+ failures.push({
577
+ pkg: step.pkg,
578
+ reason: `planned step "${step.id}" was never executed — an under-executed plan cannot pass`,
579
+ class: 'UNEXECUTED_STEP',
580
+ });
581
+ continue;
582
+ }
583
+ if (exec.timedOut === true) {
584
+ failures.push({
585
+ pkg: step.pkg,
586
+ reason: `timed out after ${step.timeoutMs}ms: ${step.cmd}`,
587
+ class: gate === 'smoke' ? 'SMOKE_TIMEOUT' : 'TIMEOUT',
588
+ });
589
+ continue;
590
+ }
591
+ if (typeof exec.exitCode !== 'number' || exec.exitCode !== 0) {
592
+ if (gate === 'audit') {
593
+ const { cls, reason } = classifyAuditFailure(`${exec.stdout ?? ''}\n${exec.stderr ?? ''}`);
594
+ const detail = auditDetailLine(exec.stdout, exec.stderr);
595
+ failures.push({ pkg: step.pkg, reason: `${reason}${detail ? ` — ${detail}` : ''}`, class: cls });
596
+ } else {
597
+ failures.push({
598
+ pkg: step.pkg,
599
+ reason: `exit ${String(exec.exitCode)}: ${step.cmd}${firstLine(exec.stderr, exec.stdout) ? ` — ${firstLine(exec.stderr, exec.stdout)}` : ''}`,
600
+ class: 'EXIT_NONZERO',
601
+ });
602
+ }
603
+ continue;
604
+ }
605
+ passed += 1;
606
+ } catch {
607
+ // Hostile/malformed step or execution record: classify as failure, never throw.
608
+ failures.push({ pkg: step?.pkg, reason: 'unclassifiable step/execution record', class: 'EXIT_NONZERO' });
609
+ }
610
+ }
611
+
612
+ const status: GateResult['status'] = failures.length > 0 ? 'fail' : passed > 0 ? 'pass' : 'skip';
613
+ return { gate, status, passed, failures, skips: gateSkips };
614
+ });
615
+
616
+ const failedGates = gates.filter((g) => g.status === 'fail');
617
+ const anyPass = gates.some((g) => g.status === 'pass');
618
+ const blockedBy: string[] = failedGates.map(
619
+ (g) => `${g.gate}: ${g.failures.length} failure(s) [${[...new Set(g.failures.map((f) => f.class))].join(', ')}]`,
620
+ );
621
+ if (failedGates.length === 0 && !anyPass) {
622
+ blockedBy.push('nothing-verified: no gate executed a single step — an all-skip run is not a verified release');
623
+ }
624
+ const ok = failedGates.length === 0 && anyPass;
625
+
626
+ return {
627
+ gates,
628
+ ok,
629
+ blockedBy,
630
+ skipped: plan?.skips ?? [],
631
+ publishAction: ok ? 'proceed' : 'blocked',
632
+ timestamp: now.toISOString(),
633
+ };
634
+ }
635
+
636
+ /* ------------------------------------------------------------------ */
637
+ /* Failure telemetry + RELEASE trimmings (all pure) */
638
+ /* ------------------------------------------------------------------ */
639
+
640
+ export interface FailureIssueContext {
641
+ /** How the release was invoked (for reproduction), e.g. `dz release --filter foo`. */
642
+ readonly invocation?: string | undefined;
643
+ readonly repo?: string | undefined;
644
+ }
645
+
646
+ /**
647
+ * gh-2.4-safe `gh issue create` payload (only `--title`/`--body` are assumed downstream).
648
+ * Pure + deterministic for a fixed verdict — the issue is the verdict's echo, never its judge.
649
+ */
650
+ export function buildFailureIssue(verdict: ReleaseVerdict, ctx: FailureIssueContext = {}): { title: string; body: string } {
651
+ const failed = verdict.gates.filter((g) => g.status === 'fail').map((g) => g.gate);
652
+ const title = `dz release: gate failure — ${failed.length > 0 ? failed.join(', ') : 'nothing verified'}`;
653
+ const lines: string[] = [
654
+ `Verified release blocked at ${verdict.timestamp}.`,
655
+ '',
656
+ ...(ctx.invocation ? [`Invocation: \`${ctx.invocation}\``, ''] : []),
657
+ ...(ctx.repo ? [`Repo: ${ctx.repo}`, ''] : []),
658
+ '## Gate verdict',
659
+ '',
660
+ ];
661
+ for (const g of verdict.gates) {
662
+ const icon = g.status === 'pass' ? '✓' : g.status === 'fail' ? '✗' : '○';
663
+ lines.push(`- ${icon} **${g.gate}** — ${g.status} (${g.passed} passed, ${g.failures.length} failed, ${g.skips.length} skipped)`);
664
+ for (const f of g.failures) lines.push(` - [${f.class}] ${f.pkg ? `${f.pkg}: ` : ''}${f.reason}`);
665
+ }
666
+ if (verdict.skipped.length > 0) {
667
+ lines.push('', '## Skipped (honestly reported, never counted as passed)', '');
668
+ for (const s of verdict.skipped) lines.push(`- [${s.class}] ${s.pkg}: ${s.reason}`);
669
+ }
670
+ lines.push('', `Blocked by: ${verdict.blockedBy.join('; ')}`, '', '_Auto-created by `dz release` (best-effort; the release verdict is independent of this issue)._');
671
+ return { title, body: lines.join('\n') };
672
+ }
673
+
674
+ /** Short, bounded release notes from injected `git log --oneline`-style lines. */
675
+ export function buildReleaseNotes(gitLogLines: readonly string[], limit = 15): string {
676
+ const bullets = (gitLogLines ?? [])
677
+ .map((l) => String(l ?? '').trim())
678
+ .filter((l) => l.length > 0)
679
+ .slice(0, Math.max(1, limit))
680
+ .map((l) => `- ${l.slice(0, 200)}`);
681
+ if (bullets.length === 0) return 'Verified release (no commit subjects available).';
682
+ return `Verified release — recent changes:\n${bullets.join('\n')}`;
683
+ }
684
+
685
+ /** Deterministic tag name from injected data: `release-<yyyymmdd>-<shortsha>`. */
686
+ export function releaseTagName(now: Date, shortSha: string): string {
687
+ const y = now.getUTCFullYear();
688
+ const m = String(now.getUTCMonth() + 1).padStart(2, '0');
689
+ const d = String(now.getUTCDate()).padStart(2, '0');
690
+ const sha = String(shortSha ?? '').replace(/[^0-9a-zA-Z]/g, '').slice(0, 12);
691
+ return sha.length > 0 ? `release-${y}${m}${d}-${sha}` : `release-${y}${m}${d}`;
692
+ }