@dzhechkov/harness-cli 0.8.23 → 0.8.24
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 +16 -16
- package/README.md +116 -6
- package/dist/boolean-flags.d.ts.map +1 -1
- package/dist/boolean-flags.js +2 -0
- package/dist/boolean-flags.js.map +1 -1
- package/dist/cli.d.ts +28 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +458 -12
- package/dist/cli.js.map +1 -1
- package/dist/known-flags.d.ts.map +1 -1
- package/dist/known-flags.js +2 -0
- package/dist/known-flags.js.map +1 -1
- package/package.json +27 -27
- package/sbom.json +15 -15
- package/src/boolean-flags.ts +2 -0
- package/src/cli.ts +474 -11
- package/src/known-flags.ts +2 -0
package/src/cli.ts
CHANGED
|
@@ -92,6 +92,7 @@ import {
|
|
|
92
92
|
generatePlugin,
|
|
93
93
|
publishPackages,
|
|
94
94
|
runSetup,
|
|
95
|
+
memoryBackendSourceLabel,
|
|
95
96
|
runMigrate,
|
|
96
97
|
searchRegistry,
|
|
97
98
|
runSync,
|
|
@@ -318,6 +319,11 @@ import {
|
|
|
318
319
|
verifyManifest,
|
|
319
320
|
hashPackBytes,
|
|
320
321
|
rewriteWorkspaceSpecs,
|
|
322
|
+
detectSiblingDrift,
|
|
323
|
+
planPackedInstallSmoke,
|
|
324
|
+
judgePackedInstallSmoke,
|
|
325
|
+
type FetchPublished,
|
|
326
|
+
type PackedInstallExecution,
|
|
321
327
|
listPackFiles,
|
|
322
328
|
listSignablePackFiles,
|
|
323
329
|
assertKeyOutsideTree,
|
|
@@ -633,7 +639,7 @@ import type { SetupSpec } from '@dzhechkov/harness-core';
|
|
|
633
639
|
import type { LogTail } from '@dzhechkov/harness-core';
|
|
634
640
|
import type { DeadwoodInventoryItem } from '@dzhechkov/harness-core';
|
|
635
641
|
import type { ContractDiagnostic, ContractEvidenceReader } from '@dzhechkov/harness-core';
|
|
636
|
-
import type { ProvenanceMode, PackVerdict, PatternRecord, RecallPatternsOptions, TeachGuardResult, TargetName, IntegrationOutcome, BookKU, HarmonizeReport, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep, SlopFinding, SlopLintConfig, SlopRegistry } from '@dzhechkov/harness-core';
|
|
642
|
+
import type { ProvenanceMode, PackVerdict, PatternRecord, RecallPatternsOptions, TeachGuardResult, TargetName, IntegrationOutcome, BookKU, HarmonizeReport, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep, SlopFinding, SlopLintConfig, SlopRegistry, PackedTarballArtifact, PackedTransportSmokeVerdict } from '@dzhechkov/harness-core';
|
|
637
643
|
import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
|
|
638
644
|
import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
|
|
639
645
|
|
|
@@ -925,6 +931,30 @@ export interface CliIo {
|
|
|
925
931
|
readonly releaseRunner?: ReleaseExecRunner;
|
|
926
932
|
/** Post-publish mirror command seam; production uses synchronous shell execution. */
|
|
927
933
|
readonly publishMirrorRunner?: PublishMirrorRunner;
|
|
934
|
+
/**
|
|
935
|
+
* Test seam for `dz publish`'s sibling-drift gate (feature publish-sibling-drift-gate):
|
|
936
|
+
* overrides the registry fetch (production leaves it unset → real `npm pack` + extract into a
|
|
937
|
+
* temp dir). Tests inject a local directory instead of hitting the real registry.
|
|
938
|
+
*/
|
|
939
|
+
readonly publishSiblingDriftFetcher?: FetchPublished;
|
|
940
|
+
/**
|
|
941
|
+
* Test seam for `dz publish`'s packed-install smoke: overrides the pack/install/`--version`
|
|
942
|
+
* subprocesses (production leaves it unset → real `execSync`, stdio piped). Mirrors
|
|
943
|
+
* {@link CliIo.releaseRunner}.
|
|
944
|
+
*/
|
|
945
|
+
readonly publishPackedInstallRunner?: ReleaseExecRunner;
|
|
946
|
+
/**
|
|
947
|
+
* AM-1 (feature publish-sibling-drift-gate): overrides EVERY subprocess `publishPackages` would
|
|
948
|
+
* run on a LIVE publish — build, the `npm pack`/`npm publish <tgz>` packedTransport commands, and
|
|
949
|
+
* the `npm view` registry probes (production leaves it unset → real `execSync`, stdio piped).
|
|
950
|
+
* Threaded into `publishPackages`'s `exec` option so a test can drive the FULL live+packedTransport
|
|
951
|
+
* `cmdPublish` path (pack → smoke → publish → registry-confirm) with zero network and zero real
|
|
952
|
+
* `npm publish`.
|
|
953
|
+
*/
|
|
954
|
+
readonly publishExecRunner?: (
|
|
955
|
+
command: string,
|
|
956
|
+
options: { cwd?: string | URL | undefined; stdio?: unknown; encoding?: unknown; timeout?: number | undefined; env?: NodeJS.ProcessEnv | undefined },
|
|
957
|
+
) => string;
|
|
928
958
|
/**
|
|
929
959
|
* Test seam for `dz install`: overrides the `npm install` subprocess (production leaves
|
|
930
960
|
* it unset → real `execSync`, stdio piped). A stub runner that pre-stages a fixture
|
|
@@ -6200,7 +6230,15 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
6200
6230
|
|
|
6201
6231
|
// Step 3: Run setup (hooks + memory + config)
|
|
6202
6232
|
write(`║ 3. Setting up learning environment... ║`);
|
|
6203
|
-
const
|
|
6233
|
+
const memoryOptRaw = options.get('memory');
|
|
6234
|
+
// FR-1/T3 (feature `setup-backend-from-config`): pass `--memory` through AS-IS — `agentdb`,
|
|
6235
|
+
// `jsonl`, or `undefined` — never collapsed to `undefined` on anything but agentdb. The prior
|
|
6236
|
+
// `memoryOpt === 'agentdb' ? 'agentdb' : undefined` made an explicit `--memory jsonl` INDISTINCT
|
|
6237
|
+
// from "no flag at all", so `runSetup`'s config-aware default (FR-2's downgrade path) could never
|
|
6238
|
+
// fire from the CLI. An unrecognised value (neither `agentdb` nor `jsonl`) still reads as
|
|
6239
|
+
// "no flag" — the same permissive fallback as before.
|
|
6240
|
+
const memoryOpt: 'agentdb' | 'jsonl' | undefined =
|
|
6241
|
+
memoryOptRaw === 'agentdb' ? 'agentdb' : memoryOptRaw === 'jsonl' ? 'jsonl' : undefined;
|
|
6204
6242
|
// ADR-001 Decision 2 (feature setup-installs-apply-leg): bake THIS CLI's own installed
|
|
6205
6243
|
// @dzhechkov/harness-core into the generated apply-leg hooks — the installation actually running
|
|
6206
6244
|
// `dz setup` is the one a consumer's project can always reach, unlike a hard-coded npm prefix
|
|
@@ -6217,13 +6255,16 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
6217
6255
|
projectRoot,
|
|
6218
6256
|
target,
|
|
6219
6257
|
preset,
|
|
6220
|
-
memory: memoryOpt
|
|
6258
|
+
memory: memoryOpt,
|
|
6221
6259
|
noHooks: flags.has('no-hooks'),
|
|
6222
6260
|
noMemory: flags.has('no-memory'),
|
|
6223
6261
|
force: flags.has('force'),
|
|
6224
6262
|
installDriver: flags.has('install-driver'),
|
|
6225
6263
|
coreDistDir,
|
|
6226
6264
|
});
|
|
6265
|
+
// FR-3: name the source of the backend actually used — never left to be inferred from the flag
|
|
6266
|
+
// alone, since the backend may now come from `.dz/config.json` or the jsonl default.
|
|
6267
|
+
write(`dz setup: memory backend: ${setupResult.memoryBackend} (${memoryBackendSourceLabel(setupResult.memoryBackendSource)})`);
|
|
6227
6268
|
|
|
6228
6269
|
for (const step of setupResult.steps) {
|
|
6229
6270
|
const icon = step.status === 'done' ? '✓' : step.status === 'skipped' ? '○' : '✗';
|
|
@@ -6275,7 +6316,11 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
6275
6316
|
// not from package presence — a skipped hook/MCP step must not let the summary claim a store
|
|
6276
6317
|
// nothing writes to (audit code#3).
|
|
6277
6318
|
const wiring = setupResult.steps.find((s) => s.name === 'agentdb wiring');
|
|
6278
|
-
|
|
6319
|
+
// Keyed off the RESOLVED backend (setupResult.memoryBackend), not the raw flag: FR-1 means the
|
|
6320
|
+
// flag can be absent while the actual backend is still agentdb (config-sourced) — the old
|
|
6321
|
+
// `memoryOpt === 'agentdb'` check would have mislabeled that run as jsonl right after fixing the
|
|
6322
|
+
// underlying steps to keep it agentdb.
|
|
6323
|
+
const backendLabel = setupResult.memoryBackend === 'agentdb'
|
|
6279
6324
|
? (wiring?.status === 'done' ? 'agentdb (.dz/agentdb.db + .dz/agentdb-mcp.db, separate stores)' : `agentdb INCOMPLETE — see setup steps`)
|
|
6280
6325
|
: 'sessions.jsonl + patterns.jsonl';
|
|
6281
6326
|
write(`║ Learning: ${backendLabel.padEnd(41)}║`);
|
|
@@ -6975,12 +7020,33 @@ function mirrorFailureMessage(error: unknown): string {
|
|
|
6975
7020
|
return String(error);
|
|
6976
7021
|
}
|
|
6977
7022
|
|
|
7023
|
+
/**
|
|
7024
|
+
* Scratch root for the packed-install smoke's pack/install dirs (feature
|
|
7025
|
+
* publish-sibling-drift-gate). MEASURED 2026-09-13: npm resolves a LOCAL tarball path (`npm
|
|
7026
|
+
* install <path-to.tgz>`) relative to `os.tmpdir()` — not to cwd — whenever that path sits
|
|
7027
|
+
* INSIDE `os.tmpdir()`, and does the same for the install dir; put pack and install dirs both
|
|
7028
|
+
* under `tmpdir()` and the recorded `file:` spec loses its `tmpdir()` prefix entirely (reproducer:
|
|
7029
|
+
* a fresh `npm pack <src> --pack-destination "$T/pack"` + `cd "$T/install" && npm install
|
|
7030
|
+
* "$T/pack/x.tgz"` with `$T` under `/tmp` silently installs NOTHING — "changed 1 package", empty
|
|
7031
|
+
* node_modules, `reify moves {}` in `--loglevel silly`; the identical commands under `/var/tmp`
|
|
7032
|
+
* install correctly). A directory outside `os.tmpdir()` sidesteps the quirk entirely.
|
|
7033
|
+
*/
|
|
7034
|
+
function packedInstallScratchRoot(): string {
|
|
7035
|
+
return existsSync('/var/tmp') ? '/var/tmp' : tmpdir();
|
|
7036
|
+
}
|
|
7037
|
+
|
|
6978
7038
|
function cmdPublish(
|
|
6979
7039
|
options: Map<string, string>,
|
|
6980
7040
|
flags: Set<string>,
|
|
6981
7041
|
cwd: string,
|
|
6982
7042
|
writeOutput: Write,
|
|
6983
7043
|
mirrorRunner?: PublishMirrorRunner,
|
|
7044
|
+
siblingDriftFetcher?: FetchPublished,
|
|
7045
|
+
packedInstallRunner?: ReleaseExecRunner,
|
|
7046
|
+
publishExecRunner?: (
|
|
7047
|
+
command: string,
|
|
7048
|
+
options: { cwd?: string | URL | undefined; stdio?: unknown; encoding?: unknown; timeout?: number | undefined; env?: NodeJS.ProcessEnv | undefined },
|
|
7049
|
+
) => string,
|
|
6984
7050
|
): number {
|
|
6985
7051
|
const json = flags.has('json');
|
|
6986
7052
|
// Under --json stdout carries exactly one JSON document, so every human line — guard notes, refusals,
|
|
@@ -6989,9 +7055,9 @@ function cmdPublish(
|
|
|
6989
7055
|
const write: Write = json ? (line) => { process.stderr.write(`${line}\n`); } : writeOutput;
|
|
6990
7056
|
// Reject unknown flags/options so a typo (e.g. `--dry-rum`) can NEVER be
|
|
6991
7057
|
// silently swallowed and flip the command into live-publish mode.
|
|
6992
|
-
const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance', 'json', 'no-mirror']);
|
|
7058
|
+
const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance', 'json', 'no-mirror', 'allow-sibling-drift', 'include-drifted']);
|
|
6993
7059
|
const allowedOptions = new Set(['filter', 'claim-check', 'no-guard', 'sign-key', 'mirror-cmd']);
|
|
6994
|
-
const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>, --mirror-cmd <cmd>, --no-mirror, --no-guard "<reason>" (skip the guard pre-flight; logged)';
|
|
7060
|
+
const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>, --mirror-cmd <cmd>, --no-mirror, --no-guard "<reason>" (skip the guard pre-flight; logged), --allow-sibling-drift (override the sibling-drift gate; logged), --include-drifted (auto-extend the batch with a drifted sibling)';
|
|
6995
7061
|
for (const flag of flags) {
|
|
6996
7062
|
if (!allowedFlags.has(flag)) {
|
|
6997
7063
|
write(`dz publish: unknown option --${flag}`);
|
|
@@ -7073,12 +7139,269 @@ function cmdPublish(
|
|
|
7073
7139
|
const claimCheckOpt = (claimCheckRaw as 'off' | 'warn' | 'error' | undefined) ?? 'warn';
|
|
7074
7140
|
|
|
7075
7141
|
const bumpOnly = flags.has('bump-only');
|
|
7076
|
-
|
|
7077
7142
|
// SAFETY: dry-run is the DEFAULT. A real publish requires an EXPLICIT opt-in
|
|
7078
7143
|
// via --yes, --confirm, or --no-dry-run. Without one, we never bump or publish.
|
|
7144
|
+
// Computed HERE (moved up from below the gates, AM-5) so both gates can see it: a dry run keeps
|
|
7145
|
+
// previewing packed-install with the CURRENT pre-bump tarball (nothing to compare a LIVE publish
|
|
7146
|
+
// against yet), while a live run defers the real packed-install-smoke into `publishPackages`'s
|
|
7147
|
+
// `packedTransport` — the one that tests the ACTUAL bytes about to ship (AM-1).
|
|
7079
7148
|
const wantsLive = flags.has('yes') || flags.has('confirm') || flags.has('no-dry-run');
|
|
7080
7149
|
const dryRun = !wantsLive;
|
|
7081
7150
|
|
|
7151
|
+
// ── FR-1..FR-4 — sibling-drift gate, then packed-install smoke (feature
|
|
7152
|
+
// publish-sibling-drift-gate, ADR-001). The sibling-drift gate runs before the signature gate
|
|
7153
|
+
// and the live-publish banner (so a --include-drifted-expanded batch is checked and shown too).
|
|
7154
|
+
// AM-5: on a DRY RUN both gates always print their verdict, even once sibling-drift already
|
|
7155
|
+
// blocks — the whole point of a preview is full information before anything ships. On a LIVE
|
|
7156
|
+
// run, sibling-drift still refuses immediately (packing/installing a doomed batch wastes real
|
|
7157
|
+
// time); its own packed-install smoke is deferred into `publishPackages`'s `packedTransport`
|
|
7158
|
+
// (AM-1) — the one gate that tests the tarball bytes actually handed to `npm publish`.
|
|
7159
|
+
const allowSiblingDrift = flags.has('allow-sibling-drift');
|
|
7160
|
+
const includeDrifted = flags.has('include-drifted');
|
|
7161
|
+
const allPackages = discoverPackages(cwd);
|
|
7162
|
+
const workspaceVersions = new Map(allPackages.map((p) => [p.name, p.version]));
|
|
7163
|
+
const workspaceDirs = new Map(allPackages.map((p) => [p.name, p.dir]));
|
|
7164
|
+
const matchesFilter = (pk: { name: string; dir: string }): boolean =>
|
|
7165
|
+
filter === undefined || filter.length === 0 || filter.some((f) => pk.name.includes(f) || pk.dir.includes(f));
|
|
7166
|
+
let targets = allPackages.filter(matchesFilter);
|
|
7167
|
+
let batchNames = new Set(targets.map((p) => p.name));
|
|
7168
|
+
|
|
7169
|
+
// Production default: `npm pack <name>@<version>` into a temp dir, extracted. Tests inject a
|
|
7170
|
+
// local directory (ADR-001, "fetchPublished … в тестах — локальный каталог").
|
|
7171
|
+
const fetchPublished: FetchPublished =
|
|
7172
|
+
siblingDriftFetcher ??
|
|
7173
|
+
((name, version) => {
|
|
7174
|
+
try {
|
|
7175
|
+
const tmp = mkdtempSync(join(tmpdir(), 'dz-sibling-drift-'));
|
|
7176
|
+
execSync(`npm pack ${name}@${version} --pack-destination ${JSON.stringify(tmp)}`, {
|
|
7177
|
+
stdio: 'pipe',
|
|
7178
|
+
encoding: 'utf-8',
|
|
7179
|
+
timeout: 60_000,
|
|
7180
|
+
});
|
|
7181
|
+
const tarball = readdirSync(tmp).find((f) => f.endsWith('.tgz'));
|
|
7182
|
+
if (tarball === undefined) return null;
|
|
7183
|
+
execSync(`tar -xzf ${JSON.stringify(join(tmp, tarball))} -C ${JSON.stringify(tmp)}`, { stdio: 'pipe', timeout: 60_000 });
|
|
7184
|
+
return { dir: join(tmp, 'package') };
|
|
7185
|
+
} catch {
|
|
7186
|
+
return null;
|
|
7187
|
+
}
|
|
7188
|
+
});
|
|
7189
|
+
|
|
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;
|
|
7199
|
+
}
|
|
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;
|
|
7202
|
+
};
|
|
7203
|
+
|
|
7204
|
+
let driftBlocked = 0;
|
|
7205
|
+
const extraBatch = new Set<string>();
|
|
7206
|
+
// AM-2: --include-drifted must reach a FIXED POINT over transitive drifted siblings — a sibling
|
|
7207
|
+
// folded into the batch can itself depend on a drifted sibling outside it, and the round-1 review
|
|
7208
|
+
// (finding 2) showed the single pass never re-checked an EXPANDED batch's own new edges. Capped at
|
|
7209
|
+
// `allPackages.length + 1` rounds (the plan's own "цикл с потолком = число пакетов").
|
|
7210
|
+
const maxRounds = allPackages.length + 1;
|
|
7211
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
7212
|
+
let addedThisRound = false;
|
|
7213
|
+
for (const pk of targets) {
|
|
7214
|
+
let manifestObj: {
|
|
7215
|
+
dependencies?: Record<string, string>;
|
|
7216
|
+
peerDependencies?: Record<string, string>;
|
|
7217
|
+
optionalDependencies?: Record<string, string>;
|
|
7218
|
+
} | undefined;
|
|
7219
|
+
try {
|
|
7220
|
+
manifestObj = JSON.parse(readFileSync(join(pk.dir, 'package.json'), 'utf-8'));
|
|
7221
|
+
} catch (err) {
|
|
7222
|
+
// AM-3: an unreadable/invalid package.json for a BATCH package is an input this HARD gate
|
|
7223
|
+
// cannot build — it must BLOCK, never silently degrade to "no dependencies" (which used to
|
|
7224
|
+
// read as a clean n/a).
|
|
7225
|
+
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++;
|
|
7231
|
+
}
|
|
7232
|
+
continue;
|
|
7233
|
+
}
|
|
7234
|
+
const deps = manifestObj?.dependencies ?? {};
|
|
7235
|
+
const peerDeps = manifestObj?.peerDependencies ?? {};
|
|
7236
|
+
const optionalDeps = manifestObj?.optionalDependencies ?? {};
|
|
7237
|
+
|
|
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).
|
|
7240
|
+
const anyWorkspaceDep = [...Object.values(deps), ...Object.values(peerDeps), ...Object.values(optionalDeps)]
|
|
7241
|
+
.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
|
+
|
|
7247
|
+
const drifts = detectSiblingDrift({
|
|
7248
|
+
dependencies: deps,
|
|
7249
|
+
peerDependencies: peerDeps,
|
|
7250
|
+
optionalDependencies: optionalDeps,
|
|
7251
|
+
workspaceVersions,
|
|
7252
|
+
workspaceDirs,
|
|
7253
|
+
batch: batchNames,
|
|
7254
|
+
fetchPublished,
|
|
7255
|
+
});
|
|
7256
|
+
|
|
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++;
|
|
7264
|
+
} 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`);
|
|
7266
|
+
driftBlocked++;
|
|
7267
|
+
}
|
|
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(', ')})` : ''}`);
|
|
7273
|
+
}
|
|
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++;
|
|
7281
|
+
}
|
|
7282
|
+
}
|
|
7283
|
+
}
|
|
7284
|
+
|
|
7285
|
+
if (driftBlocked > 0) break; // nothing to expand into a run that already refuses
|
|
7286
|
+
if (!includeDrifted || !addedThisRound) break; // no auto-expand requested, or fixed point reached
|
|
7287
|
+
|
|
7288
|
+
// FR-4: --include-drifted folds the drifted sibling(s) into the batch — they bump patch like
|
|
7289
|
+
// any other package in `publishPackages`' own (unchanged) bump logic. Re-loop: the newly
|
|
7290
|
+
// folded-in sibling(s) may themselves depend on a drifted sibling outside the (now bigger) batch.
|
|
7291
|
+
filter = filter === undefined ? [...batchNames, ...extraBatch] : [...filter, ...extraBatch];
|
|
7292
|
+
targets = allPackages.filter(matchesFilter);
|
|
7293
|
+
batchNames = new Set(targets.map((p) => p.name));
|
|
7294
|
+
}
|
|
7295
|
+
|
|
7296
|
+
const siblingDriftFailed = driftBlocked > 0;
|
|
7297
|
+
if (siblingDriftFailed && !dryRun) {
|
|
7298
|
+
write(`dz publish: refusing to publish (${driftBlocked} sibling-drift violation(s))`);
|
|
7299
|
+
return 1;
|
|
7300
|
+
}
|
|
7301
|
+
|
|
7302
|
+
// FR-3 — packed-install smoke: pack the WHOLE (possibly --include-drifted-expanded) batch,
|
|
7303
|
+
// install every tarball together in a CLEAN dir (out-of-batch siblings resolve from the
|
|
7304
|
+
// registry, exactly like a fresh user's install), then boot every bin with --version.
|
|
7305
|
+
// "n/a" (FR-6) when nothing in the batch has a bin. AM-8: a bin is collected here whether or not
|
|
7306
|
+
// its target file exists YET — a manifest that declares one but ships nothing must BLOCK after a
|
|
7307
|
+
// real install, never silently vanish from the plan (which used to read as n/a, or even skip the
|
|
7308
|
+
// whole gate when it was the batch's only bin).
|
|
7309
|
+
const bins: { pkg: string; binName: string; relPath: string }[] = [];
|
|
7310
|
+
for (const pk of targets) {
|
|
7311
|
+
let manifest: { bin?: string | Record<string, string> } = {};
|
|
7312
|
+
try { manifest = JSON.parse(readFileSync(join(pk.dir, 'package.json'), 'utf-8')); } catch { /* no bin info available */ }
|
|
7313
|
+
if (typeof manifest.bin === 'string') {
|
|
7314
|
+
bins.push({ pkg: pk.name, binName: pk.name.split('/').pop() ?? pk.name, relPath: manifest.bin.replace(/^\.\//, '') });
|
|
7315
|
+
} else if (manifest.bin !== undefined && manifest.bin !== null && typeof manifest.bin === 'object') {
|
|
7316
|
+
for (const [name, relRaw] of Object.entries(manifest.bin)) {
|
|
7317
|
+
bins.push({ pkg: pk.name, binName: name, relPath: String(relRaw).replace(/^\.\//, '') });
|
|
7318
|
+
}
|
|
7319
|
+
}
|
|
7320
|
+
}
|
|
7321
|
+
|
|
7322
|
+
// AM-1/AM-5: the packed-install-smoke PREVIEW below runs on a DRY RUN only, against whatever is
|
|
7323
|
+
// CURRENTLY on disk (pre-bump) — it cannot be the "same bytes that ship" gate AM-1 requires,
|
|
7324
|
+
// because a dry run never bumps/builds/packs anything real to compare against. On a LIVE run the
|
|
7325
|
+
// real gate is `packedTransport` (wired at the `publishPackages` call below), which packs ONCE
|
|
7326
|
+
// post-bump and smokes exactly those tarballs — this preview is skipped entirely then, so its
|
|
7327
|
+
// digest is never confused with the one that actually ships.
|
|
7328
|
+
let packedInstallSmokePreviewFailed = false;
|
|
7329
|
+
if (dryRun) {
|
|
7330
|
+
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)');
|
|
7333
|
+
} else {
|
|
7334
|
+
const scratchRoot = packedInstallScratchRoot();
|
|
7335
|
+
const packDir = mkdtempSync(join(scratchRoot, 'dz-publish-pack-'));
|
|
7336
|
+
const installDir = mkdtempSync(join(scratchRoot, 'dz-publish-install-'));
|
|
7337
|
+
const runSmoke: ReleaseExecRunner =
|
|
7338
|
+
packedInstallRunner ??
|
|
7339
|
+
((cmd, o) => {
|
|
7340
|
+
try {
|
|
7341
|
+
const stdout = execSync(cmd, { cwd: o.cwd, stdio: 'pipe', encoding: 'utf-8', timeout: o.timeoutMs });
|
|
7342
|
+
return { exitCode: 0, stdout: stdout == null ? '' : String(stdout), stderr: '' };
|
|
7343
|
+
} catch (err) {
|
|
7344
|
+
const e = err as Error & { status?: number | null; signal?: string | null; killed?: boolean; stdout?: unknown; stderr?: unknown };
|
|
7345
|
+
const timedOut = (e.status === null || e.status === undefined) && (e.signal != null || e.killed === true);
|
|
7346
|
+
return {
|
|
7347
|
+
exitCode: typeof e.status === 'number' ? e.status : 1,
|
|
7348
|
+
stdout: e.stdout == null ? '' : String(e.stdout),
|
|
7349
|
+
stderr: e.stderr == null || String(e.stderr).trim() === '' ? formatPublishError(e) : String(e.stderr),
|
|
7350
|
+
timedOut,
|
|
7351
|
+
};
|
|
7352
|
+
}
|
|
7353
|
+
});
|
|
7354
|
+
const smokePlan = planPackedInstallSmoke({
|
|
7355
|
+
packages: targets.map((p) => ({ name: p.name, dir: p.dir, version: p.version })),
|
|
7356
|
+
bins,
|
|
7357
|
+
packDir,
|
|
7358
|
+
installDir,
|
|
7359
|
+
});
|
|
7360
|
+
const smokeExecutions: PackedInstallExecution[] = [];
|
|
7361
|
+
// Lead edit after the live dry-run (13.09 12:05): the preview packed the WORKING directory with
|
|
7362
|
+
// `workspace:^` specs still inside, so `npm install <tgz>` died with EUNSUPPORTEDPROTOCOL — the
|
|
7363
|
+
// 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 }> = [];
|
|
7366
|
+
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)}`); } }
|
|
7382
|
+
}
|
|
7383
|
+
const smokeVerdict = judgePackedInstallSmoke(smokePlan, smokeExecutions);
|
|
7384
|
+
try { rmSync(packDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
7385
|
+
try { rmSync(installDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
7386
|
+
|
|
7387
|
+
if (smokeVerdict.ok) {
|
|
7388
|
+
appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'preview: pack/install/--version all clean');
|
|
7389
|
+
write('dz publish: ✓ packed install smoke (preview)');
|
|
7390
|
+
} else {
|
|
7391
|
+
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}`);
|
|
7394
|
+
for (const b of smokeVerdict.bins.filter((b) => !b.ok)) write(` ✗ ${b.pkg} (${b.binName}): ${b.detail ?? '(no detail)'}`);
|
|
7395
|
+
packedInstallSmokePreviewFailed = true;
|
|
7396
|
+
}
|
|
7397
|
+
}
|
|
7398
|
+
}
|
|
7399
|
+
|
|
7400
|
+
if (siblingDriftFailed || packedInstallSmokePreviewFailed) {
|
|
7401
|
+
write(`dz publish: refusing to publish (${driftBlocked} sibling-drift violation(s)${packedInstallSmokePreviewFailed ? ', packed install smoke failed' : ''})`);
|
|
7402
|
+
return 1;
|
|
7403
|
+
}
|
|
7404
|
+
|
|
7082
7405
|
if (!dryRun) {
|
|
7083
7406
|
// Loud confirmation banner listing exactly what is about to be published.
|
|
7084
7407
|
const targets = discoverPackages(cwd).filter((p) =>
|
|
@@ -7171,12 +7494,77 @@ function cmdPublish(
|
|
|
7171
7494
|
// longer exist. Default to the same path `dz sign --init` writes, so the ordinary operator needs no
|
|
7172
7495
|
// new flag; `--sign-key` overrides it.
|
|
7173
7496
|
const signKey = (options.get('sign-key') ?? join(homedir(), '.dz', 'keys', 'dz.key')).trim();
|
|
7497
|
+
// AM-1: the packedTransport smoke closure and the actual `npm publish <tgz>` inside
|
|
7498
|
+
// `publishPackages` both read from THIS SAME directory — created once, cleaned up once, after
|
|
7499
|
+
// publishPackages returns (it needs the tarballs on disk through its own publish step).
|
|
7500
|
+
const packedTransportPackDestDir = mkdtempSync(join(packedInstallScratchRoot(), 'dz-publish-packed-'));
|
|
7174
7501
|
const publishReport = publishPackages(cwd, {
|
|
7175
7502
|
provenance,
|
|
7176
7503
|
dryRun,
|
|
7177
7504
|
filter,
|
|
7178
7505
|
bumpOnly,
|
|
7179
7506
|
claimGate: claimCheckOpt,
|
|
7507
|
+
exec: publishExecRunner,
|
|
7508
|
+
packedTransport: {
|
|
7509
|
+
packDestDir: packedTransportPackDestDir,
|
|
7510
|
+
// AM-1: judged ONCE, over every package's packed artifact — nothing in the batch publishes
|
|
7511
|
+
// until this returns ok:true. `bins` (AM-8-fixed: declared bins are collected whether or not
|
|
7512
|
+
// their target file exists yet) was already computed above from the same `targets` this
|
|
7513
|
+
// batch resolves to.
|
|
7514
|
+
smoke: (artifacts): { ok: boolean; reason?: string } => {
|
|
7515
|
+
for (const a of artifacts) write(`dz publish: tarball ${a.name}@${a.newVersion} sha256:${a.sha256}`);
|
|
7516
|
+
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)');
|
|
7519
|
+
return { ok: true };
|
|
7520
|
+
}
|
|
7521
|
+
const scratchRoot = packedInstallScratchRoot();
|
|
7522
|
+
const installDir = mkdtempSync(join(scratchRoot, 'dz-publish-install-'));
|
|
7523
|
+
const runSmoke: ReleaseExecRunner =
|
|
7524
|
+
packedInstallRunner ??
|
|
7525
|
+
((cmd, o) => {
|
|
7526
|
+
try {
|
|
7527
|
+
const stdout = execSync(cmd, { cwd: o.cwd, stdio: 'pipe', encoding: 'utf-8', timeout: o.timeoutMs });
|
|
7528
|
+
return { exitCode: 0, stdout: stdout == null ? '' : String(stdout), stderr: '' };
|
|
7529
|
+
} catch (err) {
|
|
7530
|
+
const e = err as Error & { status?: number | null; signal?: string | null; killed?: boolean; stdout?: unknown; stderr?: unknown };
|
|
7531
|
+
const timedOut = (e.status === null || e.status === undefined) && (e.signal != null || e.killed === true);
|
|
7532
|
+
return {
|
|
7533
|
+
exitCode: typeof e.status === 'number' ? e.status : 1,
|
|
7534
|
+
stdout: e.stdout == null ? '' : String(e.stdout),
|
|
7535
|
+
stderr: e.stderr == null || String(e.stderr).trim() === '' ? formatPublishError(e) : String(e.stderr),
|
|
7536
|
+
timedOut,
|
|
7537
|
+
};
|
|
7538
|
+
}
|
|
7539
|
+
});
|
|
7540
|
+
const smokePlan = planPackedInstallSmoke({
|
|
7541
|
+
// skipPack (AM-1): these tarballs are ALREADY packed (by publishPackages, above) — a
|
|
7542
|
+
// second, different pack here would smoke bytes other than the ones about to publish.
|
|
7543
|
+
packages: artifacts.map((a) => ({ name: a.name, dir: '(packed already — see skipPack)', version: a.newVersion })),
|
|
7544
|
+
bins,
|
|
7545
|
+
packDir: packedTransportPackDestDir,
|
|
7546
|
+
installDir,
|
|
7547
|
+
skipPack: true,
|
|
7548
|
+
});
|
|
7549
|
+
const smokeExecutions: PackedInstallExecution[] = [];
|
|
7550
|
+
for (const step of smokePlan.steps) {
|
|
7551
|
+
const r = runSmoke(step.cmd, { cwd: step.cwd, timeoutMs: step.timeoutMs });
|
|
7552
|
+
smokeExecutions.push({ stepId: step.id, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, ...(r.timedOut !== undefined ? { timedOut: r.timedOut } : {}) });
|
|
7553
|
+
}
|
|
7554
|
+
const verdict = judgePackedInstallSmoke(smokePlan, smokeExecutions);
|
|
7555
|
+
try { rmSync(installDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
7556
|
+
if (verdict.ok) {
|
|
7557
|
+
appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'pack/install/--version all clean (live, packedTransport)');
|
|
7558
|
+
write('dz publish: ✓ packed install smoke');
|
|
7559
|
+
return { ok: true };
|
|
7560
|
+
}
|
|
7561
|
+
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}`);
|
|
7564
|
+
for (const b of verdict.bins.filter((b) => !b.ok)) write(` ✗ ${b.pkg} (${b.binName}): ${b.detail ?? '(no detail)'}`);
|
|
7565
|
+
return { ok: false, reason: detail };
|
|
7566
|
+
},
|
|
7567
|
+
},
|
|
7180
7568
|
signKey: signKey === '' ? undefined : resolve(cwd, signKey),
|
|
7181
7569
|
verifyAfterSign: (packDir: string): { ok: boolean; trustRootPresent: boolean; pack?: string } => {
|
|
7182
7570
|
// Verify the OUTCOME against the trust root a CONSUMER would use — an existing key may be the
|
|
@@ -7250,6 +7638,7 @@ function cmdPublish(
|
|
|
7250
7638
|
}
|
|
7251
7639
|
},
|
|
7252
7640
|
});
|
|
7641
|
+
try { rmSync(packedTransportPackDestDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
7253
7642
|
|
|
7254
7643
|
const configMirror = mirrorCommandFromConfig(cwd);
|
|
7255
7644
|
const configuredCommand = (options.get('mirror-cmd') ?? configMirror.command ?? '').trim();
|
|
@@ -7340,6 +7729,9 @@ function cmdPublish(
|
|
|
7340
7729
|
? ` (confirmed by registry after ${pkg.registryProbes} probes)`
|
|
7341
7730
|
: '';
|
|
7342
7731
|
write(` ${icon} ${pkg.name.padEnd(35)} ${pkg.oldVersion} → ${pkg.newVersion} ${pkg.status}${receipt}${detail}`);
|
|
7732
|
+
// AM-1: the digest of the EXACT tarball bytes that were smoke-tested AND published — present
|
|
7733
|
+
// only for a packedTransport publish, so "the smoke tested what shipped" is checkable here too.
|
|
7734
|
+
if (pkg.status === 'published' && pkg.sha256 !== undefined) write(` sha256:${pkg.sha256}`);
|
|
7343
7735
|
if (pkg.status === 'error' && pkg.error) {
|
|
7344
7736
|
for (const line of pkg.error.split('\n')) write(` ${line}`);
|
|
7345
7737
|
}
|
|
@@ -7676,14 +8068,31 @@ function cmdRelease(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
7676
8068
|
factsList = selected;
|
|
7677
8069
|
}
|
|
7678
8070
|
|
|
8071
|
+
// FR-6 (feature publish-sibling-drift-gate): real tmp dirs for the packed-install smoke — only
|
|
8072
|
+
// when something in the set actually has a bin to boot (packing bin-less siblings proves
|
|
8073
|
+
// nothing this gate exists to catch). Planning stays pure (planReleaseGates never mkdtemps
|
|
8074
|
+
// itself); these are cleaned up on every exit path below, dry-run included.
|
|
8075
|
+
const packedInstallEligible = factsList.some((f) => f.bins.some((b) => b.exists));
|
|
8076
|
+
const releaseScratchRoot = packedInstallScratchRoot();
|
|
8077
|
+
const packedInstallDirs = packedInstallEligible
|
|
8078
|
+
? { packDir: mkdtempSync(join(releaseScratchRoot, 'dz-release-pack-')), installDir: mkdtempSync(join(releaseScratchRoot, 'dz-release-install-')) }
|
|
8079
|
+
: undefined;
|
|
8080
|
+
const cleanupPackedInstallDirs = (): void => {
|
|
8081
|
+
if (packedInstallDirs === undefined) return;
|
|
8082
|
+
try { rmSync(packedInstallDirs.packDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
8083
|
+
try { rmSync(packedInstallDirs.installDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
8084
|
+
};
|
|
8085
|
+
|
|
7679
8086
|
const plan = planReleaseGates(factsList, {
|
|
7680
8087
|
monorepoRoot: cwd,
|
|
7681
8088
|
pnpmLockPresent: existsSync(join(cwd, 'pnpm-lock.yaml')),
|
|
7682
8089
|
includeDevDeps: flags.has('audit-dev'),
|
|
8090
|
+
packedInstall: packedInstallDirs,
|
|
7683
8091
|
});
|
|
7684
8092
|
|
|
7685
8093
|
// --dry-run: print the full plan, execute NOTHING (deterministic, byte-testable preview).
|
|
7686
8094
|
if (flags.has('dry-run')) {
|
|
8095
|
+
cleanupPackedInstallDirs();
|
|
7687
8096
|
if (json) {
|
|
7688
8097
|
write(JSON.stringify({ dryRun: true, packages: plan.packages, steps: plan.steps, skips: plan.skips, warnings }, null, 2));
|
|
7689
8098
|
return 0;
|
|
@@ -7728,6 +8137,7 @@ function cmdRelease(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
7728
8137
|
if (smokeTmp !== undefined) {
|
|
7729
8138
|
try { rmSync(smokeTmp, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
7730
8139
|
}
|
|
8140
|
+
cleanupPackedInstallDirs();
|
|
7731
8141
|
|
|
7732
8142
|
const verdict = classifyGateExecutions(plan, executions);
|
|
7733
8143
|
|
|
@@ -10455,6 +10865,43 @@ function runGuardEvaluation(root: string, op: string, text: string | undefined,
|
|
|
10455
10865
|
return result;
|
|
10456
10866
|
}
|
|
10457
10867
|
|
|
10868
|
+
/**
|
|
10869
|
+
* Feature `publish-sibling-drift-gate` (FR-5/AM-6): both the sibling-drift and packed-install-smoke
|
|
10870
|
+
* gates write to the SAME append-only, hash-chained `.dz/guard-audit.jsonl` the declarative
|
|
10871
|
+
* `dz guard` rules use — visibility for `dz guard promote`/`dz compounding` never depends on
|
|
10872
|
+
* which mechanism produced the finding. `pass` records go through as an informational `note`
|
|
10873
|
+
* (never a violation, so they can never flip the row's own verdict) so a clean check is ALSO on
|
|
10874
|
+
* the record, not just a block or an override (AM-6: "аудит без записи = не аудит").
|
|
10875
|
+
*
|
|
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`).
|
|
10880
|
+
*/
|
|
10881
|
+
function appendPublishGateAudit(
|
|
10882
|
+
root: string,
|
|
10883
|
+
rule: 'sibling-drift' | 'packed-install-smoke',
|
|
10884
|
+
verdict: 'pass' | 'warn' | 'block',
|
|
10885
|
+
detail: string,
|
|
10886
|
+
overrideReason?: string,
|
|
10887
|
+
): boolean {
|
|
10888
|
+
try {
|
|
10889
|
+
const rec = auditRecord(
|
|
10890
|
+
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: [] },
|
|
10893
|
+
new Date().toISOString(),
|
|
10894
|
+
overrideReason !== undefined ? { reason: overrideReason } : undefined,
|
|
10895
|
+
);
|
|
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)
|
|
10902
|
+
}
|
|
10903
|
+
}
|
|
10904
|
+
|
|
10458
10905
|
function renderGuardObservation(observation: GuardObservation): string {
|
|
10459
10906
|
const tag = observation.status === 'unknown' ? 'note' : 'observe';
|
|
10460
10907
|
return ` [${tag}] ${observation.rule} ${observation.scope}: ${observation.detail} [${observation.status}]`;
|
|
@@ -16903,7 +17350,17 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
|
|
|
16903
17350
|
);
|
|
16904
17351
|
}
|
|
16905
17352
|
|
|
16906
|
-
|
|
17353
|
+
// writeSequence (qe-bridge-signoff-order): diagnostic sequencing metadata — a self-reported
|
|
17354
|
+
// process-local trace with monotonic stamps taken at each named event (start of the first record
|
|
17355
|
+
// write; after the report landed; just before the atomic update). It replaces a wall-clock
|
|
17356
|
+
// mtime comparison that was a race (the record is rewritten AFTER the report by design). It does
|
|
17357
|
+
// NOT prove write order or crash safety: those are proven by the failpoint test (R4-1) and the
|
|
17358
|
+
// report-failure test. Lead edit after Codex review 2026-09-13: honest step names.
|
|
17359
|
+
const seq: Array<{ step: 'signoff-write-started' | 'report-written' | 'record-update-prepared'; monotonicNs: string }> = [
|
|
17360
|
+
{ step: 'signoff-write-started', monotonicNs: String(process.hrtime.bigint()) },
|
|
17361
|
+
];
|
|
17362
|
+
|
|
17363
|
+
const recordText = (reportWritten: boolean, writeSequence: typeof seq): string => `${JSON.stringify(buildBridgeSignoffRecord(signoff, {
|
|
16907
17364
|
runId,
|
|
16908
17365
|
claudeBin: resolvedBin,
|
|
16909
17366
|
binOverride,
|
|
@@ -16912,12 +17369,13 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
|
|
|
16912
17369
|
rawStdoutFile,
|
|
16913
17370
|
promptSha256,
|
|
16914
17371
|
...(parsed.channels === undefined ? {} : { channels: parsed.channels }),
|
|
17372
|
+
writeSequence,
|
|
16915
17373
|
}), null, 2)}\n`;
|
|
16916
17374
|
|
|
16917
17375
|
let signoffPath: string;
|
|
16918
17376
|
try {
|
|
16919
17377
|
signoffPath = uniquePath(join(stateDir, `signoff-${runId}`), '.json');
|
|
16920
|
-
writeNewFileOrThrow(signoffPath, recordText(false));
|
|
17378
|
+
writeNewFileOrThrow(signoffPath, recordText(false, seq));
|
|
16921
17379
|
} catch (error) {
|
|
16922
17380
|
return failRun(
|
|
16923
17381
|
'audit-write-failed',
|
|
@@ -16935,6 +17393,10 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
|
|
|
16935
17393
|
}
|
|
16936
17394
|
|
|
16937
17395
|
if (reportError === null) {
|
|
17396
|
+
// the report is on disk: the sequence gains a step BEFORE the `reportWritten:true` record
|
|
17397
|
+
// write, not after — an observer reading the eventual writeSequence must see the report step
|
|
17398
|
+
// land before the record-update step that persists it.
|
|
17399
|
+
seq.push({ step: 'report-written', monotonicNs: String(process.hrtime.bigint()) });
|
|
16938
17400
|
// the ONLY moment `reportWritten:true` may appear: after the report is on disk
|
|
16939
17401
|
try {
|
|
16940
17402
|
// ATOMIC (R4-1): write a sibling temp file, then rename() over the original. On the same
|
|
@@ -16943,7 +17405,8 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
|
|
|
16943
17405
|
// which made the "a crash leaves a record that is true or pessimistic" claim untrue in the
|
|
16944
17406
|
// one case it was about.
|
|
16945
17407
|
const tmpPath = `${signoffPath}.tmp.${process.pid}`;
|
|
16946
|
-
|
|
17408
|
+
seq.push({ step: 'record-update-prepared', monotonicNs: String(process.hrtime.bigint()) });
|
|
17409
|
+
writeNewFileOrThrow(tmpPath, recordText(true, seq));
|
|
16947
17410
|
if (process.env[QE_BRIDGE_FAILPOINT_ENV] === 'hang-before-rename') {
|
|
16948
17411
|
// test-only: stop dead INSIDE the window, so a SIGKILL can prove the property
|
|
16949
17412
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 600_000);
|
|
@@ -19681,7 +20144,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
19681
20144
|
case 'auto-canonicalize':
|
|
19682
20145
|
return await cmdAutoCanonicalize(options, cwd, write);
|
|
19683
20146
|
case 'publish':
|
|
19684
|
-
return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner);
|
|
20147
|
+
return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner, io.publishSiblingDriftFetcher, io.publishPackedInstallRunner, io.publishExecRunner);
|
|
19685
20148
|
case 'release':
|
|
19686
20149
|
return cmdRelease(options, flags, cwd, write, io.releaseRunner);
|
|
19687
20150
|
case 'parity':
|