@dzhechkov/harness-cli 0.8.24 → 0.8.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * @packageDocumentation
5
5
  */
6
6
 
7
- import { appendFileSync, chmodSync, closeSync, cpSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync, type Dirent } from 'node:fs';
7
+ import { parseNpmPackInventory, type InventorySource, type LocalInventoryResult } from '@dzhechkov/harness-core';
8
+ import { appendFileSync, chmodSync, closeSync, constants as fsConstants, cpSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync, writeSync, type Dirent } from 'node:fs';
8
9
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
9
10
  import { fileURLToPath } from 'node:url';
10
11
  import { request as httpsRequest } from 'node:https';
@@ -955,6 +956,20 @@ export interface CliIo {
955
956
  command: string,
956
957
  options: { cwd?: string | URL | undefined; stdio?: unknown; encoding?: unknown; timeout?: number | undefined; env?: NodeJS.ProcessEnv | undefined },
957
958
  ) => string;
959
+ /**
960
+ * Test seam for `dz publish`'s gate-audit writer (feature `publish-gate-audit-durable`, FR-2):
961
+ * overrides the fs primitives `appendPublishGateAudit` uses for its durable append (production
962
+ * leaves it unset → the real `node:fs` functions). Lets a test make `fsyncSync` throw to prove
963
+ * `(audit NOT logged: …)` is printed and the write is reported as failed, without touching any
964
+ * other seam's filesystem.
965
+ */
966
+ readonly publishGateAuditFsLayer?: PublishGateAuditFsLayer;
967
+ /**
968
+ * AM-5 (feature publish-gate-audit-durable): test seam for the sibling-drift gate's `npm pack
969
+ * --dry-run --json` call (production leaves it unset → real `execFileSync`). Takes the package
970
+ * dir, returns raw stdout, or throws to simulate a real `npm` failure without spawning anything.
971
+ */
972
+ readonly publishNpmPackRunner?: (dir: string) => string;
958
973
  /**
959
974
  * Test seam for `dz install`: overrides the `npm install` subprocess (production leaves
960
975
  * it unset → real `execSync`, stdio piped). A stub runner that pre-stages a fixture
@@ -7035,6 +7050,77 @@ function packedInstallScratchRoot(): string {
7035
7050
  return existsSync('/var/tmp') ? '/var/tmp' : tmpdir();
7036
7051
  }
7037
7052
 
7053
+ /**
7054
+ * FR-1 (feature release-smoke-staged-pack): stage every target's `package.json` exactly like a
7055
+ * live publish packs it — `workspace:*` sibling specs rewritten to the exact sibling version
7056
+ * (`rewriteWorkspaceSpecs`), `scripts.prepublishOnly` dropped — run `fn`, then ALWAYS restore the
7057
+ * original bytes in a `finally`, whatever `fn` does or throws. A restore failure is reported
7058
+ * through `write` (with the path), never swallowed — the "absence of a receipt is not success"
7059
+ * rule this file follows everywhere else.
7060
+ *
7061
+ * `cmdPublish`'s dry-run preview and `cmdRelease`'s packed-install-smoke `pack` steps both go
7062
+ * through this ONE helper, so the two doors that ask "what would the registry receive?" pack the
7063
+ * exact same bytes (MEASURED 2026-09-13 16:05: `dz release` packed the live `workspace:*`
7064
+ * package.json and its smoke install died with EUNSUPPORTEDPROTOCOL — `dz publish`'s preview
7065
+ * already staged around this and release never got that).
7066
+ */
7067
+ function withStagedPackageJson<T>(
7068
+ targets: readonly { readonly dir: string }[],
7069
+ workspaceVersions: ReadonlyMap<string, string>,
7070
+ write: Write,
7071
+ fn: () => T,
7072
+ label = 'dz',
7073
+ ): T {
7074
+ // Lead edits after Codex review (2026-09-13, findings 1/5/6): every write — the staged text and
7075
+ // the restore — goes through a sibling temp file + rename, so a reader never sees a truncated
7076
+ // package.json; a restore that FAILS is an error the caller must see (thrown after fn, or attached
7077
+ // to fn's own error), never a warning that lets a run "succeed" on a damaged tree; and the
7078
+ // diagnostic keeps the calling command's name (`label`).
7079
+ const atomicWrite = (path: string, text: string): void => {
7080
+ // Codex round 2: an EXCLUSIVE, randomized sibling temp — never a shared pid-named file
7081
+ const tmp = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.staged.tmp`;
7082
+ writeFileSync(tmp, text, { flag: 'wx' });
7083
+ renameSync(tmp, path);
7084
+ };
7085
+ const stagedOriginals: Array<{ path: string; text: string }> = [];
7086
+ let fnError: unknown;
7087
+ let fnThrew = false;
7088
+ try {
7089
+ for (const p of targets) {
7090
+ const pkgJsonPath = join(p.dir, 'package.json');
7091
+ const original = readFileSync(pkgJsonPath, 'utf-8');
7092
+ const rewritten = JSON.parse(rewriteWorkspaceSpecs(original, workspaceVersions)) as Record<string, unknown>;
7093
+ const scripts = rewritten['scripts'];
7094
+ if (scripts !== null && typeof scripts === 'object' && !Array.isArray(scripts)) delete (scripts as Record<string, unknown>)['prepublishOnly'];
7095
+ stagedOriginals.push({ path: pkgJsonPath, text: original });
7096
+ atomicWrite(pkgJsonPath, JSON.stringify(rewritten, null, 2) + '\n');
7097
+ }
7098
+ return fn();
7099
+ } catch (err) {
7100
+ fnThrew = true;
7101
+ fnError = err;
7102
+ throw err;
7103
+ } finally {
7104
+ const restoreFailures: string[] = [];
7105
+ for (const o of stagedOriginals) {
7106
+ try {
7107
+ atomicWrite(o.path, o.text);
7108
+ } catch (err) {
7109
+ const msg = `${label}: ✗ could not restore ${o.path} after staged packing: ${formatPublishError(err)} — the tree is left STAGED, restore it by hand`;
7110
+ try { write(msg); } catch { /* a throwing writer must not mask the restore failure */ }
7111
+ restoreFailures.push(msg);
7112
+ }
7113
+ }
7114
+ if (restoreFailures.length > 0) {
7115
+ // Codex round 2: one aggregate error carrying BOTH fn's own failure (if any) and the restore
7116
+ // failures — never a bare message assignment that could itself throw out of finally.
7117
+ const fnPart = fnThrew ? `\n(during: ${fnError instanceof Error ? fnError.message : String(fnError)})` : '';
7118
+ // eslint-disable-next-line no-unsafe-finally -- a damaged tree must not read as success
7119
+ throw new Error(`${restoreFailures.join('\n')}${fnPart}`);
7120
+ }
7121
+ }
7122
+ }
7123
+
7038
7124
  function cmdPublish(
7039
7125
  options: Map<string, string>,
7040
7126
  flags: Set<string>,
@@ -7047,6 +7133,14 @@ function cmdPublish(
7047
7133
  command: string,
7048
7134
  options: { cwd?: string | URL | undefined; stdio?: unknown; encoding?: unknown; timeout?: number | undefined; env?: NodeJS.ProcessEnv | undefined },
7049
7135
  ) => string,
7136
+ gateAuditFsLayer?: PublishGateAuditFsLayer,
7137
+ /**
7138
+ * AM-5 (feature publish-gate-audit-durable): test seam for the sibling-drift gate's `npm pack
7139
+ * --dry-run --json` call — production leaves it unset (real `execFileSync`). Takes the package
7140
+ * dir, returns raw stdout, or THROWS to simulate a real `npm` failure — a test can then prove the
7141
+ * failure reaches `parseNpmPackInventory`'s caller as `unavailable`, never a real subprocess.
7142
+ */
7143
+ npmPackRunner?: (dir: string) => string,
7050
7144
  ): number {
7051
7145
  const json = flags.has('json');
7052
7146
  // Under --json stdout carries exactly one JSON document, so every human line — guard notes, refusals,
@@ -7187,19 +7281,55 @@ function cmdPublish(
7187
7281
  }
7188
7282
  });
7189
7283
 
7190
- // AM-6: an override (--allow-sibling-drift) is only real once its audit row is DURABLE. A write
7191
- // failure must refuse the publish rather than print "(logged)" about a log entry that never
7192
- // landed the same "absence of a receipt is not success" lesson the registry-probe gate already
7193
- // enforces for a publish's own confirmation.
7194
- const auditedOverride = (detail: string, humanMessage: string, pkgNameForBlock: string): boolean => {
7195
- const wrote = appendPublishGateAudit(cwd, 'sibling-drift', 'warn', detail, '--allow-sibling-drift');
7196
- if (wrote) {
7197
- write(`dz publish: ${humanMessage}allowed via --allow-sibling-drift (logged)`);
7198
- return false;
7284
+ // AM-4: `npm pack --dry-run --json` is a real subprocess cache it for the lifetime of this
7285
+ // ENTIRE run (keyed by resolved dir), NOT per package being checked (round-1 review, finding 5):
7286
+ // the cache used to be re-created inside the per-package loop body, so two different dependents
7287
+ // of the SAME sibling packed it twice. `npmPackRunner` (AM-5) is a test seam — production leaves
7288
+ // it unset and runs the real subprocess; a test injects a stub that throws to prove a real `npm`
7289
+ // failure reaches the caller as `unavailable`, without spawning anything.
7290
+ // Lead fix after the fix-round's live dry-run (2026-09-14 01:02, MEASURED on the hub): the
7291
+ // workspace side is now PACKED BY THE LIVE TRANSPORT `pnpm pack` into a per-run temp dir,
7292
+ // unpacked, and handed to core as a `packedDir` that core hashes with the SAME full walk it uses
7293
+ // for the published tarball. `npm pack --dry-run --json` (kept behind the `npmPackRunner` test
7294
+ // seam) never lists the LICENSE pnpm synthesises from the workspace root into a package whose own
7295
+ // tree has none, so two siblings unchanged since publication (harness-presets, scout) read as
7296
+ // "LICENSE only in the published copy" — a false drift the fix-round's inventory could not see.
7297
+ // Honest limit: the seam path (tests) still parses npm's JSON; only production takes the pnpm path.
7298
+ const npmPackInventoryCache = new Map<string, LocalInventoryResult>();
7299
+ let packTmpDir: string | undefined;
7300
+ const npmPackInventory = (dir: string): LocalInventoryResult => {
7301
+ const key = resolve(dir);
7302
+ const hit = npmPackInventoryCache.get(key);
7303
+ if (hit !== undefined) return hit;
7304
+ let out: LocalInventoryResult;
7305
+ try {
7306
+ if (npmPackRunner !== undefined) {
7307
+ out = parseNpmPackInventory(npmPackRunner(dir));
7308
+ } else {
7309
+ packTmpDir ??= mkdtempSync(join(tmpdir(), 'dz-drift-pack-'));
7310
+ out = { packedDir: extractIntoTempDir(dir, mkdtempSync(join(packTmpDir, 'p-'))).dir };
7311
+ }
7312
+ } catch (err) {
7313
+ const how = npmPackRunner !== undefined ? 'npm pack --dry-run --json' : 'pnpm pack';
7314
+ out = { unavailable: `${how} failed: ${(err as Error).message.split('\n')[0]}` };
7199
7315
  }
7200
- write(`dz publish: BLOCKED ${pkgNameForBlock} — ${humanMessage}, and the override could not be recorded (audit write failed); refusing rather than proceeding unlogged`);
7201
- return true;
7316
+ npmPackInventoryCache.set(key, out);
7317
+ return out;
7202
7318
  };
7319
+ const localInventorySource: InventorySource = npmPackRunner !== undefined ? 'npm-pack' : 'pnpm-pack';
7320
+
7321
+ // AM-3 (Codex round-1 review, finding 4, high): sibling-drift audit records are EXACTLY one per
7322
+ // package per rule per RUN. The old code appended one JSONL record per SIBLING a package depends
7323
+ // on (a package with two drifted deps wrote two rows under the same rule), and the `unavailable`
7324
+ // branch without `--allow-sibling-drift` wrote NO record at all. Every sibling outcome for a
7325
+ // package is now aggregated first (`pkParts`/`pkVerdict`/`pkOverrideUsed`) and written ONCE —
7326
+ // `block` if any sibling blocks, else `warn` if the only issues were resolved via
7327
+ // `--allow-sibling-drift`, else `pass` — with a detail naming every sibling and its status.
7328
+ // `siblingDriftAudited` guarantees the single write even though `--include-drifted`'s fixed-point
7329
+ // loop can revisit the SAME package across rounds: a package's own `dependencies` never change
7330
+ // between rounds, so a later round can only ever re-derive a SUBSET of what the first pass
7331
+ // already covered (its siblings that drifted got folded into the batch and are now skipped).
7332
+ const siblingDriftAudited = new Set<string>();
7203
7333
 
7204
7334
  let driftBlocked = 0;
7205
7335
  const extraBatch = new Set<string>();
@@ -7208,6 +7338,9 @@ function cmdPublish(
7208
7338
  // (finding 2) showed the single pass never re-checked an EXPANDED batch's own new edges. Capped at
7209
7339
  // `allPackages.length + 1` rounds (the plan's own "цикл с потолком = число пакетов").
7210
7340
  const maxRounds = allPackages.length + 1;
7341
+ // Codex round-3 (2026-09-14): the per-run pack scratch is released in a `finally`, so an
7342
+ // exception thrown while hashing or auditing cannot leak a `dz-drift-pack-*` dir under tmpdir.
7343
+ try {
7211
7344
  for (let round = 0; round < maxRounds; round++) {
7212
7345
  let addedThisRound = false;
7213
7346
  for (const pk of targets) {
@@ -7221,13 +7354,25 @@ function cmdPublish(
7221
7354
  } catch (err) {
7222
7355
  // AM-3: an unreadable/invalid package.json for a BATCH package is an input this HARD gate
7223
7356
  // cannot build — it must BLOCK, never silently degrade to "no dependencies" (which used to
7224
- // read as a clean n/a).
7357
+ // read as a clean n/a). AND (finding 4) the non-override branch below used to print a
7358
+ // BLOCKED line with NO audit record behind it — an `unavailable` outcome is logged exactly
7359
+ // like every other outcome, override or not.
7225
7360
  const reason = `package.json unreadable/invalid (${(err as Error).message.split('\n')[0]})`;
7226
- if (allowSiblingDrift) {
7227
- if (auditedOverride(`${pk.name}: ${reason}`, `sibling drift check unavailable for ${pk.name} (${reason})`, pk.name)) driftBlocked++;
7228
- } else {
7229
- write(`dz publish: BLOCKED ${pk.name} — sibling drift check unavailable (${reason}); add --allow-sibling-drift to override (logged) or fix the manifest`);
7230
- driftBlocked++;
7361
+ if (!siblingDriftAudited.has(pk.name)) {
7362
+ siblingDriftAudited.add(pk.name);
7363
+ if (allowSiblingDrift) {
7364
+ const wrote = appendPublishGateAudit(cwd, 'sibling-drift', 'warn', `${pk.name}: ${reason} allowed via --allow-sibling-drift`, [{ name: pk.name, version: pk.version }], '--allow-sibling-drift', gateAuditFsLayer);
7365
+ if (wrote.logged) {
7366
+ write(`dz publish: ⚠ sibling drift check unavailable for ${pk.name} (${reason}) — allowed via --allow-sibling-drift (logged)`);
7367
+ } else {
7368
+ write(`dz publish: BLOCKED ${pk.name} — sibling drift check unavailable for ${pk.name} (${reason}), and the override could not be recorded (audit write failed: ${wrote.reason ?? 'unknown reason'}); refusing rather than proceeding unlogged`);
7369
+ driftBlocked++;
7370
+ }
7371
+ } else {
7372
+ const wrote = appendPublishGateAudit(cwd, 'sibling-drift', 'block', `${pk.name}: ${reason}`, [{ name: pk.name, version: pk.version }], undefined, gateAuditFsLayer);
7373
+ write(`dz publish: BLOCKED ${pk.name} — sibling drift check unavailable (${reason}); add --allow-sibling-drift to override (logged) or fix the manifest${auditSuffix(wrote)}`);
7374
+ driftBlocked++;
7375
+ }
7231
7376
  }
7232
7377
  continue;
7233
7378
  }
@@ -7235,49 +7380,92 @@ function cmdPublish(
7235
7380
  const peerDeps = manifestObj?.peerDependencies ?? {};
7236
7381
  const optionalDeps = manifestObj?.optionalDependencies ?? {};
7237
7382
 
7238
- // AM-6: a package with no workspace: dependency at all is n/a for THIS gate — recorded as a
7239
- // pass note, not silence (FR-6 compatibility: output stays unchanged for such a batch).
7383
+ // AM-6: a package with no EXTERNAL sibling to check at all is n/a for THIS gate — recorded as
7384
+ // a pass note ("no external siblings"), not silence (FR-6 compatibility: this branch prints
7385
+ // nothing to stdout, matching the pre-existing behavior). "External" covers BOTH "no
7386
+ // workspace: dependency declared" and "every workspace: dependency is inside THIS batch"
7387
+ // (publishing fresh, nothing stale to drift from) — both used to leave this package with no
7388
+ // audit record at all when every dep resolved to the second case.
7240
7389
  const anyWorkspaceDep = [...Object.values(deps), ...Object.values(peerDeps), ...Object.values(optionalDeps)]
7241
7390
  .some((spec) => String(spec).startsWith('workspace:'));
7242
- if (!anyWorkspaceDep) {
7243
- appendPublishGateAudit(cwd, 'sibling-drift', 'pass', `${pk.name}: n/a — no workspace: dependency declared`);
7244
- continue;
7245
- }
7246
7391
 
7247
- const drifts = detectSiblingDrift({
7248
- dependencies: deps,
7249
- peerDependencies: peerDeps,
7250
- optionalDependencies: optionalDeps,
7251
- workspaceVersions,
7252
- workspaceDirs,
7253
- batch: batchNames,
7254
- fetchPublished,
7255
- });
7392
+ const pkParts: string[] = [];
7393
+ let pkVerdict: 'pass' | 'warn' | 'block' = 'pass';
7394
+ let pkOverrideUsed = false;
7395
+
7396
+ if (anyWorkspaceDep) {
7397
+ const drifts = detectSiblingDrift({
7398
+ localInventory: npmPackInventory,
7399
+ localInventorySource,
7400
+ dependencies: deps,
7401
+ peerDependencies: peerDeps,
7402
+ optionalDependencies: optionalDeps,
7403
+ workspaceVersions,
7404
+ workspaceDirs,
7405
+ batch: batchNames,
7406
+ fetchPublished,
7407
+ });
7256
7408
 
7257
- for (const r of drifts) {
7258
- if (r.status === 'same') {
7259
- appendPublishGateAudit(cwd, 'sibling-drift', 'pass', `${r.name}@${r.version} = workspace (dependent: ${pk.name})`);
7260
- write(`dz publish: ✓ sibling drift: none (${r.name}@${r.version} = workspace)`);
7261
- } else if (r.status === 'unavailable') {
7262
- if (allowSiblingDrift) {
7263
- if (auditedOverride(`${r.name}@${r.version}: ${r.reason}`, `sibling drift check unavailable for ${r.name}@${r.version} (${r.reason})`, pk.name)) driftBlocked++;
7409
+ for (const r of drifts) {
7410
+ if (r.status === 'same') {
7411
+ pkParts.push(`${r.name}@${r.version}: same`);
7412
+ write(`dz publish: ✓ sibling drift: none (${r.name}@${r.version} = workspace)`);
7413
+ } else if (r.status === 'unavailable') {
7414
+ if (allowSiblingDrift) {
7415
+ pkParts.push(`${r.name}@${r.version}: unavailable (${r.reason}) — allowed via --allow-sibling-drift`);
7416
+ if (pkVerdict !== 'block') pkVerdict = 'warn';
7417
+ pkOverrideUsed = true;
7418
+ } else {
7419
+ pkParts.push(`${r.name}@${r.version}: unavailable (${r.reason})`);
7420
+ pkVerdict = 'block';
7421
+ write(`dz publish: BLOCKED ${pk.name} — sibling drift check unavailable (${r.reason}); add --allow-sibling-drift to override (logged) or check network/registry access`);
7422
+ driftBlocked++;
7423
+ }
7424
+ } else if (includeDrifted) {
7425
+ pkParts.push(`${r.name}@${r.version}: drift (${r.changedFiles.length} file(s)) — auto-included via --include-drifted`);
7426
+ if (!batchNames.has(r.name) && !extraBatch.has(r.name)) {
7427
+ extraBatch.add(r.name);
7428
+ addedThisRound = true;
7429
+ write(`dz publish: → sibling drift: ${r.name}@${r.version} differs from the workspace (${r.changedFiles.length} file(s)) — adding to the batch via --include-drifted${r.missingExports.length > 0 ? ` (missing exports: ${r.missingExports.join(', ')})` : ''}`);
7430
+ }
7431
+ } else if (allowSiblingDrift) {
7432
+ pkParts.push(`${r.name}@${r.version}: drift (${r.changedFiles.length} file(s)) — allowed via --allow-sibling-drift`);
7433
+ if (pkVerdict !== 'block') pkVerdict = 'warn';
7434
+ pkOverrideUsed = true;
7264
7435
  } else {
7265
- write(`dz publish: BLOCKED ${pk.name} — sibling drift check unavailable (${r.reason}); add --allow-sibling-drift to override (logged) or check network/registry access`);
7436
+ pkParts.push(`${r.name}@${r.version}: drift (${r.changedFiles.length} file(s))`);
7437
+ pkVerdict = 'block';
7438
+ const suggestFilter = filterStr !== undefined ? `${filterStr},${r.name}` : `${pk.name},${r.name}`;
7439
+ write(`dz publish: BLOCKED ${pk.name} — sibling drift: @dzhechkov/${r.name.replace(/^@dzhechkov\//, '')}@${r.version} on the registry differs from the workspace (${r.changedFiles.length} file(s)); add ${r.name} to the batch (--filter ${suggestFilter}) or publish it first`);
7266
7440
  driftBlocked++;
7267
7441
  }
7268
- } else if (includeDrifted) {
7269
- if (!batchNames.has(r.name) && !extraBatch.has(r.name)) {
7270
- extraBatch.add(r.name);
7271
- addedThisRound = true;
7272
- write(`dz publish: sibling drift: ${r.name}@${r.version} differs from the workspace (${r.changedFiles.length} file(s)) adding to the batch via --include-drifted${r.missingExports.length > 0 ? ` (missing exports: ${r.missingExports.join(', ')})` : ''}`);
7442
+ }
7443
+ }
7444
+
7445
+ // AM-3/AM-6: the single, aggregated audit write for this package — "no external siblings"
7446
+ // when nothing was ever checked, otherwise every sibling's status joined into one detail.
7447
+ if (!siblingDriftAudited.has(pk.name)) {
7448
+ siblingDriftAudited.add(pk.name);
7449
+ const detail = pkParts.length > 0 ? pkParts.join('; ') : 'no external siblings';
7450
+ // AM-6: an override reason is attached only when the FINAL verdict is 'warn' — if some
7451
+ // OTHER sibling still stands as a live block, the override never actually excused the run.
7452
+ const overrideReason = pkOverrideUsed && pkVerdict !== 'block' ? '--allow-sibling-drift' : undefined;
7453
+ const wrote = appendPublishGateAudit(cwd, 'sibling-drift', pkVerdict, detail, [{ name: pk.name, version: pk.version }], overrideReason, gateAuditFsLayer);
7454
+ if (pkOverrideUsed) {
7455
+ // AM-6: the override is only real once ITS audit row is durable — a write failure must
7456
+ // refuse the publish rather than print "(logged)" about a record that never landed.
7457
+ if (wrote.logged) {
7458
+ write(`dz publish: ⚠ sibling drift override recorded for ${pk.name} — allowed via --allow-sibling-drift (logged)`);
7459
+ } else {
7460
+ write(`dz publish: BLOCKED ${pk.name} — the --allow-sibling-drift override could not be recorded (audit write failed: ${wrote.reason ?? 'unknown reason'}); refusing rather than proceeding unlogged`);
7461
+ driftBlocked++;
7273
7462
  }
7274
- } else if (allowSiblingDrift) {
7275
- if (auditedOverride(`${r.name}@${r.version}: ${r.changedFiles.length} file(s) differ from the workspace`, `sibling drift: ${r.name}@${r.version} differs from the workspace (${r.changedFiles.length} file(s))`, pk.name)) driftBlocked++;
7276
- } else {
7277
- appendPublishGateAudit(cwd, 'sibling-drift', 'block', `${pk.name} depends on ${r.name}@${r.version}; ${r.changedFiles.length} file(s) differ from the workspace`);
7278
- const suggestFilter = filterStr !== undefined ? `${filterStr},${r.name}` : `${pk.name},${r.name}`;
7279
- write(`dz publish: BLOCKED ${pk.name} — sibling drift: @dzhechkov/${r.name.replace(/^@dzhechkov\//, '')}@${r.version} on the registry differs from the workspace (${r.changedFiles.length} file(s)); add ${r.name} to the batch (--filter ${suggestFilter}) or publish it first`);
7280
- driftBlocked++;
7463
+ } else if (pkParts.length > 0) {
7464
+ write(`dz publish: sibling drift audit for ${pk.name}${auditSuffix(wrote)}`);
7465
+ } else if (!wrote.logged) {
7466
+ // Codex round-2 (2026-09-14), AM-6 residual: the "no external siblings" pass note stays
7467
+ // silent on stdout ONLY while its record actually landed — a failed audit write is said.
7468
+ write(`dz publish: sibling drift audit for ${pk.name} (no external siblings)${auditSuffix(wrote)}`);
7281
7469
  }
7282
7470
  }
7283
7471
  }
@@ -7292,6 +7480,9 @@ function cmdPublish(
7292
7480
  targets = allPackages.filter(matchesFilter);
7293
7481
  batchNames = new Set(targets.map((p) => p.name));
7294
7482
  }
7483
+ } finally {
7484
+ if (packTmpDir !== undefined) rmSync(packTmpDir, { recursive: true, force: true });
7485
+ }
7295
7486
 
