@dzhechkov/harness-core 0.8.32 → 0.8.34

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.
Files changed (63) hide show
  1. package/.dz-manifest.json +107 -47
  2. package/README.md +108 -0
  3. package/dist/agentdb-index.d.ts +17 -6
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +61 -17
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/apply-leg.d.ts +68 -5
  8. package/dist/apply-leg.d.ts.map +1 -1
  9. package/dist/apply-leg.js +466 -37
  10. package/dist/apply-leg.js.map +1 -1
  11. package/dist/embed-socket-path.d.ts +65 -0
  12. package/dist/embed-socket-path.d.ts.map +1 -0
  13. package/dist/embed-socket-path.js +100 -0
  14. package/dist/embed-socket-path.js.map +1 -0
  15. package/dist/index.d.ts +11 -4
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +9 -3
  18. package/dist/index.js.map +1 -1
  19. package/dist/operations.d.ts.map +1 -1
  20. package/dist/operations.js +105 -3
  21. package/dist/operations.js.map +1 -1
  22. package/dist/packed-install-smoke.d.ts +108 -0
  23. package/dist/packed-install-smoke.d.ts.map +1 -0
  24. package/dist/packed-install-smoke.js +172 -0
  25. package/dist/packed-install-smoke.js.map +1 -0
  26. package/dist/publish-sibling-drift.d.ts +139 -0
  27. package/dist/publish-sibling-drift.d.ts.map +1 -0
  28. package/dist/publish-sibling-drift.js +408 -0
  29. package/dist/publish-sibling-drift.js.map +1 -0
  30. package/dist/publish.d.ts +44 -0
  31. package/dist/publish.d.ts.map +1 -1
  32. package/dist/publish.js +243 -24
  33. package/dist/publish.js.map +1 -1
  34. package/dist/qe-bridge.d.ts +16 -0
  35. package/dist/qe-bridge.d.ts.map +1 -1
  36. package/dist/qe-bridge.js +1 -0
  37. package/dist/qe-bridge.js.map +1 -1
  38. package/dist/release.d.ts +91 -0
  39. package/dist/release.d.ts.map +1 -1
  40. package/dist/release.js +317 -21
  41. package/dist/release.js.map +1 -1
  42. package/dist/setup.d.ts +36 -0
  43. package/dist/setup.d.ts.map +1 -1
  44. package/dist/setup.js +96 -2
  45. package/dist/setup.js.map +1 -1
  46. package/dist/vector-tier.d.ts +27 -2
  47. package/dist/vector-tier.d.ts.map +1 -1
  48. package/dist/vector-tier.js +112 -3
  49. package/dist/vector-tier.js.map +1 -1
  50. package/package.json +23 -23
  51. package/sbom.json +196 -46
  52. package/src/agentdb-index.ts +65 -17
  53. package/src/apply-leg.ts +461 -37
  54. package/src/embed-socket-path.ts +113 -0
  55. package/src/index.ts +44 -3
  56. package/src/operations.ts +104 -3
  57. package/src/packed-install-smoke.ts +273 -0
  58. package/src/publish-sibling-drift.ts +498 -0
  59. package/src/publish.ts +280 -25
  60. package/src/qe-bridge.ts +14 -0
  61. package/src/release.ts +366 -19
  62. package/src/setup.ts +108 -2
  63. package/src/vector-tier.ts +147 -5
package/dist/release.d.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  *
21
21
  * @packageDocumentation
22
22
  */
23
+ import type { PackedInstallPlan } from './packed-install-smoke.js';
23
24
  /** The four HARD verify gates, in execution order. */
24
25
  export type ReleaseGateId = 'tests' | 'audit' | 'syntax' | 'smoke';
25
26
  /** Order the CLI executes and the verdict reports gates in. */
@@ -90,6 +91,15 @@ export interface GatePlan {
90
91
  readonly skips: readonly GateSkip[];
91
92
  /** Package names in the release set (dependency order). */
92
93
  readonly packages: readonly string[];
94
+ /**
95
+ * AM-7 (feature publish-sibling-drift-gate): the packed-install sub-plan, carried through so
96
+ * {@link classifyGateExecutions} can re-judge its `bin-version`/`bin-exists` steps through the
97
+ * SAME judge `dz publish` uses (`judgePackedInstallSmoke`: exit 0 AND non-empty stdout, AND the
98
+ * declared bin must exist post-install) instead of the generic exit-code-only check every other
99
+ * step gets. Without this, a silently no-op bin could pass `dz release` while `dz publish`
100
+ * refuses it — the two doors would not be equal, contradicting FR-6's own claim.
101
+ */
102
+ readonly packedInstallPlan?: PackedInstallPlan | undefined;
93
103
  }
94
104
  /** The CLI's record of running one exec step. */
