@dzhechkov/harness-core 0.8.33 → 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.
@@ -28,10 +28,24 @@
28
28
 
29
29
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
30
30
  import { createHash } from 'node:crypto';
31
- import { join, relative } from 'node:path';
31
+ import { join, relative, isAbsolute } from 'node:path';
32
32
 
33
33
  export type SiblingDriftStatus = 'same' | 'drift' | 'unavailable';
34
34
 
35
+ /**
36
+ * AM-6 (feature publish-gate-audit-durable): which mechanism produced BOTH sides' file inventory
37
+ * for this comparison — named on every result, never left implicit. `'npm-pack'`: the caller
38
+ * injected {@link DetectSiblingDriftOptions.localInventory} (the CLI's `npm pack --dry-run --json`
39
+ * via {@link parseNpmPackInventory}); the workspace side is exactly what npm will ship, and the
40
+ * published side is hashed by a FULL recursive walk of the already-unpacked tarball (AM-1 — the
41
+ * two sides must be symmetric: "every file npm put there" on one side, "every file npm will put
42
+ * there" on the other). `'readdir-approximation'`: no provider was injected — BOTH sides fall back
43
+ * to the pre-existing `dist`/`files`/`bin` walk ({@link shippedInventoryDirs}), which stays
44
+ * symmetric by construction (same function, same rules, both sides) but can miss a file
45
+ * `.npmignore` excludes or include one npm would never ship.
46
+ */
47
+ export type InventorySource = 'npm-pack' | 'pnpm-pack' | 'readdir-approximation';
48
+
35
49
  export interface SiblingDriftResult {
36
50
  readonly name: string;
37
51
  readonly version: string;
@@ -42,6 +56,12 @@ export interface SiblingDriftResult {
42
56
  readonly missingExports: readonly string[];
43
57
  /** Present only when status === 'unavailable'. */
44
58
  readonly reason?: string;
59
+ /**
60
+ * AM-6: named per-result (not merely per-call) because `detectSiblingDrift` short-circuits to
61
+ * `'unavailable'` before ever reaching the hashing step for some entries — those still carry the
62
+ * source that WOULD have been used, so a reader never has to guess.
63
+ */
64
+ readonly inventorySource: InventorySource;
45
65
  }
46
66
 
47
67
  export interface FetchedPublished {
@@ -65,6 +85,21 @@ export interface DetectSiblingDriftOptions {
65
85
  /** Names being published in THIS batch — they publish fresh, so drift cannot be measured against them. */
66
86
  readonly batch: ReadonlySet<string>;
67
87
  readonly fetchPublished: FetchPublished;
88
+ /**
89
+ * FR-3 (feature publish-gate-audit-durable): the LOCAL (workspace) package's shipped-file
90
+ * inventory, asked from npm instead of approximated by walking `dist`/`files`/`bin` by hand —
91
+ * `.npmignore` and nested ignore rules make the hand-rolled walk wrong in both directions (a file
92
+ * npm will never ship can still be read off disk, producing a false drift). No production default
93
+ * lives in THIS module — core stays pure (never spawns `npm`, per the core-boundary import
94
+ * ratchet). The CLI runs `npm pack --dry-run --json` and hands the stdout to
95
+ * {@link parseNpmPackInventory}, then passes the resulting closure here; a caller that injects
96
+ * nothing (`undefined`) makes `detectSiblingDrift` fall back to the named
97
+ * `'readdir-approximation'` {@link InventorySource} on BOTH sides (AM-1) — never a silent "no
98
+ * drift".
99
+ */
100
+ readonly localInventory?: LocalInventory;
101
+ /** Label for the injected provider's source (default `'npm-pack'`); the CLI passes `'pnpm-pack'` for a packed tree. */
102
+ readonly localInventorySource?: InventorySource;
68
103
  }
69
104
 
70
105
  function listFilesRecursive(root: string, dir: string): string[] {
@@ -83,6 +118,120 @@ function sha256(data: string | Buffer): string {
83
118
  return createHash('sha256').update(data).digest('hex');
84
119
  }
85
120
 
121
+ // ── FR-3 (feature publish-gate-audit-durable): ask npm, don't approximate ──────────────────────
122
+
123
+ /** The exact set of relative paths `npm pack` will ship for a package — no `.npmignore` guessing. */
124
+ export interface PackInventory {
125
+ readonly paths: readonly string[];
126
+ }
127
+
128
+ /** `npm pack --dry-run --json` could not be run or answered in a shape this code cannot use. */
129
+ export interface PackInventoryUnavailable {
130
+ readonly unavailable: string;
131
+ }
132
+
133
+ /**
134
+ * Lead fix after the fix-round's live dry-run (2026-09-14 01:02): the workspace side PACKED BY THE
135
+ * LIVE TRANSPORT (`pnpm pack`) and unpacked into `packedDir`. pnpm synthesises a LICENSE from the
136
+ * workspace root into the tarball of a package whose own tree has none; `npm pack --dry-run --json`
137
+ * never lists that file, so a `paths` inventory read every such sibling as "LICENSE only in the
138
+ * published copy" — 2 false drifts (harness-presets, scout) on a tree unchanged since publication.
139
+ * A packed tree is hashed by the SAME full walk as the published side, symmetric by construction.
140
+ */
141
+ export interface PackedTree {
142
+ readonly packedDir: string;
143
+ }
144
+
145
+ export type LocalInventoryResult = PackInventory | PackedTree | PackInventoryUnavailable;
146
+
147
+ /** Ask what npm would ship for the package rooted at `dir`. Injected in tests (no subprocess). */
148
+ export type LocalInventory = (dir: string) => LocalInventoryResult;
149
+
150
+ /**
151
+ * C-1/AM-4: `npm pack --dry-run --json` is a real subprocess call — the CLI caches its result per
152
+ * absolute directory for the lifetime of ONE `dz publish` run (not per package being checked), so
153
+ * a run that checks the same sibling from more than one dependent package packs it only once. Core
154
+ * itself never runs the subprocess or owns the cache (core-boundary import ratchet) — this parser
155
+ * is the pure half only.
156
+ *
157
+ * Parses `npm pack --dry-run --json`'s stdout (an array with one element; `files[]` holds
158
+ * `{path,size,mode}` per shipped path, plus `integrity`/`shasum`/`entryCount`) into the exact set of
159
+ * relative paths npm intends to ship, honouring `.npmignore`/`files`/default-ignore exactly the way
160
+ * a real `npm publish` would. A failure to run, parse, or make sense of the shape — including a
161
+ * malformed individual `files[]` element (AM-5: a corrupt entry is a reason to say the WHOLE
162
+ * inventory is untrustworthy, never a file to silently drop) — is `{ unavailable: reason }`: an
163
+ * input this gate cannot read is a reason to say so, never a silent "nothing to compare".
164
+ */
165
+ export function parseNpmPackInventory(stdout: string): LocalInventoryResult {
166
+ try {
167
+ const parsed: unknown = JSON.parse(stdout);
168
+ const entry = Array.isArray(parsed) ? (parsed[0] as unknown) : undefined;
169
+ const files = entry !== null && typeof entry === 'object' ? (entry as Record<string, unknown>)['files'] : undefined;
170
+ if (!Array.isArray(files)) return { unavailable: 'npm pack --dry-run --json returned no files[] array' };
171
+ // AM-5 (Codex round-1 finding 6, medium): a malformed element used to be `.filter()`ed out
172
+ // silently — a `files[]` entry npm itself always shapes as `{path,size,mode}` should never fail
173
+ // to parse; if one DOES (missing/non-string `path`, or a non-object element), that is a signal
174
+ // this output cannot be trusted, not a single file to quietly drop from the comparison. Say so.
175
+ const paths: string[] = [];
176
+ for (let i = 0; i < files.length; i++) {
177
+ const f = files[i];
178
+ if (f === null || typeof f !== 'object') {
179
+ return { unavailable: `npm pack --dry-run --json files[${i}] is not an object (got ${JSON.stringify(f)})` };
180
+ }
181
+ const path = (f as Record<string, unknown>)['path'];
182
+ if (typeof path !== 'string' || path === '') {
183
+ return { unavailable: `npm pack --dry-run --json files[${i}].path is missing or not a non-empty string (got ${JSON.stringify(path)})` };
184
+ }
185
+ paths.push(path);
186
+ }
187
+ return { paths };
188
+ } catch (err) {
189
+ return { unavailable: `npm pack --dry-run --json output could not be parsed: ${(err as Error).message.split('\n')[0]}` };
190
+ }
191
+ }
192
+
193
+ /** Hash exactly the paths `npm pack` names (package.json normalized separately, as {@link hashTree} does). */
194
+ /**
195
+ * Codex round-2 (2026-09-14) new findings 1+2: a listed path that is absent, a directory, absolute,
196
+ * or that climbs out of `dir` via `..` used to be SKIPPED silently — a comparison over a listing
197
+ * the tree does not match is not a comparison, it is `unavailable`; and an inventory must never
198
+ * read outside the package directory. Thrown here, turned into an `unavailable` result by the caller.
199
+ */
200
+ class InventoryListingError extends Error {}
201
+
202
+ function hashTreeFromPaths(dir: string, paths: readonly string[], manifest: Record<string, unknown>): Map<string, string> {
203
+ const map = new Map<string, string>();
204
+ for (const rel of paths) {
205
+ if (rel === 'package.json') continue; // normalized below, not hashed raw
206
+ if (isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) {
207
+ throw new InventoryListingError(`inventory path "${rel}" is absolute or leaves the package directory`);
208
+ }
209
+ const abs = join(dir, rel);
210
+ if (!existsSync(abs)) throw new InventoryListingError(`inventory path "${rel}" does not exist in the workspace copy`);
211
+ if (statSync(abs).isDirectory()) throw new InventoryListingError(`inventory path "${rel}" is a directory, not a file`);
212
+ map.set(rel, sha256(readFileSync(abs)));
213
+ }
214
+ map.set('package.json', sha256(normalizedPackageJsonText(manifest)));
215
+ return map;
216
+ }
217
+
218
+ /**
219
+ * AM-1: hash EVERY file under `dir` (the already-unpacked published tarball) — the literal "full
220
+ * recursive walk of what npm put there" the amendment names, used ONLY as the symmetric partner to
221
+ * {@link hashTreeFromPaths} (i.e. only when a `localInventory` provider is injected). `dir` here is
222
+ * always an extracted tarball, never the workspace tree, so there is no `.npmignore` to consult:
223
+ * everything that exists on disk is, by construction, exactly what npm shipped.
224
+ */
225
+ function hashTreeFull(dir: string, manifest: Record<string, unknown>): Map<string, string> {
226
+ const map = new Map<string, string>();
227
+ for (const rel of listFilesRecursive(dir, dir)) {
228
+ if (rel === 'package.json') continue; // normalized below, not hashed raw
229
+ map.set(rel, sha256(readFileSync(join(dir, rel))));
230
+ }
231
+ map.set('package.json', sha256(normalizedPackageJsonText(manifest)));
232
+ return map;
233
+ }
234
+
86
235
  /**
87
236
  * package.json PARSED and validated. `null` (never `undefined`) means "this side cannot be built
88
237
  * at all" — AM-3: a missing or unparseable manifest on EITHER side must surface as `unavailable`,
@@ -208,6 +357,9 @@ function missingExportNames(publishedDir: string, workspaceDir: string): string[
208
357
  export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDriftResult[] {
209
358
  const results: SiblingDriftResult[] = [];
210
359
  const seen = new Set<string>();
360
+ // AM-6: named ONCE per call — every result below (including the short-circuited `unavailable`
361
+ // ones) carries the source that is or would have been used for this comparison.
362
+ const inventorySource: InventorySource = opts.localInventory !== undefined ? (opts.localInventorySource ?? 'npm-pack') : 'readdir-approximation';
211
363
  // AM-3: `optionalDependencies` ships and pins EXACTLY like `dependencies`/`peerDependencies` —
212
364
  // checking only the first two let a stale optional sibling through untouched (round-1 finding 3).
213
365
  const entries = [
@@ -233,6 +385,7 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
233
385
  changedFiles: [],
234
386
  missingExports: [],
235
387
  reason: `${dep} is declared workspace:-protocol but is not a known workspace package`,
388
+ inventorySource,
236
389
  });
237
390
  continue;
238
391
  }
@@ -246,6 +399,7 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
246
399
  changedFiles: [],
247
400
  missingExports: [],
248
401
  reason: `could not fetch ${dep}@${version} from the registry (network unavailable or the version was not found)`,
402
+ inventorySource,
249
403
  });
250
404
  continue;
251
405
  }
@@ -264,12 +418,62 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
264
418
  changedFiles: [],
265
419
  missingExports: [],
266
420
  reason: `${dep}@${version}: package.json in ${side} is missing or not valid JSON — cannot compare`,
421
+ inventorySource,
267
422
  });
268
423
  continue;
269
424
  }
270
425
 
271
- const publishedHashes = hashTree(fetched.dir, publishedManifest);
272
- const workspaceHashes = hashTree(workspaceDir, workspaceManifest);
426
+ // FR-3/AM-1: the LOCAL package's inventory comes from npm, not from a hand-rolled dist/files/bin
427
+ // walk — `.npmignore` (and nested ignore rules) can exclude a file this gate would otherwise walk
428
+ // straight into, producing a false drift about a file npm was never going to ship. AM-1 (Codex
429
+ // review, round-1 finding 3, high): the two sides must stay SYMMETRIC. With a provider injected,
430
+ // the workspace side is npm's OWN shipped-path list; the published side must then be hashed by a
431
+ // FULL recursive walk of the already-unpacked tarball (every file npm actually put there —
432
+ // README/LICENSE included, since npm auto-packs those regardless of `files`), not the narrower
433
+ // `dist`/`files`/`bin` approximation `hashTree` uses — that approximation would silently OMIT an
434
+ // auto-packed README/LICENSE from the published side while the workspace side (via real `npm
435
+ // pack`) correctly includes them, reading as a false "only in workspace" drift. WITHOUT a
436
+ // provider, core has no way to ask npm on either side, so it degrades to the SAME approximation
437
+ // on BOTH sides (symmetry preserved, just cruder) — a named approximation, never a subprocess.
438
+ let workspaceHashes: Map<string, string>;
439
+ let publishedHashes: Map<string, string>;
440
+ if (opts.localInventory !== undefined) {
441
+ const localResult = opts.localInventory(workspaceDir);
442
+ if ('unavailable' in localResult) {
443
+ results.push({
444
+ name: dep,
445
+ version,
446
+ status: 'unavailable',
447
+ changedFiles: [],
448
+ missingExports: [],
449
+ reason: `${dep}@${version}: local package inventory unavailable (${localResult.unavailable})`,
450
+ inventorySource,
451
+ });
452
+ continue;
453
+ }
454
+ try {
455
+ workspaceHashes = 'packedDir' in localResult
456
+ ? hashTreeFull(localResult.packedDir, workspaceManifest)
457
+ : hashTreeFromPaths(workspaceDir, localResult.paths, workspaceManifest);
458
+ } catch (err) {
459
+ if (!(err instanceof InventoryListingError)) throw err;
460
+ results.push({
461
+ name: dep,
462
+ version,
463
+ status: 'unavailable',
464
+ changedFiles: [],
465
+ missingExports: [],
466
+ reason: `${dep}@${version}: local package inventory unusable (${err.message})`,
467
+ inventorySource,
468
+ });
469
+ continue;
470
+ }
471
+ publishedHashes = hashTreeFull(fetched.dir, publishedManifest);
472
+ } else {
473
+ workspaceHashes = hashTree(workspaceDir, workspaceManifest);
474
+ publishedHashes = hashTree(fetched.dir, publishedManifest);
475
+ }
476
+
273
477
  const allKeys = new Set<string>([...publishedHashes.keys(), ...workspaceHashes.keys()]);
274
478
  const changed: string[] = [];
275
479
  for (const key of allKeys) {
@@ -278,7 +482,7 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
278
482
  changed.sort();
279
483
 
280
484
  if (changed.length === 0) {
281
- results.push({ name: dep, version, status: 'same', changedFiles: [], missingExports: [] });
485
+ results.push({ name: dep, version, status: 'same', changedFiles: [], missingExports: [], inventorySource });
282
486
  } else {
283
487
  results.push({
284
488
  name: dep,
@@ -286,6 +490,7 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
286
490
  status: 'drift',
287
491
  changedFiles: changed,
288
492
  missingExports: missingExportNames(fetched.dir, workspaceDir),
493
+ inventorySource,
289
494
  });
290
495
  }
291
496
  }
package/src/release.ts CHANGED
@@ -142,6 +142,19 @@ export interface GateFailure {
142
142
  readonly pkg?: string | undefined;
143
143
  readonly reason: string;
144
144
  readonly class: ReleaseFailureClass;
145
+ /**
146
+ * Feature release-gate-output-tail (AM-4): the last non-empty lines of the step's stdout and
147
+ * stderr, KEPT SEPARATE — each stream through {@link outputTail} on its own, never merged —
148
+ * so a reader can tell which stream a line came from. Set for `tests`/`syntax`/`smoke`
149
+ * EXIT_NONZERO/TIMEOUT failures; absent for `audit` (its own detail line already summarizes)
150
+ * and for failures with no execution record (e.g. UNEXECUTED_STEP).
151
+ *
152
+ * Scope honesty (AM-4): the two streams are captured independently, so a printed/issued
153
+ * `stdout:`/`stderr:` pair does NOT reconstruct the chronological interleaving of the two
154
+ * streams as the process actually emitted them — only each stream's own tail order is
155
+ * preserved. Documented in the CLI README (AM-8), not silently implied.
156
+ */
157
+ readonly tails?: { readonly stdout: string; readonly stderr: string };
145
158
  }
146
159
 
147
160
  /** Per-gate verdict. `skip` = the gate had nothing to execute (still not a pass). */
@@ -600,6 +613,138 @@ function firstLine(...chunks: readonly unknown[]): string {
600
613
  return '';
601
614
  }
602
615
 
616
+ /**
617
+ * Strip ANSI/VT100 escape sequences (colour codes, cursor moves, OSC hyperlinks) so pattern
618
+ * matching sees the plain text a human reads on a non-colour terminal. AM-2/AM-1 precondition:
619
+ * `testsFailureDetail` and the issue-body redaction both run this FIRST, before any regex tries
620
+ * to recognise a runner's summary/FAIL lines or a secret value — a coloured `FAIL` token (e.g.
621
+ * `\x1b[31mFAIL\x1b[0m`) must still match `/^FAIL\b/` once stripped.
622
+ */
623
+ // eslint-disable-next-line no-control-regex -- deliberately matching raw ESC control bytes
624
+ function stripAnsi(s: string): string {
625
+ return s
626
+ .replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g, '') // OSC … BEL | OSC … ST
627
+ .replace(/\x1B[[()#;?]*[0-9]*(?:;[0-9]*)*[a-zA-Z@]/g, ''); // CSI/other short escapes
628
+ }
629
+
630
+ /**
631
+ * Feature release-gate-output-tail (FR-1, amended AM-2): a one-line-ish detail for a
632
+ * `tests`/`syntax`/`smoke` EXIT_NONZERO/TIMEOUT failure that names the ACTUAL failure — not
633
+ * just the first output line, which for `pnpm test`/vitest is routinely an unrelated
634
+ * vite/esbuild deprecation warning (MEASURED 2026-09-13 16:05/18:52).
635
+ *
636
+ * AM-2: ANSI escapes are stripped FIRST (a coloured runner must match the same patterns as a
637
+ * plain one). Recognised shapes, collected in this priority order and joined:
638
+ * 1. vitest summary lines (`Tests …`, `Test Files …`);
639
+ * 2. up to 5 `FAIL …` / `× …` / `❯ …` lines (failing test names/paths);
640
+ * 3. node:test (TAP) lines: `not ok N - name` and `# fail N`.
641
+ *
642
+ * If NONE of the above is present (a non-vitest, non-TAP failure, or empty output), fall back
643
+ * to the prior `firstLine` behavior, marked `(no test-runner summary recognised)` so a reader
644
+ * knows the detail is a guess, not a parsed summary — UNLESS `firstLine` itself is empty (no
645
+ * output at all), in which case the mark would manufacture a synthetic line where none existed
646
+ * and is withheld. Capped at 600 chars — a detail line, not a dump.
647
+ */
648
+ export function testsFailureDetail(stdout: unknown, stderr: unknown): string {
649
+ const all = stripAnsi(`${stdout == null ? '' : String(stdout)}\n${stderr == null ? '' : String(stderr)}`);
650
+ const lines = all
651
+ .split('\n')
652
+ .map((l) => l.trim())
653
+ .filter((l) => l.length > 0);
654
+ const summaryLines = lines.filter((l) => /^(Tests|Test Files)\b/.test(l));
655
+ const failLines = lines.filter((l) => /^(FAIL\b|×|❯)/.test(l)).slice(0, 5);
656
+ const tapNotOkLines = lines.filter((l) => /^not ok \d+/.test(l)).slice(0, 5);
657
+ const tapFailCountLines = lines.filter((l) => /^# fail \d+/i.test(l));
658
+ const parts = [...summaryLines, ...failLines, ...tapNotOkLines, ...tapFailCountLines];
659
+ if (parts.length === 0) {
660
+ // lead r2: the fallback is derived from the ANSI-STRIPPED text, never the raw stream
661
+ const fl = lines[0] ?? '';
662
+ return fl.length === 0 ? '' : `${fl.slice(0, 200)} (no test-runner summary recognised)`;
663
+ }
664
+ return parts.join(' — ').slice(0, 600);
665
+ }
666
+
667
+ /** Truncate `s` to at most `maxBytes` UTF-8 bytes, never splitting a multi-byte character. */
668
+ function truncateToBytes(s: string, maxBytes: number): string {
669
+ if (maxBytes <= 0) return '';
670
+ const buf = Buffer.from(s, 'utf-8');
671
+ if (buf.length <= maxBytes) return s;
672
+ let end = maxBytes;
673
+ // back off while the next byte is a UTF-8 continuation byte (10xxxxxx)
674
+ while (end > 0 && (buf[end]! & 0xc0) === 0x80) end -= 1;
675
+ return buf.subarray(0, end).toString('utf-8');
676
+ }
677
+
678
+ /**
679
+ * Feature release-gate-output-tail (FR-2/FR-3, amended AM-3): the last non-empty lines of ONE
680
+ * stream (call separately for stdout and stderr — AM-4), bounded on BOTH axes (line count and
681
+ * byte size) so a runaway suite cannot blow up a report or an issue body.
682
+ *
683
+ * AM-3 bounds, each an explicit branch rather than an emergent `Array.slice(-0)` accident
684
+ * (`slice(-0)` returns the WHOLE array, not `[]` — the pre-amendment bug):
685
+ * - `maxLines <= 0` → `''`; `maxBytes <= 0` → `''`.
686
+ * - Whole-line selection: lines are pulled from the END while the running BYTE total (each
687
+ * line's UTF-8 byte length plus its joining `\n`) stays `<= maxBytes` — never a partial line.
688
+ * - A single most-recent line that ALONE exceeds `maxBytes` is truncated at a UTF-8 CHARACTER
689
+ * boundary (never splitting a multi-byte codepoint) and marked `… (line truncated)`.
690
+ *
691
+ * Empty/whitespace-only output → `''` (never a synthetic line).
692
+ */
693
+ export function outputTail(stdout: unknown, stderr: unknown, maxLines = 40, maxBytes = 8192): string {
694
+ if (maxLines <= 0 || maxBytes <= 0) return '';
695
+ const all = `${stdout == null ? '' : String(stdout)}\n${stderr == null ? '' : String(stderr)}`;
696
+ const nonEmpty = all
697
+ .split('\n')
698
+ .map((l) => l.replace(/\r$/, ''))
699
+ .filter((l) => l.trim().length > 0);
700
+ const tailLines = nonEmpty.slice(-maxLines);
701
+ if (tailLines.length === 0) return '';
702
+
703
+ const lastLine = tailLines[tailLines.length - 1]!;
704
+ if (Buffer.byteLength(lastLine, 'utf-8') > maxBytes) {
705
+ // lead r2: the marker lives INSIDE the byte budget, so the returned text never exceeds maxBytes
706
+ const marker = '… (line truncated)';
707
+ const room = Math.max(0, maxBytes - Buffer.byteLength(marker, 'utf-8'));
708
+ return `${truncateToBytes(lastLine, room)}${marker}`;
709
+ }
710
+
711
+ const selected: string[] = [];
712
+ let bytes = 0;
713
+ for (let i = tailLines.length - 1; i >= 0; i--) {
714
+ const line = tailLines[i]!;
715
+ const lineBytes = Buffer.byteLength(line, 'utf-8');
716
+ const joinerBytes = selected.length > 0 ? 1 : 0; // the '\n' this line adds once prepended
717
+ if (bytes + lineBytes + joinerBytes > maxBytes) break;
718
+ selected.unshift(line);
719
+ bytes += lineBytes + joinerBytes;
720
+ }
721
+ return selected.join('\n');
722
+ }
723
+
724
+ /**
725
+ * Feature release-gate-output-tail (AM-1): redact secret-shaped substrings before ANY tail text
726
+ * reaches a GitHub issue body. Patterns, each independently redacted:
727
+ * - `token`/`secret`/`password` (case-insensitive) as a `key: value` or `key=value` pair — the
728
+ * KEY survives, only the value is replaced;
729
+ * - `Bearer <token>` HTTP auth headers;
730
+ * - vendor-prefixed tokens: `npm_…`, `ghp_…`, `sk-…`, `AKIA…`;
731
+ * - long opaque strings (base64/hex-ish, `[A-Za-z0-9+/=]{32,}`) that look like a key/secret even
732
+ * without a recognisable prefix.
733
+ * Order matters: prefixed/labelled patterns run BEFORE the generic long-opaque-string pattern so
734
+ * a `Bearer …` token is redacted as a whole rather than surviving as a shorter unlabelled blob.
735
+ */
736
+ export function redactSecrets(text: string): string {
737
+ let out = text;
738
+ out = out.replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]');
739
+ out = out.replace(/\bnpm_[A-Za-z0-9]+/g, '[redacted]');
740
+ out = out.replace(/\bghp_[A-Za-z0-9]+/g, '[redacted]');
741
+ out = out.replace(/\bsk-[A-Za-z0-9]+/g, '[redacted]');
742
+ out = out.replace(/\bAKIA[A-Za-z0-9]+/g, '[redacted]');
743
+ out = out.replace(/\b(token|secret|password)(\s*[:=]\s*)(\S+)/gi, '$1$2[redacted]');
744
+ out = out.replace(/\b[A-Za-z0-9+/=]{32,}\b/g, '[redacted]');
745
+ return out;
746
+ }
747
+
603
748
  /**
604
749
  * Merge plan + executions into the {@link ReleaseVerdict} — the single fail-closed decision
605
750
  * point (ADR load-bearing property):
@@ -670,6 +815,9 @@ export function classifyGateExecutions(
670
815
  pkg: step.pkg,
671
816
  reason: `timed out after ${step.timeoutMs}ms: ${step.cmd}`,
672
817
  class: gate === 'smoke' ? 'SMOKE_TIMEOUT' : 'TIMEOUT',
818
+ // FR-3 / AM-4: a killed-by-timeout step still has whatever it printed before the
819
+ // kill — captured per-stream, never merged (see GateFailure.tails doc comment).
820
+ tails: { stdout: outputTail(exec.stdout, undefined), stderr: outputTail(undefined, exec.stderr) },
673
821
  });
674
822
  continue;
675
823
  }
@@ -679,10 +827,15 @@ export function classifyGateExecutions(
679
827
  const detail = auditDetailLine(exec.stdout, exec.stderr);
680
828
  failures.push({ pkg: step.pkg, reason: `${reason}${detail ? ` — ${detail}` : ''}`, class: cls });
681
829
  } else {
830
+ // FR-1: for tests/syntax/smoke, name the ACTUAL failure (summary + failing tests),
831
+ // not just the first output line — see testsFailureDetail's doc comment for why.
832
+ const detail = testsFailureDetail(exec.stderr, exec.stdout);
682
833
  failures.push({
683
834
  pkg: step.pkg,
684
- reason: `exit ${String(exec.exitCode)}: ${step.cmd}${firstLine(exec.stderr, exec.stdout) ? ` — ${firstLine(exec.stderr, exec.stdout)}` : ''}`,
835
+ reason: `exit ${String(exec.exitCode)}: ${step.cmd}${detail ? ` — ${detail}` : ''}`,
685
836
  class: 'EXIT_NONZERO',
837
+ // AM-4: per-stream tails, never merged — see GateFailure.tails doc comment.
838
+ tails: { stdout: outputTail(exec.stdout, undefined), stderr: outputTail(undefined, exec.stderr) },
686
839
  });
687
840
  }
688
841
  continue;
@@ -744,32 +897,125 @@ export interface FailureIssueContext {
744
897
  readonly repo?: string | undefined;
745
898
  }
746
899
 
900
+ /** AM-1: total issue-body cap — a courier never balloons into an unpostable payload. */
901
+ const MAX_ISSUE_BODY_BYTES = 60 * 1024;
902
+
903
+ /**
904
+ * AM-5: fence `text` so the payload can never prematurely close the code block — the fence is
905
+ * N+1 backticks, where N is the LONGEST run of consecutive backticks already present in `text`.
906
+ * Every content line (and the fence itself) carries `indent` so a multi-line block renders as a
907
+ * continuation of the enclosing markdown list item, not as a sibling paragraph.
908
+ */
909
+ function fencedBlock(text: string, indent = ' '): string[] {
910
+ const runs = text.match(/`+/g) ?? [];
911
+ const longestRun = runs.reduce((m, r) => Math.max(m, r.length), 0);
912
+ // GFM needs >= 3 backticks for a FENCED (block) code fence — fewer reads as inline code.
913
+ const fence = '`'.repeat(Math.max(3, longestRun + 1));
914
+ const contentLines = text.split('\n').map((l) => `${indent}${l}`);
915
+ return [`${indent}${fence}`, ...contentLines, `${indent}${fence}`];
916
+ }
917
+
747
918
  /**
748
919
  * gh-2.4-safe `gh issue create` payload (only `--title`/`--body` are assumed downstream).
749
920
  * Pure + deterministic for a fixed verdict — the issue is the verdict's echo, never its judge.
921
+ *
922
+ * AM-1/AM-4/AM-5: every tail is (a) redacted (secret-shaped substrings replaced — see
923
+ * {@link redactSecrets}) and ANSI-stripped BEFORE it is ever considered for the body; (b) shown
924
+ * per STREAM, labelled `stdout:`/`stderr:` — AM-4's scope note applies here too: the two labelled
925
+ * blocks do NOT reconstruct chronological interleaving between the streams; (c) fenced so the
926
+ * payload cannot break out of its code block; (d) the WHOLE body is capped at
927
+ * {@link MAX_ISSUE_BODY_BYTES} — when it would exceed the cap, every tail is shrunk EVENLY
928
+ * (byte-proportional), not by dropping some tails whole while keeping others untouched.
750
929
  */
751
930
  export function buildFailureIssue(verdict: ReleaseVerdict, ctx: FailureIssueContext = {}): { title: string; body: string } {
752
931
  const failed = verdict.gates.filter((g) => g.status === 'fail').map((g) => g.gate);
753
932
  const title = `dz release: gate failure — ${failed.length > 0 ? failed.join(', ') : 'nothing verified'}`;
754
- const lines: string[] = [
755
- `Verified release blocked at ${verdict.timestamp}.`,
756
- '',
757
- ...(ctx.invocation ? [`Invocation: \`${ctx.invocation}\``, ''] : []),
758
- ...(ctx.repo ? [`Repo: ${ctx.repo}`, ''] : []),
759
- '## Gate verdict',
760
- '',
761
- ];
933
+
934
+ interface TailRef {
935
+ readonly stream: 'stdout' | 'stderr';
936
+ text: string;
937
+ readonly rawBytes: number;
938
+ }
939
+ const refsByFailure = new Map<GateFailure, TailRef[]>();
762
940
  for (const g of verdict.gates) {
763
- const icon = g.status === 'pass' ? '✓' : g.status === 'fail' ? '✗' : '○';
764
- lines.push(`- ${icon} **${g.gate}** — ${g.status} (${g.passed} passed, ${g.failures.length} failed, ${g.skips.length} skipped)`);
765
- for (const f of g.failures) lines.push(` - [${f.class}] ${f.pkg ? `${f.pkg}: ` : ''}${f.reason}`);
941
+ for (const f of g.failures) {
942
+ if (f.tails === undefined) continue;
943
+ const refs: TailRef[] = [];
944
+ for (const stream of ['stdout', 'stderr'] as const) {
945
+ const raw = f.tails[stream];
946
+ if (raw.length === 0) continue;
947
+ const clean = redactSecrets(stripAnsi(raw));
948
+ refs.push({ stream, text: clean, rawBytes: Buffer.byteLength(clean, 'utf-8') });
949
+ }
950
+ if (refs.length > 0) refsByFailure.set(f, refs);
951
+ }
766
952
  }
767
- if (verdict.skipped.length > 0) {
768
- lines.push('', '## Skipped (honestly reported, never counted as passed)', '');
769
- for (const s of verdict.skipped) lines.push(`- [${s.class}] ${s.pkg}: ${s.reason}`);
953
+
954
+ const render = (): string => {
955
+ const lines: string[] = [
956
+ `Verified release blocked at ${verdict.timestamp}.`,
957
+ '',
958
+ ...(ctx.invocation ? [`Invocation: \`${redactSecrets(stripAnsi(ctx.invocation)).replace(/`/g, "'")}\``, ''] : []),
959
+ ...(ctx.repo ? [`Repo: ${ctx.repo}`, ''] : []),
960
+ '## Gate verdict',
961
+ '',
962
+ ];
963
+ for (const g of verdict.gates) {
964
+ const icon = g.status === 'pass' ? '✓' : g.status === 'fail' ? '✗' : '○';
965
+ lines.push(`- ${icon} **${g.gate}** — ${g.status} (${g.passed} passed, ${g.failures.length} failed, ${g.skips.length} skipped)`);
966
+ for (const f of g.failures) {
967
+ // lead r2 (HIGH): the reason is output-derived free text — strip ANSI and redact it like a tail
968
+ lines.push(` - [${f.class}] ${f.pkg ? `${f.pkg}: ` : ''}${redactSecrets(stripAnsi(f.reason))}`);
969
+ // FR-2 / AM-4 / AM-5: a labelled, fenced block per non-empty stream — the issue is the
970
+ // echo of the verdict, so a reader can see the actual failing output without re-running.
971
+ for (const ref of refsByFailure.get(f) ?? []) {
972
+ lines.push(` ${ref.stream}:`, ...fencedBlock(ref.text));
973
+ }
974
+ }
975
+ }
976
+ if (verdict.skipped.length > 0) {
977
+ lines.push('', '## Skipped (honestly reported, never counted as passed)', '');
978
+ for (const s of verdict.skipped) lines.push(`- [${s.class}] ${s.pkg}: ${s.reason}`);
979
+ }
980
+ lines.push(
981
+ '',
982
+ `Blocked by: ${verdict.blockedBy.join('; ')}`,
983
+ '',
984
+ '_Auto-created by `dz release` (best-effort; the release verdict is independent of this issue)._',
985
+ );
986
+ return lines.join('\n');
987
+ };
988
+
989
+ let body = render();
990
+ let bodyBytes = Buffer.byteLength(body, 'utf-8');
991
+
992
+ if (bodyBytes > MAX_ISSUE_BODY_BYTES && refsByFailure.size > 0) {
993
+ const allRefs = [...refsByFailure.values()].flat();
994
+ let overage = bodyBytes - MAX_ISSUE_BODY_BYTES;
995
+ // Bounded iteration: each pass's cut is based on the LATEST measured overage (markup like
996
+ // "… (truncated)" adds a few bytes back per ref, so one pass rarely lands exactly) — a few
997
+ // passes converge; the safety net below closes any pathological remainder.
998
+ for (let pass = 0; pass < 3 && overage > 0; pass++) {
999
+ const perRefCut = Math.ceil(overage / allRefs.length);
1000
+ for (const ref of allRefs) {
1001
+ const targetBytes = Math.max(0, ref.rawBytes - perRefCut);
1002
+ if (Buffer.byteLength(ref.text, 'utf-8') > targetBytes) {
1003
+ ref.text = `${truncateToBytes(ref.text, targetBytes)}… (truncated)`;
1004
+ }
1005
+ }
1006
+ body = render();
1007
+ bodyBytes = Buffer.byteLength(body, 'utf-8');
1008
+ overage = bodyBytes - MAX_ISSUE_BODY_BYTES;
1009
+ }
1010
+ // Safety net: a pathological shape (a huge non-tail skeleton, tiny/no tails) can still exceed
1011
+ // the cap after every tail is wiped — hard-truncate the whole body as the last resort so the
1012
+ // cap is an INVARIANT, never a best-effort.
1013
+ if (bodyBytes > MAX_ISSUE_BODY_BYTES) {
1014
+ body = `${truncateToBytes(body, MAX_ISSUE_BODY_BYTES - 20)}\n… (truncated)`;
1015
+ }
770
1016
  }
771
- lines.push('', `Blocked by: ${verdict.blockedBy.join('; ')}`, '', '_Auto-created by `dz release` (best-effort; the release verdict is independent of this issue)._');
772
- return { title, body: lines.join('\n') };
1017
+
1018
+ return { title, body };
773
1019
  }
774
1020
 
775
1021
  /** Short, bounded release notes from injected `git log --oneline`-style lines. */