7296
7487
  const siblingDriftFailed = driftBlocked > 0;
7297
7488
  if (siblingDriftFailed && !dryRun) {
@@ -7328,8 +7519,8 @@ function cmdPublish(
7328
7519
  let packedInstallSmokePreviewFailed = false;
7329
7520
  if (dryRun) {
7330
7521
  if (bins.length === 0) {
7331
- appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin');
7332
- write('dz publish: ○ packed install smoke: n/a (nothing in the batch declares a bin)');
7522
+ const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin', targets.map((p) => ({ name: p.name, version: p.version })), undefined, gateAuditFsLayer);
7523
+ write(`dz publish: ○ packed install smoke: n/a (nothing in the batch declares a bin)${auditSuffix(wrote)}`);
7333
7524
  } else {
7334
7525
  const scratchRoot = packedInstallScratchRoot();
7335
7526
  const packDir = mkdtempSync(join(scratchRoot, 'dz-publish-pack-'));
@@ -7361,36 +7552,30 @@ function cmdPublish(
7361
7552
  // Lead edit after the live dry-run (13.09 12:05): the preview packed the WORKING directory with
7362
7553
  // `workspace:^` specs still inside, so `npm install <tgz>` died with EUNSUPPORTEDPROTOCOL — the
7363
7554
  // preview must stage package.json exactly as the live packedTransport does (sibling pins via
7364
- // rewriteWorkspaceSpecs, prepublishOnly dropped) and restore the originals afterwards.
7365
- const stagedOriginals: Array<{ path: string; text: string }> = [];
7555
+ // rewriteWorkspaceSpecs, prepublishOnly dropped) and restore the originals afterwards. Shared
7556
+ // with `dz release` via `withStagedPackageJson` (feature release-smoke-staged-pack).
7366
7557
  try {
7367
- for (const p of targets) {
7368
- const pkgJsonPath = join(p.dir, 'package.json');
7369
- const original = readFileSync(pkgJsonPath, 'utf-8');
7370
- const rewritten = JSON.parse(rewriteWorkspaceSpecs(original, workspaceVersions)) as Record<string, unknown>;
7371
- const scripts = rewritten['scripts'];
7372
- if (scripts !== null && typeof scripts === 'object' && !Array.isArray(scripts)) delete (scripts as Record<string, unknown>)['prepublishOnly'];
7373
- stagedOriginals.push({ path: pkgJsonPath, text: original });
7374
- writeFileSync(pkgJsonPath, JSON.stringify(rewritten, null, 2) + '\n');
7375
- }
7376
- for (const step of smokePlan.steps) {
7377
- const r = runSmoke(step.cmd, { cwd: step.cwd, timeoutMs: step.timeoutMs });
7378
- smokeExecutions.push({ stepId: step.id, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, ...(r.timedOut !== undefined ? { timedOut: r.timedOut } : {}) });
7379
- }
7380
- } finally {
7381
- for (const o of stagedOriginals) { try { writeFileSync(o.path, o.text); } catch (err) { write(`dz publish: ⚠ could not restore ${o.path} after the preview smoke: ${formatPublishError(err)}`); } }
7558
+ withStagedPackageJson(targets, workspaceVersions, write, () => {
7559
+ for (const step of smokePlan.steps) {
7560
+ const r = runSmoke(step.cmd, { cwd: step.cwd, timeoutMs: step.timeoutMs });
7561
+ smokeExecutions.push({ stepId: step.id, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, ...(r.timedOut !== undefined ? { timedOut: r.timedOut } : {}) });
7562
+ }
7563
+ }, 'dz publish');
7564
+ } catch (err) {
7565
+ // a restore failure is a preview failure (Codex finding 1): never a green preview on a staged tree
7566
+ smokeExecutions.push({ stepId: 'staged-restore', exitCode: 1, stdout: '', stderr: formatPublishError(err) });
7382
7567
  }
7383
7568
  const smokeVerdict = judgePackedInstallSmoke(smokePlan, smokeExecutions);
7384
7569
  try { rmSync(packDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
7385
7570
  try { rmSync(installDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
7386
7571
 
7387
7572
  if (smokeVerdict.ok) {
7388
- appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'preview: pack/install/--version all clean');
7389
- write('dz publish: ✓ packed install smoke (preview)');
7573
+ const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'preview: pack/install/--version all clean', targets.map((p) => ({ name: p.name, version: p.version })), undefined, gateAuditFsLayer);
7574
+ write(`dz publish: ✓ packed install smoke (preview)${auditSuffix(wrote)}`);
7390
7575
  } else {
7391
7576
  const detail = smokeVerdict.failureDetail ?? smokeVerdict.bins.find((b) => !b.ok)?.detail ?? '(no detail)';
7392
- appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail);
7393
- write(`dz publish: BLOCKED — packed install smoke failed (preview): ${detail}`);
7577
+ const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail, targets.map((p) => ({ name: p.name, version: p.version })), undefined, gateAuditFsLayer);
7578
+ write(`dz publish: BLOCKED — packed install smoke failed (preview): ${detail}${auditSuffix(wrote)}`);
7394
7579
  for (const b of smokeVerdict.bins.filter((b) => !b.ok)) write(` ✗ ${b.pkg} (${b.binName}): ${b.detail ?? '(no detail)'}`);
7395
7580
  packedInstallSmokePreviewFailed = true;
7396
7581
  }
@@ -7453,22 +7638,35 @@ function cmdPublish(
7453
7638
  // `pnpm publish` and skipped re-signing (ADR-001, features/publish-gate-verifies-the-tarball).
7454
7639
  let cleanupGate: (() => void) | null = null;
7455
7640
  try {
7456
- const signed = JSON.parse(readFileSync(manifestPath, 'utf8'));
7457
- let extracted: { dir: string; cleanup: () => void } | undefined;
7458
- try {
7459
- extracted = extractPublishTarball(pk.dir);
7460
- cleanupGate = extracted.cleanup;
7461
- } catch (err) {
7462
- // Cross-family review (codex `gpt-5.6-sol`, 2026-08-22): falling back to the working
7463
- // TREE here fails the gate OPEN. The gate's whole claim is "what ships matches the
7464
- // signature"; with no artifact, nothing was compared, and reporting a pass would be a
7465
- // claim about an object that was never built. Say why, and block.
7466
- write(`dz publish: could not pack ${pk.name} (${(err as Error).message.split('\n')[0]}) — the artifact was never built, so its signature was not checked`);
7641
+ const parsedManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
7642
+ // FR-4 (feature publish-gate-audit-durable): a manifest that PARSES but is not a plain
7643
+ // object — `null`, an array, a bare string — is UNAVAILABLE, never "no signature". Those
7644
+ // are different failures with different fixes: "no signature" means run `dz sign`; a
7645
+ // malformed manifest means the file itself is corrupt/wrong-shaped and re-signing alone
7646
+ // would silently paper over that. Mirrors `readManifest`'s shape guard in
7647
+ // publish-sibling-drift.ts (null/array/non-object cannot be used, say so).
7648
+ if (parsedManifest === null || typeof parsedManifest !== 'object' || Array.isArray(parsedManifest)) {
7649
+ const gotShape = parsedManifest === null ? 'null' : Array.isArray(parsedManifest) ? 'an array' : typeof parsedManifest;
7650
+ write(`dz publish: ${pk.name}'s ${MANIFEST_NAME} is not a JSON object (got ${gotShape}) its signature is unavailable, not merely absent`);
7467
7651
  artifactUnavailable = true;
7652
+ } else {
7653
+ const signed = parsedManifest;
7654
+ let extracted: { dir: string; cleanup: () => void } | undefined;
7655
+ try {
7656
+ extracted = extractPublishTarball(pk.dir);
7657
+ cleanupGate = extracted.cleanup;
7658
+ } catch (err) {
7659
+ // Cross-family review (codex `gpt-5.6-sol`, 2026-08-22): falling back to the working
7660
+ // TREE here fails the gate OPEN. The gate's whole claim is "what ships matches the
7661
+ // signature"; with no artifact, nothing was compared, and reporting a pass would be a
7662
+ // claim about an object that was never built. Say why, and block.
7663
+ write(`dz publish: could not pack ${pk.name} (${(err as Error).message.split('\n')[0]}) — the artifact was never built, so its signature was not checked`);
7664
+ artifactUnavailable = true;
7665
+ }
7666
+ verifyOk =
7667
+ extracted !== undefined &&
7668
+ verifyManifest(extracted.dir, signed, readFileSync(trustRoot, 'utf8')).ok;
7468
7669
  }
7469
- verifyOk =
7470
- extracted !== undefined &&
7471
- verifyManifest(extracted.dir, signed, readFileSync(trustRoot, 'utf8')).ok;
7472
7670
  } catch {
7473
7671
  verifyOk = false;
7474
7672
  } finally {
@@ -7513,9 +7711,10 @@ function cmdPublish(
7513
7711
  // batch resolves to.
7514
7712
  smoke: (artifacts): { ok: boolean; reason?: string } => {
7515
7713
  for (const a of artifacts) write(`dz publish: tarball ${a.name}@${a.newVersion} sha256:${a.sha256}`);
7714
+ const auditPackages = artifacts.map((a) => ({ name: a.name, version: a.newVersion, tarballSha256: a.sha256 }));
7516
7715
  if (bins.length === 0) {
7517
- appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin');
7518
- write('dz publish: ○ packed install smoke: n/a (nothing in the batch declares a bin)');
7716
+ const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin', auditPackages, undefined, gateAuditFsLayer);
7717
+ write(`dz publish: ○ packed install smoke: n/a (nothing in the batch declares a bin)${auditSuffix(wrote)}`);
7519
7718
  return { ok: true };
7520
7719
  }
7521
7720
  const scratchRoot = packedInstallScratchRoot();
@@ -7554,13 +7753,13 @@ function cmdPublish(
7554
7753
  const verdict = judgePackedInstallSmoke(smokePlan, smokeExecutions);
7555
7754
  try { rmSync(installDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
7556
7755
  if (verdict.ok) {
7557
- appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'pack/install/--version all clean (live, packedTransport)');
7558
- write('dz publish: ✓ packed install smoke');
7756
+ const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'pack/install/--version all clean (live, packedTransport)', auditPackages, undefined, gateAuditFsLayer);
7757
+ write(`dz publish: ✓ packed install smoke${auditSuffix(wrote)}`);
7559
7758
  return { ok: true };
7560
7759
  }
7561
7760
  const detail = verdict.failureDetail ?? verdict.bins.find((b) => !b.ok)?.detail ?? '(no detail)';
7562
- appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail);
7563
- write(`dz publish: BLOCKED — packed install smoke failed: ${detail}`);
7761
+ const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail, auditPackages, undefined, gateAuditFsLayer);
7762
+ write(`dz publish: BLOCKED — packed install smoke failed: ${detail}${auditSuffix(wrote)}`);
7564
7763
  for (const b of verdict.bins.filter((b) => !b.ok)) write(` ✗ ${b.pkg} (${b.binName}): ${b.detail ?? '(no detail)'}`);
7565
7764
  return { ok: false, reason: detail };
7566
7765
  },
@@ -8116,7 +8315,24 @@ function cmdRelease(options: Map<string, string>, flags: Set<string>, cwd: strin
8116
8315
  let smokeTmp: string | undefined;
8117
8316
  const execSteps: GateStep[] = plan.steps.filter((s) => s.kind !== 'synthetic-fail');
8118
8317
  say(`\ndz release — executing ${execSteps.length} gate step(s) across ${plan.packages.length} package(s)…`);
8119
- for (const step of execSteps) {
8318
+
8319
+ // FR-1/FR-2 (feature release-smoke-staged-pack): the packed-install smoke's `pack` steps must
8320
+ // pack the SAME staged bytes `dz publish`'s preview does — sibling `workspace:*` deps rewritten
8321
+ // to the exact sibling version, `prepublishOnly` dropped — or `npm install` on the resulting
8322
+ // tarballs dies with EUNSUPPORTEDPROTOCOL (MEASURED 2026-09-13 16:05). `workspaceVersions` is
8323
+ // built like publish's (from the FULL workspace, not just this release's filtered batch — an
8324
+ // out-of-batch sibling still needs its real version). Only the `pack` sub-steps are staged; the
8325
+ // `install`/`bin-exists`/`bin-version` steps already run against the packed tarballs and need no
8326
+ // staging.
8327
+ const allPackages = discoverPackages(cwd);
8328
+ const workspaceVersions = new Map(allPackages.map((p) => [p.name, p.version]));
8329
+ const packStepIds = new Set(
8330
+ (plan.packedInstallPlan?.steps ?? [])
8331
+ .filter((s) => s.kind === 'pack')
8332
+ .map((s) => `smoke:packed-install:${s.id}`),
8333
+ );
8334
+
8335
+ const runGateStep = (step: GateStep): void => {
8120
8336
  let stepCwd = step.cwd;
8121
8337
  if (step.tempCwd === true) {
8122
8338
  // AM-4: boot bins in a throwaway cwd so an installer-style bin cannot mutate the workspace.
@@ -8133,7 +8349,35 @@ function cmdRelease(options: Map<string, string>, flags: Set<string>, cwd: strin
8133
8349
  durationMs: Date.now() - started,
8134
8350
  timedOut: r.timedOut,
8135
8351
  });
8352
+ };
8353
+
8354
+ // Plan/execution order is load-bearing (see the plan/execution skew guard test) — steps run in
8355
+ // exactly the order `plan.steps` lists them, one loop, no reordering. Only the `pack` steps are
8356
+ // wrapped in the staged window, individually, so where they fall in that order never changes.
8357
+ let announcedStagedPack = false;
8358
+ for (const step of execSteps) {
8359
+ if (packStepIds.has(step.id)) {
8360
+ if (!announcedStagedPack) {
8361
+ say('dz release: smoke: packed tarballs staged like the live publish (workspace:* → exact sibling versions)');
8362
+ announcedStagedPack = true;
8363
+ }
8364
+ // Codex finding 2: stage ONLY the package this pack step packs — sibling pins come from
8365
+ // workspaceVersions, and `npm pack` reads the packed package's manifest alone.
8366
+ const packed = factsList.filter((f) => f.name === step.pkg);
8367
+ try {
8368
+ withStagedPackageJson(packed, workspaceVersions, write, () => runGateStep(step), 'dz release');
8369
+ } catch (err) {
8370
+ // a staging/restore failure is a FAILED step with the reason, never a silent skip — and ONE
8371
+ // record per step: a run already recorded by runGateStep is replaced, not duplicated (Codex r2)
8372
+ const failed = { stepId: step.id, exitCode: 1, stdout: '', stderr: formatPublishError(err), durationMs: 0, timedOut: false };
8373
+ const at = executions.findIndex((e) => e.stepId === step.id);
8374
+ if (at >= 0) executions[at] = failed; else executions.push(failed);
8375
+ }
8376
+ } else {
8377
+ runGateStep(step);
8378
+ }
8136
8379
  }
8380
+
8137
8381
  if (smokeTmp !== undefined) {
8138
8382
  try { rmSync(smokeTmp, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
8139
8383
  }
@@ -8146,7 +8390,22 @@ function cmdRelease(options: Map<string, string>, flags: Set<string>, cwd: strin
8146
8390
  for (const g of verdict.gates) {
8147
8391
  const icon = g.status === 'pass' ? '✓' : g.status === 'fail' ? '✗' : '○';
8148
8392
  say(` ${icon} ${g.gate.padEnd(7)} ${g.status.toUpperCase().padEnd(4)} ${g.passed} passed, ${g.failures.length} failed, ${g.skips.length} skipped`);
8149
- for (const f of g.failures) say(` [${f.class}] ${f.pkg !== undefined ? `${f.pkg}: ` : ''}${f.reason}`);
8393
+ for (const f of g.failures) {
8394
+ say(` [${f.class}] ${f.pkg !== undefined ? `${f.pkg}: ` : ''}${f.reason}`);
8395
+ // FR-2 / AM-4 / AM-6 (feature release-gate-output-tail): print each non-empty stream's
8396
+ // tail under the failure line, labelled `stdout:`/`stderr:` at the failure's own 6-space
8397
+ // indent, with its content lines at an 8-space CONTINUATION indent so a reader can tell a
8398
+ // tail line from a new failure/skip bullet at a glance; --json carries the same `tails`
8399
+ // object as-is (present, possibly with empty strings, on every executed failure).
8400
+ if (f.tails !== undefined) {
8401
+ for (const stream of ['stdout', 'stderr'] as const) {
8402
+ const t = f.tails[stream];
8403
+ if (t.length === 0) continue;
8404
+ say(` ${stream}:`);
8405
+ for (const tailLine of t.split('\n')) say(` ${tailLine}`);
8406
+ }
8407
+ }
8408
+ }
8150
8409
  for (const sk of g.skips) say(` [${sk.class}] ${sk.pkg}: ${sk.reason}`);
8151
8410
  }
8152
8411
  say(` verdict at ${verdict.timestamp}: ${verdict.publishAction === 'proceed' ? '✓ all gates green' : '✗ RELEASE BLOCKED'}`);
@@ -10865,41 +11124,175 @@ function runGuardEvaluation(root: string, op: string, text: string | undefined,
10865
11124
  return result;
10866
11125
  }
10867
11126
 
11127
+ /** A package this audit call concerns (feature `publish-gate-audit-durable`, FR-1). */
11128
+ interface PublishGateAuditPackage {
11129
+ readonly name: string;
11130
+ readonly version: string;
11131
+ /** Present once a tarball has actually been built (packedTransport/smoke); absent → `sha256:n/a`. */
11132
+ readonly tarballSha256?: string;
11133
+ }
11134
+
11135
+ /**
11136
+ * fs primitives `appendPublishGateAudit` needs for its durable write (FR-2), injectable so a test
11137
+ * can make `fsyncSync` throw without touching the real filesystem underneath every OTHER seam this
11138
+ * function shares with production. Left unset in production → the real `node:fs` functions above.
11139
+ */
11140
+ interface PublishGateAuditFsLayer {
11141
+ readonly existsSync: (path: string) => boolean;
11142
+ readonly mkdirSync: (path: string, opts: { recursive: boolean }) => void;
11143
+ readonly openSync: (path: string, flags: number) => number;
11144
+ /**
11145
+ * AM-2 (Codex round-1 review, finding 2, high): `Buffer`, not `string` — a SHORT write must
11146
+ * resume at the exact BYTE it stopped at, and a string-based API cannot express that safely once
11147
+ * the data contains any multi-byte UTF-8 character (re-encoding a slice of an already-partial
11148
+ * string can silently produce different bytes than the ones actually pending). The real
11149
+ * `node:fs.writeSync` accepts a `Buffer` directly (no re-encoding), so this changes nothing about
11150
+ * what production writes.
11151
+ */
11152
+ readonly writeSync: (fd: number, data: Buffer) => number;
11153
+ readonly fsyncSync: (fd: number) => void;
11154
+ readonly closeSync: (fd: number) => void;
11155
+ }
11156
+
10868
11157
  /**
10869
- * Feature `publish-sibling-drift-gate` (FR-5/AM-6): both the sibling-drift and packed-install-smoke
11158
+ * AM-2: loop `writeSync` until every byte of `data` has been accepted, checking the RETURNED
11159
+ * length on every call (Codex round-1 review, finding 1: the old code called `writeSync` once and
11160
+ * ignored the return value — a short write followed by `fsyncSync` durably persists a TRUNCATED,
11161
+ * unparseable JSON line into an append-only log every future read walks). A call that reports zero
11162
+ * or negative progress can never complete the buffer and would spin forever — that is treated as a
11163
+ * failure, not a retry target.
11164
+ */
11165
+ function writeAllSync(fsLayer: PublishGateAuditFsLayer, fd: number, data: Buffer): void {
11166
+ let remaining = data;
11167
+ while (remaining.length > 0) {
11168
+ const n = fsLayer.writeSync(fd, remaining);
11169
+ if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) {
11170
+ throw new Error(`writeSync made no progress (returned ${n}) with ${remaining.length} byte(s) still pending`);
11171
+ }
11172
+ // Codex round-2 (2026-09-14) finding 3: a layer claiming MORE bytes than it was given is lying
11173
+ // about the record, and must not reach "logged: true" through a subarray that just goes empty.
11174
+ if (n > remaining.length) {
11175
+ throw new Error(`writeSync claimed ${n} byte(s) written but only ${remaining.length} were supplied`);
11176
+ }
11177
+ remaining = remaining.subarray(n);
11178
+ }
11179
+ }
11180
+
11181
+ const REAL_PUBLISH_GATE_AUDIT_FS: PublishGateAuditFsLayer = { existsSync, mkdirSync, openSync, writeSync, fsyncSync, closeSync };
11182
+
11183
+ /**
11184
+ * Feature `publish-sibling-drift-gate` (FR-5/AM-6), durability + per-package identity added by
11185
+ * feature `publish-gate-audit-durable` (FR-1/FR-2): both the sibling-drift and packed-install-smoke
10870
11186
  * gates write to the SAME append-only, hash-chained `.dz/guard-audit.jsonl` the declarative
10871
11187
  * `dz guard` rules use — visibility for `dz guard promote`/`dz compounding` never depends on
10872
11188
  * which mechanism produced the finding. `pass` records go through as an informational `note`
10873
11189
  * (never a violation, so they can never flip the row's own verdict) so a clean check is ALSO on
10874
11190
  * the record, not just a block or an override (AM-6: "аудит без записи = не аудит").
10875
11191
  *
10876
- * Returns whether the write actually landed. Most callers are best-effort (a write failure never
10877
- * blocks a verdict already decided) the one exception is an `--allow-sibling-drift` OVERRIDE,
10878
- * whose caller MUST check this return value: an override is not real without a durable row behind
10879
- * it (AM-6's load-bearing property see `auditedOverride` in `cmdPublish`).
11192
+ * FR-1: one JSONL record PER PACKAGE in `packages`, each naming the package, its version, and the
11193
+ * tarball's sha256 (or the explicit `sha256:n/a` before a tarball exists a dry-run preview). FR-2:
11194
+ * the write is `openSync('a') writeSync fsyncSync(fd) closeSync`, plus an `fsyncSync` of the
11195
+ * `.dz` directory itself the one time this call CREATES it (a file's own fsync durably persists its
11196
+ * bytes; the directory entry that makes the file findable after a crash needs its own fsync — the
11197
+ * same lesson `integration-apply.ts`'s `fsyncDirectory` already encodes).
11198
+ *
11199
+ * Returns `{ logged, reason? }` — never a bare boolean, so a caller can print WHY a write failed,
11200
+ * not just that it did. Most callers are best-effort (a write failure never blocks a verdict already
11201
+ * decided) — the one exception is an `--allow-sibling-drift` OVERRIDE, whose caller MUST check
11202
+ * `logged`: an override is not real without a durable row behind it (AM-6's load-bearing property —
11203
+ * see `auditedOverride` in `cmdPublish`).
10880
11204
  */
10881
11205
  function appendPublishGateAudit(
10882
11206
  root: string,
10883
11207
  rule: 'sibling-drift' | 'packed-install-smoke',
10884
11208
  verdict: 'pass' | 'warn' | 'block',
10885
11209
  detail: string,
11210
+ packages: readonly PublishGateAuditPackage[],
10886
11211
  overrideReason?: string,
10887
- ): boolean {
11212
+ fsLayer: PublishGateAuditFsLayer = REAL_PUBLISH_GATE_AUDIT_FS,
11213
+ ): { logged: boolean; reason?: string } {
11214
+ if (packages.length === 0) return { logged: true }; // nothing to record about — not a failure
11215
+ const dzDir = join(root, '.dz');
11216
+ const auditPath = join(dzDir, 'guard-audit.jsonl');
11217
+ const dzDirExisted = fsLayer.existsSync(dzDir);
11218
+ // AM-2: tracked BEFORE the write — this is what decides whether the append call below is about
11219
+ // to CREATE the file (needing a `.dz` directory-entry fsync afterwards) or extend an existing one
11220
+ // (whose directory entry is already durable from a previous append).
11221
+ const auditFileExisted = fsLayer.existsSync(auditPath);
10888
11222
  try {
10889
- const rec = auditRecord(
11223
+ fsLayer.mkdirSync(dzDir, { recursive: true });
11224
+ } catch (err) {
11225
+ return { logged: false, reason: `could not create ${dzDir}: ${(err as Error).message.split('\n')[0]}` };
11226
+ }
11227
+ const records = packages.map((pkg) => {
11228
+ const sha = pkg.tarballSha256 !== undefined && pkg.tarballSha256 !== '' ? pkg.tarballSha256 : 'n/a';
11229
+ const label = `${rule}: ${pkg.name}@${pkg.version} sha256:${sha} — ${detail}`;
11230
+ return auditRecord(
10890
11231
  verdict === 'pass'
10891
- ? { op: 'publish', verdict, violations: [], checked: [rule], notEstablished: [], notes: [`${rule}: ${detail}`] }
10892
- : { op: 'publish', verdict, violations: [{ rule, severity: 'hard', detail }], checked: [rule], notEstablished: [] },
11232
+ ? { op: 'publish', verdict, violations: [], checked: [rule], notEstablished: [], notes: [label] }
11233
+ : { op: 'publish', verdict, violations: [{ rule, severity: 'hard', detail: label }], checked: [rule], notEstablished: [] },
10893
11234
  new Date().toISOString(),
10894
11235
  overrideReason !== undefined ? { reason: overrideReason } : undefined,
10895
11236
  );
10896
- mkdirSync(join(root, '.dz'), { recursive: true });
10897
- const auditPath = join(root, '.dz', 'guard-audit.jsonl');
10898
- writeFileSync(auditPath, appendChainedLines([rec], readLogTail(auditPath)), { flag: 'a' });
10899
- return true;
10900
- } catch {
10901
- return false; // audit write failed the caller decides whether that itself is refusable (AM-6)
11237
+ });
11238
+ let bytes: string;
11239
+ try {
11240
+ bytes = appendChainedLines(records, readLogTail(auditPath));
11241
+ } catch (err) {
11242
+ return { logged: false, reason: `could not build the chained record: ${(err as Error).message.split('\n')[0]}` };
10902
11243
  }
11244
+ if (bytes === '') return { logged: true };
11245
+ const buf = Buffer.from(bytes, 'utf-8');
11246
+ let fd: number | undefined;
11247
+ try {
11248
+ fd = fsLayer.openSync(auditPath, fsConstants.O_APPEND | fsConstants.O_CREAT | fsConstants.O_WRONLY);
11249
+ writeAllSync(fsLayer, fd, buf); // AM-2: loops on a short write, throws on no progress
11250
+ fsLayer.fsyncSync(fd);
11251
+ fsLayer.closeSync(fd);
11252
+ fd = undefined;
11253
+ } catch (err) {
11254
+ return { logged: false, reason: (err as Error).message.split('\n')[0] ?? String(err) };
11255
+ } finally {
11256
+ if (fd !== undefined) { try { fsLayer.closeSync(fd); } catch { /* best-effort */ } }
11257
+ }
11258
+ // AM-2 (Codex round-1 review, finding 1, high): a directory-entry fsync is REQUIRED, not
11259
+ // best-effort, whenever THIS call is the reason the entry needed persisting — swallowing its
11260
+ // failure used to let `logged: true` go out about a record whose directory entry can vanish on a
11261
+ // crash before the next fsck. Two DIFFERENT entries can need persisting, tracked independently:
11262
+ if (!auditFileExisted) {
11263
+ // The audit FILE was just created by the open() above — `.dz` (its containing directory) needs
11264
+ // its own fsync so the new directory entry survives a crash; the file's own fsync (above) only
11265
+ // guarantees the file's DATA, not that anything can find it afterwards.
11266
+ let dfd: number | undefined;
11267
+ try {
11268
+ dfd = fsLayer.openSync(dzDir, fsConstants.O_RDONLY);
11269
+ fsLayer.fsyncSync(dfd);
11270
+ } catch (err) {
11271
+ return { logged: false, reason: `could not fsync ${dzDir} after creating ${auditPath}: ${(err as Error).message.split('\n')[0]}` };
11272
+ } finally {
11273
+ if (dfd !== undefined) { try { fsLayer.closeSync(dfd); } catch { /* best-effort, does not affect the verdict already returned */ } }
11274
+ }
11275
+ }
11276
+ if (!dzDirExisted) {
11277
+ // `.dz` ITSELF was just created by `mkdirSync` above — its PARENT (`root`) needs its own fsync
11278
+ // so `.dz`'s OWN directory entry survives a crash (the fsync of `.dz` just above only durably
11279
+ // persists entries INSIDE `.dz`, not the fact that `.dz` exists at all).
11280
+ let pfd: number | undefined;
11281
+ try {
11282
+ pfd = fsLayer.openSync(root, fsConstants.O_RDONLY);
11283
+ fsLayer.fsyncSync(pfd);
11284
+ } catch (err) {
11285
+ return { logged: false, reason: `could not fsync ${root} after creating ${dzDir}: ${(err as Error).message.split('\n')[0]}` };
11286
+ } finally {
11287
+ if (pfd !== undefined) { try { fsLayer.closeSync(pfd); } catch { /* best-effort, does not affect the verdict already returned */ } }
11288
+ }
11289
+ }
11290
+ return { logged: true };
11291
+ }
11292
+
11293
+ /** FR-2: the printed suffix a caller appends to its own verdict line — never silent about logging. */
11294
+ function auditSuffix(wrote: { logged: boolean; reason?: string }): string {
11295
+ return wrote.logged ? ' (logged)' : ` (audit NOT logged: ${wrote.reason ?? 'unknown reason'})`;
10903
11296
  }
10904
11297
 
10905
11298
  function renderGuardObservation(observation: GuardObservation): string {
@@ -20144,7 +20537,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
20144
20537
  case 'auto-canonicalize':
20145
20538
  return await cmdAutoCanonicalize(options, cwd, write);
20146
20539
  case 'publish':
20147
- return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner, io.publishSiblingDriftFetcher, io.publishPackedInstallRunner, io.publishExecRunner);
20540
+ return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner, io.publishSiblingDriftFetcher, io.publishPackedInstallRunner, io.publishExecRunner, io.publishGateAuditFsLayer, io.publishNpmPackRunner);
20148
20541
  case 'release':
20149
20542
  return cmdRelease(options, flags, cwd, write, io.releaseRunner);
20150
20543
  case 'parity':