@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.
- package/.dz-manifest.json +37 -37
- package/README.md +78 -0
- package/dist/agentdb-index.d.ts +17 -6
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +61 -17
- package/dist/agentdb-index.js.map +1 -1
- package/dist/apply-leg.d.ts +56 -5
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +344 -28
- package/dist/apply-leg.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +43 -4
- package/dist/operations.js.map +1 -1
- package/dist/publish-sibling-drift.d.ts +72 -0
- package/dist/publish-sibling-drift.d.ts.map +1 -1
- package/dist/publish-sibling-drift.js +150 -4
- package/dist/publish-sibling-drift.js.map +1 -1
- package/dist/release.d.ts +72 -0
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +236 -19
- package/dist/release.js.map +1 -1
- package/dist/vector-tier.d.ts +27 -2
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +112 -3
- package/dist/vector-tier.js.map +1 -1
- package/package.json +1 -1
- package/sbom.json +36 -36
- package/src/agentdb-index.ts +65 -17
- package/src/apply-leg.ts +339 -28
- package/src/index.ts +10 -2
- package/src/operations.ts +41 -4
- package/src/publish-sibling-drift.ts +209 -4
- package/src/release.ts +263 -17
- package/src/vector-tier.ts +147 -5
|
@@ -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
|
-
|
|
272
|
-
|
|
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}${
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
''
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
772
|
-
return { title, body
|
|
1017
|
+
|
|
1018
|
+
return { title, body };
|
|
773
1019
|
}
|
|
774
1020
|
|
|
775
1021
|
/** Short, bounded release notes from injected `git log --oneline`-style lines. */
|