95
105
  export interface GateExecution {
@@ -105,6 +115,22 @@ export interface GateFailure {
105
115
  readonly pkg?: string | undefined;
106
116
  readonly reason: string;
107
117
  readonly class: ReleaseFailureClass;
118
+ /**
119
+ * Feature release-gate-output-tail (AM-4): the last non-empty lines of the step's stdout and
120
+ * stderr, KEPT SEPARATE — each stream through {@link outputTail} on its own, never merged —
121
+ * so a reader can tell which stream a line came from. Set for `tests`/`syntax`/`smoke`
122
+ * EXIT_NONZERO/TIMEOUT failures; absent for `audit` (its own detail line already summarizes)
123
+ * and for failures with no execution record (e.g. UNEXECUTED_STEP).
124
+ *
125
+ * Scope honesty (AM-4): the two streams are captured independently, so a printed/issued
126
+ * `stdout:`/`stderr:` pair does NOT reconstruct the chronological interleaving of the two
127
+ * streams as the process actually emitted them — only each stream's own tail order is
128
+ * preserved. Documented in the CLI README (AM-8), not silently implied.
129
+ */
130
+ readonly tails?: {
131
+ readonly stdout: string;
132
+ readonly stderr: string;
133
+ };
108
134
  }
109
135
  /** Per-gate verdict. `skip` = the gate had nothing to execute (still not a pass). */
110
136
  export interface GateResult {
@@ -164,6 +190,15 @@ export interface PlanReleaseGatesOptions {
164
190
  readonly auditTimeoutMs?: number | undefined;
165
191
  readonly syntaxTimeoutMs?: number | undefined;
166
192
  readonly smokeTimeoutMs?: number | undefined;
193
+ /**
194
+ * FR-6 (feature publish-sibling-drift-gate): real tmp dirs for the packed-install smoke,
195
+ * supplied by the CLI (planning stays pure — it never calls mkdtemp itself). Omitted ⇒ no
196
+ * packed-install steps are planned, byte-identical to the pre-feature behavior.
197
+ */
198
+ readonly packedInstall?: {
199
+ readonly packDir: string;
200
+ readonly installDir: string;
201
+ } | undefined;
167
202
  }
168
203
  /**
169
204
  * Plan the four gates from injected facts. Pure: same facts ⇒ byte-identical plan; nothing
@@ -176,6 +211,54 @@ export declare function planReleaseGates(facts: readonly ReleasePackageFacts[],
176
211
  * Exported so the CLI reuses it for gh/tag periphery messages (G9 reuse-never-copy).
177
212
  */
178
213
  export declare function firstOutputLine(...chunks: readonly unknown[]): string;
214
+ /**
215
+ * Feature release-gate-output-tail (FR-1, amended AM-2): a one-line-ish detail for a
216
+ * `tests`/`syntax`/`smoke` EXIT_NONZERO/TIMEOUT failure that names the ACTUAL failure — not
217
+ * just the first output line, which for `pnpm test`/vitest is routinely an unrelated
218
+ * vite/esbuild deprecation warning (MEASURED 2026-09-13 16:05/18:52).
219
+ *
220
+ * AM-2: ANSI escapes are stripped FIRST (a coloured runner must match the same patterns as a
221
+ * plain one). Recognised shapes, collected in this priority order and joined:
222
+ * 1. vitest summary lines (`Tests …`, `Test Files …`);
223
+ * 2. up to 5 `FAIL …` / `× …` / `❯ …` lines (failing test names/paths);
224
+ * 3. node:test (TAP) lines: `not ok N - name` and `# fail N`.
225
+ *
226
+ * If NONE of the above is present (a non-vitest, non-TAP failure, or empty output), fall back
227
+ * to the prior `firstLine` behavior, marked `(no test-runner summary recognised)` so a reader
228
+ * knows the detail is a guess, not a parsed summary — UNLESS `firstLine` itself is empty (no
229
+ * output at all), in which case the mark would manufacture a synthetic line where none existed
230
+ * and is withheld. Capped at 600 chars — a detail line, not a dump.
231
+ */
232
+ export declare function testsFailureDetail(stdout: unknown, stderr: unknown): string;
233
+ /**
234
+ * Feature release-gate-output-tail (FR-2/FR-3, amended AM-3): the last non-empty lines of ONE
235
+ * stream (call separately for stdout and stderr — AM-4), bounded on BOTH axes (line count and
236
+ * byte size) so a runaway suite cannot blow up a report or an issue body.
237
+ *
238
+ * AM-3 bounds, each an explicit branch rather than an emergent `Array.slice(-0)` accident
239
+ * (`slice(-0)` returns the WHOLE array, not `[]` — the pre-amendment bug):
240
+ * - `maxLines <= 0` → `''`; `maxBytes <= 0` → `''`.
241
+ * - Whole-line selection: lines are pulled from the END while the running BYTE total (each
242
+ * line's UTF-8 byte length plus its joining `\n`) stays `<= maxBytes` — never a partial line.
243
+ * - A single most-recent line that ALONE exceeds `maxBytes` is truncated at a UTF-8 CHARACTER
244
+ * boundary (never splitting a multi-byte codepoint) and marked `… (line truncated)`.
245
+ *
246
+ * Empty/whitespace-only output → `''` (never a synthetic line).
247
+ */
248
+ export declare function outputTail(stdout: unknown, stderr: unknown, maxLines?: number, maxBytes?: number): string;
249
+ /**
250
+ * Feature release-gate-output-tail (AM-1): redact secret-shaped substrings before ANY tail text
251
+ * reaches a GitHub issue body. Patterns, each independently redacted:
252
+ * - `token`/`secret`/`password` (case-insensitive) as a `key: value` or `key=value` pair — the
253
+ * KEY survives, only the value is replaced;
254
+ * - `Bearer <token>` HTTP auth headers;
255
+ * - vendor-prefixed tokens: `npm_…`, `ghp_…`, `sk-…`, `AKIA…`;
256
+ * - long opaque strings (base64/hex-ish, `[A-Za-z0-9+/=]{32,}`) that look like a key/secret even
257
+ * without a recognisable prefix.
258
+ * Order matters: prefixed/labelled patterns run BEFORE the generic long-opaque-string pattern so
259
+ * a `Bearer …` token is redacted as a whole rather than surviving as a shorter unlabelled blob.
260
+ */
261
+ export declare function redactSecrets(text: string): string;
179
262
  /**
180
263
  * Merge plan + executions into the {@link ReleaseVerdict} — the single fail-closed decision
181
264
  * point (ADR load-bearing property):
@@ -194,6 +277,14 @@ export interface FailureIssueContext {
194
277
  /**
195
278
  * gh-2.4-safe `gh issue create` payload (only `--title`/`--body` are assumed downstream).
196
279
  * Pure + deterministic for a fixed verdict — the issue is the verdict's echo, never its judge.
280
+ *
281
+ * AM-1/AM-4/AM-5: every tail is (a) redacted (secret-shaped substrings replaced — see
282
+ * {@link redactSecrets}) and ANSI-stripped BEFORE it is ever considered for the body; (b) shown
283
+ * per STREAM, labelled `stdout:`/`stderr:` — AM-4's scope note applies here too: the two labelled
284
+ * blocks do NOT reconstruct chronological interleaving between the streams; (c) fenced so the
285
+ * payload cannot break out of its code block; (d) the WHOLE body is capped at
286
+ * {@link MAX_ISSUE_BODY_BYTES} — when it would exceed the cap, every tail is shrunk EVENLY
287
+ * (byte-proportional), not by dropping some tails whole while keeping others untouched.
197
288
  */
198
289
  export declare function buildFailureIssue(verdict: ReleaseVerdict, ctx?: FailureIssueContext): {
199
290
  title: string;
@@ -1 +1 @@
1
- {"version":3,"file":"release.d.ts","sourceRoot":"","sources":["../src/release.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"release.d.ts","sourceRoot":"","sources":["../src/release.ts"],"names":[],"mappings":"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"}
package/dist/release.js CHANGED
@@ -21,8 +21,9 @@
21
21
  * @packageDocumentation
22
22
  */
23
23
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
24
- import { join } from 'node:path';
24
+ import { join, relative } from 'node:path';
25
25
  import { discoverPackages, orderByDependencies } from './publish.js';
26
+ import { planPackedInstallSmoke, judgePackedInstallSmoke } from './packed-install-smoke.js';
26
27
  /** Order the CLI executes and the verdict reports gates in. */
27
28
  export const RELEASE_GATE_ORDER = ['tests', 'audit', 'syntax', 'smoke'];
28
29
  /** Default per-step timeouts (NFR-4: a hung child is a classified failure, not a hung release). */
@@ -323,7 +324,46 @@ export function planReleaseGates(facts, opts) {
323
324
  }
324
325
  }
325
326
  }
326
- return { steps, skips, packages: facts.map((f) => f.name) };
327
+ // FR-6 (feature publish-sibling-drift-gate): the packed-install smoke joins the SAME smoke
328
+ // gate `dz publish` runs (planPackedInstallSmoke, ADR-001 Decision 2) — so both doors apply
329
+ // the identical rule. Opt-in via `opts.packedInstall` (real tmp dirs, supplied by the CLI):
330
+ // omitted, this is byte-identical to the pre-feature plan, which every existing planner test
331
+ // relies on. Skipped entirely when nothing in the batch has a bin — packing siblings nobody
332
+ // will boot proves nothing a fresh `npm install` doesn't already cover elsewhere.
333
+ let packedInstallPlan;
334
+ if (opts.packedInstall !== undefined) {
335
+ const bins = facts.flatMap((f) => f.bins.map((b) => ({ pkg: f.name, binName: b.name, relPath: relative(f.dir, b.path) })));
336
+ if (bins.length > 0) {
337
+ const packages = facts.map((f) => ({ name: f.name, dir: f.dir, version: f.version }));
338
+ const smokePlan = planPackedInstallSmoke({
339
+ packages,
340
+ bins,
341
+ packDir: opts.packedInstall.packDir,
342
+ installDir: opts.packedInstall.installDir,
343
+ });
344
+ packedInstallPlan = smokePlan; // AM-7: kept for classifyGateExecutions's re-judge pass
345
+ for (const s of smokePlan.steps) {
346
+ const reason = s.kind === 'pack'
347
+ ? `pack ${s.pkg} for the packed-install smoke — the tarball a consumer would actually receive`
348
+ : s.kind === 'install'
349
+ ? 'install every packed tarball together in a clean dir — out-of-batch siblings resolve from the registry, exactly like a fresh user'
350
+ : s.kind === 'bin-exists'
351
+ ? `bin "${s.binName}" must EXIST after the packed install (AM-8)`
352
+ : `bin "${s.binName}" must boot from the PACKED install (--version, exit 0, non-empty stdout)`;
353
+ steps.push({
354
+ id: `smoke:packed-install:${s.id}`,
355
+ gate: 'smoke',
356
+ pkg: s.pkg,
357
+ cmd: s.cmd,
358
+ cwd: s.cwd,
359
+ timeoutMs: s.timeoutMs,
360
+ reason,
361
+ kind: 'exec',
362
+ });
363
+ }
364
+ }
365
+ }
366
+ return { steps, skips, packages: facts.map((f) => f.name), ...(packedInstallPlan !== undefined ? { packedInstallPlan } : {}) };
327
367
  }
328
368
  /* ------------------------------------------------------------------ */
329
369
  /* VERIFY — pure classification */
@@ -374,6 +414,137 @@ function firstLine(...chunks) {
374
414
  }
375
415
  return '';
376
416
  }
417
+ /**
418
+ * Strip ANSI/VT100 escape sequences (colour codes, cursor moves, OSC hyperlinks) so pattern
419
+ * matching sees the plain text a human reads on a non-colour terminal. AM-2/AM-1 precondition:
420
+ * `testsFailureDetail` and the issue-body redaction both run this FIRST, before any regex tries
421
+ * to recognise a runner's summary/FAIL lines or a secret value — a coloured `FAIL` token (e.g.
422
+ * `\x1b[31mFAIL\x1b[0m`) must still match `/^FAIL\b/` once stripped.
423
+ */
424
+ // eslint-disable-next-line no-control-regex -- deliberately matching raw ESC control bytes
425
+ function stripAnsi(s) {
426
+ return s
427
+ .replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g, '') // OSC … BEL | OSC … ST
428
+ .replace(/\x1B[[()#;?]*[0-9]*(?:;[0-9]*)*[a-zA-Z@]/g, ''); // CSI/other short escapes
429
+ }
430
+ /**
431
+ * Feature release-gate-output-tail (FR-1, amended AM-2): a one-line-ish detail for a
432
+ * `tests`/`syntax`/`smoke` EXIT_NONZERO/TIMEOUT failure that names the ACTUAL failure — not
433
+ * just the first output line, which for `pnpm test`/vitest is routinely an unrelated
434
+ * vite/esbuild deprecation warning (MEASURED 2026-09-13 16:05/18:52).
435
+ *
436
+ * AM-2: ANSI escapes are stripped FIRST (a coloured runner must match the same patterns as a
437
+ * plain one). Recognised shapes, collected in this priority order and joined:
438
+ * 1. vitest summary lines (`Tests …`, `Test Files …`);
439
+ * 2. up to 5 `FAIL …` / `× …` / `❯ …` lines (failing test names/paths);
440
+ * 3. node:test (TAP) lines: `not ok N - name` and `# fail N`.
441
+ *
442
+ * If NONE of the above is present (a non-vitest, non-TAP failure, or empty output), fall back
443
+ * to the prior `firstLine` behavior, marked `(no test-runner summary recognised)` so a reader
444
+ * knows the detail is a guess, not a parsed summary — UNLESS `firstLine` itself is empty (no
445
+ * output at all), in which case the mark would manufacture a synthetic line where none existed
446
+ * and is withheld. Capped at 600 chars — a detail line, not a dump.
447
+ */
448
+ export function testsFailureDetail(stdout, stderr) {
449
+ const all = stripAnsi(`${stdout == null ? '' : String(stdout)}\n${stderr == null ? '' : String(stderr)}`);
450
+ const lines = all
451
+ .split('\n')
452
+ .map((l) => l.trim())
453
+ .filter((l) => l.length > 0);
454
+ const summaryLines = lines.filter((l) => /^(Tests|Test Files)\b/.test(l));
455
+ const failLines = lines.filter((l) => /^(FAIL\b|×|❯)/.test(l)).slice(0, 5);
456
+ const tapNotOkLines = lines.filter((l) => /^not ok \d+/.test(l)).slice(0, 5);
457
+ const tapFailCountLines = lines.filter((l) => /^# fail \d+/i.test(l));
458
+ const parts = [...summaryLines, ...failLines, ...tapNotOkLines, ...tapFailCountLines];
459
+ if (parts.length === 0) {
460
+ // lead r2: the fallback is derived from the ANSI-STRIPPED text, never the raw stream
461
+ const fl = lines[0] ?? '';
462
+ return fl.length === 0 ? '' : `${fl.slice(0, 200)} (no test-runner summary recognised)`;
463
+ }
464
+ return parts.join(' — ').slice(0, 600);
465
+ }
466
+ /** Truncate `s` to at most `maxBytes` UTF-8 bytes, never splitting a multi-byte character. */
467
+ function truncateToBytes(s, maxBytes) {
468
+ if (maxBytes <= 0)
469
+ return '';
470
+ const buf = Buffer.from(s, 'utf-8');
471
+ if (buf.length <= maxBytes)
472
+ return s;
473
+ let end = maxBytes;
474
+ // back off while the next byte is a UTF-8 continuation byte (10xxxxxx)
475
+ while (end > 0 && (buf[end] & 0xc0) === 0x80)
476
+ end -= 1;
477
+ return buf.subarray(0, end).toString('utf-8');
478
+ }
479
+ /**
480
+ * Feature release-gate-output-tail (FR-2/FR-3, amended AM-3): the last non-empty lines of ONE
481
+ * stream (call separately for stdout and stderr — AM-4), bounded on BOTH axes (line count and
482
+ * byte size) so a runaway suite cannot blow up a report or an issue body.
483
+ *
484
+ * AM-3 bounds, each an explicit branch rather than an emergent `Array.slice(-0)` accident
485
+ * (`slice(-0)` returns the WHOLE array, not `[]` — the pre-amendment bug):
486
+ * - `maxLines <= 0` → `''`; `maxBytes <= 0` → `''`.
487
+ * - Whole-line selection: lines are pulled from the END while the running BYTE total (each
488
+ * line's UTF-8 byte length plus its joining `\n`) stays `<= maxBytes` — never a partial line.
489
+ * - A single most-recent line that ALONE exceeds `maxBytes` is truncated at a UTF-8 CHARACTER
490
+ * boundary (never splitting a multi-byte codepoint) and marked `… (line truncated)`.
491
+ *
492
+ * Empty/whitespace-only output → `''` (never a synthetic line).
493
+ */
494
+ export function outputTail(stdout, stderr, maxLines = 40, maxBytes = 8192) {
495
+ if (maxLines <= 0 || maxBytes <= 0)
496
+ return '';
497
+ const all = `${stdout == null ? '' : String(stdout)}\n${stderr == null ? '' : String(stderr)}`;
498
+ const nonEmpty = all
499
+ .split('\n')
500
+ .map((l) => l.replace(/\r$/, ''))
501
+ .filter((l) => l.trim().length > 0);
502
+ const tailLines = nonEmpty.slice(-maxLines);
503
+ if (tailLines.length === 0)
504
+ return '';
505
+ const lastLine = tailLines[tailLines.length - 1];
506
+ if (Buffer.byteLength(lastLine, 'utf-8') > maxBytes) {
507
+ // lead r2: the marker lives INSIDE the byte budget, so the returned text never exceeds maxBytes
508
+ const marker = '… (line truncated)';
509
+ const room = Math.max(0, maxBytes - Buffer.byteLength(marker, 'utf-8'));
510
+ return `${truncateToBytes(lastLine, room)}${marker}`;
511
+ }
512
+ const selected = [];
513
+ let bytes = 0;
514
+ for (let i = tailLines.length - 1; i >= 0; i--) {
515
+ const line = tailLines[i];
516
+ const lineBytes = Buffer.byteLength(line, 'utf-8');
517
+ const joinerBytes = selected.length > 0 ? 1 : 0; // the '\n' this line adds once prepended
518
+ if (bytes + lineBytes + joinerBytes > maxBytes)
519
+ break;
520
+ selected.unshift(line);
521
+ bytes += lineBytes + joinerBytes;
522
+ }
523
+ return selected.join('\n');
524
+ }
525
+ /**
526
+ * Feature release-gate-output-tail (AM-1): redact secret-shaped substrings before ANY tail text
527
+ * reaches a GitHub issue body. Patterns, each independently redacted:
528
+ * - `token`/`secret`/`password` (case-insensitive) as a `key: value` or `key=value` pair — the
529
+ * KEY survives, only the value is replaced;
530
+ * - `Bearer <token>` HTTP auth headers;
531
+ * - vendor-prefixed tokens: `npm_…`, `ghp_…`, `sk-…`, `AKIA…`;
532
+ * - long opaque strings (base64/hex-ish, `[A-Za-z0-9+/=]{32,}`) that look like a key/secret even
533
+ * without a recognisable prefix.
534
+ * Order matters: prefixed/labelled patterns run BEFORE the generic long-opaque-string pattern so
535
+ * a `Bearer …` token is redacted as a whole rather than surviving as a shorter unlabelled blob.
536
+ */
537
+ export function redactSecrets(text) {
538
+ let out = text;
539
+ out = out.replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]');
540
+ out = out.replace(/\bnpm_[A-Za-z0-9]+/g, '[redacted]');
541
+ out = out.replace(/\bghp_[A-Za-z0-9]+/g, '[redacted]');
542
+ out = out.replace(/\bsk-[A-Za-z0-9]+/g, '[redacted]');
543
+ out = out.replace(/\bAKIA[A-Za-z0-9]+/g, '[redacted]');
544
+ out = out.replace(/\b(token|secret|password)(\s*[:=]\s*)(\S+)/gi, '$1$2[redacted]');
545
+ out = out.replace(/\b[A-Za-z0-9+/=]{32,}\b/g, '[redacted]');
546
+ return out;
547
+ }
377
548
  /**
378
549
  * Merge plan + executions into the {@link ReleaseVerdict} — the single fail-closed decision
379
550
  * point (ADR load-bearing property):
@@ -389,6 +560,27 @@ export function classifyGateExecutions(plan, executions, now = new Date()) {
389
560
  if (e != null && typeof e.stepId === 'string')
390
561
  byId.set(e.stepId, e);
391
562
  }
563
+ // AM-7: `bin-exists`/`bin-version` packed-install steps are judged through
564
+ // `judgePackedInstallSmoke` — the SAME rule `dz publish` applies (exit 0 AND non-empty stdout,
565
+ // AND the declared bin must exist post-install) — instead of the generic exit-code-only check
566
+ // every other step gets. The generic loop below SKIPS these step ids; the judged verdict is
567
+ // folded into the 'smoke' gate's failures/passed count after the loop.
568
+ const packedInstallPlan = plan?.packedInstallPlan;
569
+ const packedInstallBinStepIds = new Set((packedInstallPlan?.steps ?? []).filter((s) => s.kind === 'bin-exists' || s.kind === 'bin-version').map((s) => `smoke:packed-install:${s.id}`));
570
+ let packedInstallVerdict;
571
+ if (packedInstallPlan !== undefined) {
572
+ const prefix = 'smoke:packed-install:';
573
+ const translated = (executions ?? [])
574
+ .filter((e) => e != null && typeof e.stepId === 'string' && e.stepId.startsWith(prefix))
575
+ .map((e) => ({
576
+ stepId: e.stepId.slice(prefix.length),
577
+ exitCode: e.exitCode,
578
+ stdout: e.stdout ?? '',
579
+ stderr: e.stderr ?? '',
580
+ ...(e.timedOut !== undefined ? { timedOut: e.timedOut } : {}),
581
+ }));
582
+ packedInstallVerdict = judgePackedInstallSmoke(packedInstallPlan, translated);
583
+ }
392
584
  const gates = RELEASE_GATE_ORDER.map((gate) => {
393
585
  const gateSteps = (plan?.steps ?? []).filter((s) => s?.gate === gate);
394
586
  const gateSkips = (plan?.skips ?? []).filter((s) => s?.gate === gate);
@@ -396,6 +588,8 @@ export function classifyGateExecutions(plan, executions, now = new Date()) {
396
588
  let passed = 0;
397
589
  for (const step of gateSteps) {
398
590
  try {
591
+ if (packedInstallBinStepIds.has(step.id))
592
+ continue; // judged separately below (AM-7)
399
593
  if (step.kind === 'synthetic-fail') {
400
594
  failures.push({ pkg: step.pkg, reason: step.reason, class: step.failClass ?? 'EXIT_NONZERO' });
401
595
  continue;
@@ -414,6 +608,9 @@ export function classifyGateExecutions(plan, executions, now = new Date()) {
414
608
  pkg: step.pkg,
415
609
  reason: `timed out after ${step.timeoutMs}ms: ${step.cmd}`,
416
610
  class: gate === 'smoke' ? 'SMOKE_TIMEOUT' : 'TIMEOUT',
611
+ // FR-3 / AM-4: a killed-by-timeout step still has whatever it printed before the
612
+ // kill — captured per-stream, never merged (see GateFailure.tails doc comment).
613
+ tails: { stdout: outputTail(exec.stdout, undefined), stderr: outputTail(undefined, exec.stderr) },
417
614
  });
418
615
  continue;
419
616
  }
@@ -424,10 +621,15 @@ export function classifyGateExecutions(plan, executions, now = new Date()) {
424
621
  failures.push({ pkg: step.pkg, reason: `${reason}${detail ? ` — ${detail}` : ''}`, class: cls });
425
622
  }
426
623
  else {
624
+ // FR-1: for tests/syntax/smoke, name the ACTUAL failure (summary + failing tests),
625
+ // not just the first output line — see testsFailureDetail's doc comment for why.
626
+ const detail = testsFailureDetail(exec.stderr, exec.stdout);
427
627
  failures.push({
428
628
  pkg: step.pkg,
429
- reason: `exit ${String(exec.exitCode)}: ${step.cmd}${firstLine(exec.stderr, exec.stdout) ? ` — ${firstLine(exec.stderr, exec.stdout)}` : ''}`,
629
+ reason: `exit ${String(exec.exitCode)}: ${step.cmd}${detail ? ` — ${detail}` : ''}`,
430
630
  class: 'EXIT_NONZERO',
631
+ // AM-4: per-stream tails, never merged — see GateFailure.tails doc comment.
632
+ tails: { stdout: outputTail(exec.stdout, undefined), stderr: outputTail(undefined, exec.stderr) },
431
633
  });
432
634
  }
433
635
  continue;
@@ -439,6 +641,22 @@ export function classifyGateExecutions(plan, executions, now = new Date()) {
439
641
  failures.push({ pkg: step?.pkg, reason: 'unclassifiable step/execution record', class: 'EXIT_NONZERO' });
440
642
  }
441
643
  }
644
+ // AM-7: fold the packed-install bin verdicts (judged via judgePackedInstallSmoke, above) into
645
+ // the 'smoke' gate — the ONLY gate that ever plans packed-install steps.
646
+ if (gate === 'smoke' && packedInstallVerdict !== undefined) {
647
+ for (const bin of packedInstallVerdict.bins) {
648
+ if (bin.ok) {
649
+ passed += 1;
650
+ }
651
+ else {
652
+ failures.push({
653
+ pkg: bin.pkg,
654
+ reason: `packed-install bin "${bin.binName}" ${bin.detail ?? 'failed'}`,
655
+ class: 'EXIT_NONZERO',
656
+ });
657
+ }
658
+ }
659
+ }
442
660
  const status = failures.length > 0 ? 'fail' : passed > 0 ? 'pass' : 'skip';
443
661
  return { gate, status, passed, failures, skips: gateSkips };
444
662
  });
@@ -458,34 +676,112 @@ export function classifyGateExecutions(plan, executions, now = new Date()) {
458
676
  timestamp: now.toISOString(),
459
677
  };
460
678
  }
679
+ /** AM-1: total issue-body cap — a courier never balloons into an unpostable payload. */
680
+ const MAX_ISSUE_BODY_BYTES = 60 * 1024;
681
+ /**
682
+ * AM-5: fence `text` so the payload can never prematurely close the code block — the fence is
683
+ * N+1 backticks, where N is the LONGEST run of consecutive backticks already present in `text`.
684
+ * Every content line (and the fence itself) carries `indent` so a multi-line block renders as a
685
+ * continuation of the enclosing markdown list item, not as a sibling paragraph.
686
+ */
687
+ function fencedBlock(text, indent = ' ') {
688
+ const runs = text.match(/`+/g) ?? [];
689
+ const longestRun = runs.reduce((m, r) => Math.max(m, r.length), 0);
690
+ // GFM needs >= 3 backticks for a FENCED (block) code fence — fewer reads as inline code.
691
+ const fence = '`'.repeat(Math.max(3, longestRun + 1));
692
+ const contentLines = text.split('\n').map((l) => `${indent}${l}`);
693
+ return [`${indent}${fence}`, ...contentLines, `${indent}${fence}`];
694
+ }
461
695
  /**
462
696
  * gh-2.4-safe `gh issue create` payload (only `--title`/`--body` are assumed downstream).
463
697
  * Pure + deterministic for a fixed verdict — the issue is the verdict's echo, never its judge.
698
+ *
699
+ * AM-1/AM-4/AM-5: every tail is (a) redacted (secret-shaped substrings replaced — see
700
+ * {@link redactSecrets}) and ANSI-stripped BEFORE it is ever considered for the body; (b) shown
701
+ * per STREAM, labelled `stdout:`/`stderr:` — AM-4's scope note applies here too: the two labelled
702
+ * blocks do NOT reconstruct chronological interleaving between the streams; (c) fenced so the
703
+ * payload cannot break out of its code block; (d) the WHOLE body is capped at
704
+ * {@link MAX_ISSUE_BODY_BYTES} — when it would exceed the cap, every tail is shrunk EVENLY
705
+ * (byte-proportional), not by dropping some tails whole while keeping others untouched.
464
706
  */
465
707
  export function buildFailureIssue(verdict, ctx = {}) {
466
708
  const failed = verdict.gates.filter((g) => g.status === 'fail').map((g) => g.gate);
467
709
  const title = `dz release: gate failure — ${failed.length > 0 ? failed.join(', ') : 'nothing verified'}`;
468
- const lines = [
469
- `Verified release blocked at ${verdict.timestamp}.`,
470
- '',
471
- ...(ctx.invocation ? [`Invocation: \`${ctx.invocation}\``, ''] : []),
472
- ...(ctx.repo ? [`Repo: ${ctx.repo}`, ''] : []),
473
- '## Gate verdict',
474
- '',
475
- ];
710
+ const refsByFailure = new Map();
476
711
  for (const g of verdict.gates) {
477
- const icon = g.status === 'pass' ? '✓' : g.status === 'fail' ? '✗' : '○';
478
- lines.push(`- ${icon} **${g.gate}** — ${g.status} (${g.passed} passed, ${g.failures.length} failed, ${g.skips.length} skipped)`);
479
- for (const f of g.failures)
480
- lines.push(` - [${f.class}] ${f.pkg ? `${f.pkg}: ` : ''}${f.reason}`);
712
+ for (const f of g.failures) {
713
+ if (f.tails === undefined)
714
+ continue;
715
+ const refs = [];
716
+ for (const stream of ['stdout', 'stderr']) {
717
+ const raw = f.tails[stream];
718
+ if (raw.length === 0)
719
+ continue;
720
+ const clean = redactSecrets(stripAnsi(raw));
721
+ refs.push({ stream, text: clean, rawBytes: Buffer.byteLength(clean, 'utf-8') });
722
+ }
723
+ if (refs.length > 0)
724
+ refsByFailure.set(f, refs);
725
+ }
481
726
  }
482
- if (verdict.skipped.length > 0) {
483
- lines.push('', '## Skipped (honestly reported, never counted as passed)', '');
484
- for (const s of verdict.skipped)
485
- lines.push(`- [${s.class}] ${s.pkg}: ${s.reason}`);
727
+ const render = () => {
728
+ const lines = [
729
+ `Verified release blocked at ${verdict.timestamp}.`,
730
+ '',
731
+ ...(ctx.invocation ? [`Invocation: \`${redactSecrets(stripAnsi(ctx.invocation)).replace(/`/g, "'")}\``, ''] : []),
732
+ ...(ctx.repo ? [`Repo: ${ctx.repo}`, ''] : []),
733
+ '## Gate verdict',
734
+ '',
735
+ ];
736
+ for (const g of verdict.gates) {
737
+ const icon = g.status === 'pass' ? '✓' : g.status === 'fail' ? '✗' : '○';
738
+ lines.push(`- ${icon} **${g.gate}** — ${g.status} (${g.passed} passed, ${g.failures.length} failed, ${g.skips.length} skipped)`);
739
+ for (const f of g.failures) {
740
+ // lead r2 (HIGH): the reason is output-derived free text — strip ANSI and redact it like a tail
741
+ lines.push(` - [${f.class}] ${f.pkg ? `${f.pkg}: ` : ''}${redactSecrets(stripAnsi(f.reason))}`);
742
+ // FR-2 / AM-4 / AM-5: a labelled, fenced block per non-empty stream — the issue is the
743
+ // echo of the verdict, so a reader can see the actual failing output without re-running.
744
+ for (const ref of refsByFailure.get(f) ?? []) {
745
+ lines.push(` ${ref.stream}:`, ...fencedBlock(ref.text));
746
+ }
747
+ }
748
+ }
749
+ if (verdict.skipped.length > 0) {
750
+ lines.push('', '## Skipped (honestly reported, never counted as passed)', '');
751
+ for (const s of verdict.skipped)
752
+ lines.push(`- [${s.class}] ${s.pkg}: ${s.reason}`);
753
+ }
754
+ lines.push('', `Blocked by: ${verdict.blockedBy.join('; ')}`, '', '_Auto-created by `dz release` (best-effort; the release verdict is independent of this issue)._');
755
+ return lines.join('\n');
756
+ };
757
+ let body = render();
758
+ let bodyBytes = Buffer.byteLength(body, 'utf-8');
759
+ if (bodyBytes > MAX_ISSUE_BODY_BYTES && refsByFailure.size > 0) {
760
+ const allRefs = [...refsByFailure.values()].flat();
761
+ let overage = bodyBytes - MAX_ISSUE_BODY_BYTES;
762
+ // Bounded iteration: each pass's cut is based on the LATEST measured overage (markup like
763
+ // "… (truncated)" adds a few bytes back per ref, so one pass rarely lands exactly) — a few
764
+ // passes converge; the safety net below closes any pathological remainder.
765
+ for (let pass = 0; pass < 3 && overage > 0; pass++) {
766
+ const perRefCut = Math.ceil(overage / allRefs.length);
767
+ for (const ref of allRefs) {
768
+ const targetBytes = Math.max(0, ref.rawBytes - perRefCut);
769
+ if (Buffer.byteLength(ref.text, 'utf-8') > targetBytes) {
770
+ ref.text = `${truncateToBytes(ref.text, targetBytes)}… (truncated)`;
771
+ }
772
+ }
773
+ body = render();
774
+ bodyBytes = Buffer.byteLength(body, 'utf-8');
775
+ overage = bodyBytes - MAX_ISSUE_BODY_BYTES;
776
+ }
777
+ // Safety net: a pathological shape (a huge non-tail skeleton, tiny/no tails) can still exceed
778
+ // the cap after every tail is wiped — hard-truncate the whole body as the last resort so the
779
+ // cap is an INVARIANT, never a best-effort.
780
+ if (bodyBytes > MAX_ISSUE_BODY_BYTES) {
781
+ body = `${truncateToBytes(body, MAX_ISSUE_BODY_BYTES - 20)}\n… (truncated)`;
782
+ }
486
783
  }
487
- lines.push('', `Blocked by: ${verdict.blockedBy.join('; ')}`, '', '_Auto-created by `dz release` (best-effort; the release verdict is independent of this issue)._');
488
- return { title, body: lines.join('\n') };
784
+ return { title, body };
489
785
  }
490
786
  /** Short, bounded release notes from injected `git log --oneline`-style lines. */
491
787
  export function buildReleaseNotes(gitLogLines, limit = 15) {