@dzhechkov/harness-core 0.8.32 → 0.8.33
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 +97 -37
- package/README.md +30 -0
- package/dist/apply-leg.d.ts +13 -1
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +123 -10
- package/dist/apply-leg.js.map +1 -1
- package/dist/embed-socket-path.d.ts +65 -0
- package/dist/embed-socket-path.d.ts.map +1 -0
- package/dist/embed-socket-path.js +100 -0
- package/dist/embed-socket-path.js.map +1 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +66 -3
- package/dist/operations.js.map +1 -1
- package/dist/packed-install-smoke.d.ts +108 -0
- package/dist/packed-install-smoke.d.ts.map +1 -0
- package/dist/packed-install-smoke.js +172 -0
- package/dist/packed-install-smoke.js.map +1 -0
- package/dist/publish-sibling-drift.d.ts +67 -0
- package/dist/publish-sibling-drift.d.ts.map +1 -0
- package/dist/publish-sibling-drift.js +262 -0
- package/dist/publish-sibling-drift.js.map +1 -0
- package/dist/publish.d.ts +44 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +243 -24
- package/dist/publish.js.map +1 -1
- package/dist/qe-bridge.d.ts +16 -0
- package/dist/qe-bridge.d.ts.map +1 -1
- package/dist/qe-bridge.js +1 -0
- package/dist/qe-bridge.js.map +1 -1
- package/dist/release.d.ts +19 -0
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +81 -2
- package/dist/release.js.map +1 -1
- package/dist/setup.d.ts +36 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +96 -2
- package/dist/setup.js.map +1 -1
- package/package.json +23 -23
- package/sbom.json +186 -36
- package/src/apply-leg.ts +123 -10
- package/src/embed-socket-path.ts +113 -0
- package/src/index.ts +35 -2
- package/src/operations.ts +67 -3
- package/src/packed-install-smoke.ts +273 -0
- package/src/publish-sibling-drift.ts +293 -0
- package/src/publish.ts +280 -25
- package/src/qe-bridge.ts +14 -0
- package/src/release.ts +103 -2
- package/src/setup.ts +108 -2
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Packed-install smoke — feature `publish-sibling-drift-gate`, ADR-001 (Decision 2).
|
|
3
|
+
*
|
|
4
|
+
* `dz release`'s existing smoke gate boots a package's bin straight from the WORKSPACE — its
|
|
5
|
+
* sibling `workspace:*` deps resolve via pnpm's workspace links, never through a real install.
|
|
6
|
+
* That makes the whole class of "published tarball missing an export" incidents invisible by
|
|
7
|
+
* construction (Alternative Б1, rejected). This module plans and judges the alternative
|
|
8
|
+
* (Б2, accepted): pack every package in the batch, `npm install` the resulting tarballs together
|
|
9
|
+
* into a CLEAN directory — siblings OUTSIDE the batch resolve from the registry, exactly like a
|
|
10
|
+
* fresh user's install — then boot every bin with `--version` and require exit 0 AND non-empty
|
|
11
|
+
* stdout (the "publisher output is not a receipt" lesson: a bin that boots but prints nothing has
|
|
12
|
+
* not proven it works).
|
|
13
|
+
*
|
|
14
|
+
* Pure by construction (NFR-2): `planPackedInstallSmoke` only builds command STRINGS from
|
|
15
|
+
* injected package/bin facts and paths — it never spawns anything. `judgePackedInstallSmoke`
|
|
16
|
+
* only classifies injected execution records. The CLI (`cmdPublish`, `cmdRelease`) is the single
|
|
17
|
+
* executor, sharing this same plan/judge pair so both doors apply the identical rule.
|
|
18
|
+
*
|
|
19
|
+
* @packageDocumentation
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
export type PackedInstallStepKind = 'pack' | 'install' | 'bin-exists' | 'bin-version';
|
|
25
|
+
|
|
26
|
+
/** One concrete step — data, not action (mirrors release.ts's GateStep idiom). */
|
|
27
|
+
export interface PackedInstallStep {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly kind: PackedInstallStepKind;
|
|
30
|
+
readonly cmd: string;
|
|
31
|
+
readonly cwd: string;
|
|
32
|
+
readonly timeoutMs: number;
|
|
33
|
+
/** Present for 'pack' and 'bin-version' steps. */
|
|
34
|
+
readonly pkg?: string;
|
|
35
|
+
/** Present for 'bin-version' steps only. */
|
|
36
|
+
readonly binName?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PackedInstallPackageSpec {
|
|
40
|
+
readonly name: string;
|
|
41
|
+
/** Absolute source directory to `npm pack`. */
|
|
42
|
+
readonly dir: string;
|
|
43
|
+
readonly version: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PackedInstallBinSpec {
|
|
47
|
+
readonly pkg: string;
|
|
48
|
+
readonly binName: string;
|
|
49
|
+
/** Path to the executable relative to the package's OWN directory (as it ships), e.g. "dist/bin.js". */
|
|
50
|
+
readonly relPath: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PlanPackedInstallSmokeOptions {
|
|
54
|
+
/** Every package to pack — the full batch, since a bin-less sibling can still be a dependency. */
|
|
55
|
+
readonly packages: readonly PackedInstallPackageSpec[];
|
|
56
|
+
/** Bins to boot after install (typically the subset of `packages` that declare one). */
|
|
57
|
+
readonly bins: readonly PackedInstallBinSpec[];
|
|
58
|
+
/** Directory `npm pack --pack-destination` writes tarballs into. */
|
|
59
|
+
readonly packDir: string;
|
|
60
|
+
/** Fresh, empty directory `npm install` runs in — outside-batch siblings resolve from the registry here. */
|
|
61
|
+
readonly installDir: string;
|
|
62
|
+
readonly packTimeoutMs?: number;
|
|
63
|
+
readonly installTimeoutMs?: number;
|
|
64
|
+
readonly versionTimeoutMs?: number;
|
|
65
|
+
/**
|
|
66
|
+
* AM-1 (feature publish-sibling-drift-gate): the caller (`publishPackages`'s `packedTransport`)
|
|
67
|
+
* already packed each artifact ONCE, post-bump — a SECOND, different `npm pack` here would smoke
|
|
68
|
+
* bytes other than the ones about to be published, reintroducing the exact defect this amendment
|
|
69
|
+
* closes. `true` skips planning any 'pack' step; `tarballs` is still populated with the SAME
|
|
70
|
+
* deterministic `packedTarballName(name, version)` path under `packDir` — the caller is
|
|
71
|
+
* responsible for having written the tarball there already (`packages[].dir` is unused in this
|
|
72
|
+
* mode and may be any string).
|
|
73
|
+
*/
|
|
74
|
+
readonly skipPack?: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface PackedInstallPlan {
|
|
78
|
+
readonly steps: readonly PackedInstallStep[];
|
|
79
|
+
/** Absolute tarball paths the install step references, in package order. */
|
|
80
|
+
readonly tarballs: readonly string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const DEFAULT_PACK_TIMEOUT_MS = 60_000;
|
|
84
|
+
const DEFAULT_INSTALL_TIMEOUT_MS = 180_000;
|
|
85
|
+
const DEFAULT_VERSION_TIMEOUT_MS = 30_000;
|
|
86
|
+
|
|
87
|
+
/** Mirror npm's own tarball naming: `@scope/name@1.2.3` -> `scope-name-1.2.3.tgz`. */
|
|
88
|
+
export function packedTarballName(name: string, version: string): string {
|
|
89
|
+
return `${name.replace(/^@/, '').replace(/\//g, '-')}-${version}.tgz`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function planPackedInstallSmoke(opts: PlanPackedInstallSmokeOptions): PackedInstallPlan {
|
|
93
|
+
const packTimeoutMs = opts.packTimeoutMs ?? DEFAULT_PACK_TIMEOUT_MS;
|
|
94
|
+
const installTimeoutMs = opts.installTimeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS;
|
|
95
|
+
const versionTimeoutMs = opts.versionTimeoutMs ?? DEFAULT_VERSION_TIMEOUT_MS;
|
|
96
|
+
|
|
97
|
+
const steps: PackedInstallStep[] = [];
|
|
98
|
+
const tarballs: string[] = [];
|
|
99
|
+
|
|
100
|
+
for (const pkg of opts.packages) {
|
|
101
|
+
const tgz = join(opts.packDir, packedTarballName(pkg.name, pkg.version));
|
|
102
|
+
tarballs.push(tgz);
|
|
103
|
+
if (opts.skipPack === true) continue; // AM-1: already packed by the caller — see skipPack's doc
|
|
104
|
+
steps.push({
|
|
105
|
+
id: `pack:${pkg.name}`,
|
|
106
|
+
kind: 'pack',
|
|
107
|
+
cmd: `npm pack ${JSON.stringify(pkg.dir)} --pack-destination ${JSON.stringify(opts.packDir)}`,
|
|
108
|
+
cwd: pkg.dir,
|
|
109
|
+
timeoutMs: packTimeoutMs,
|
|
110
|
+
pkg: pkg.name,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (tarballs.length > 0) {
|
|
115
|
+
steps.push({
|
|
116
|
+
id: 'install',
|
|
117
|
+
kind: 'install',
|
|
118
|
+
cmd: `npm install ${tarballs.map((t) => JSON.stringify(t)).join(' ')} --no-audit --no-fund`,
|
|
119
|
+
cwd: opts.installDir,
|
|
120
|
+
timeoutMs: installTimeoutMs,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const bin of opts.bins) {
|
|
125
|
+
const absBinPath = join(opts.installDir, 'node_modules', bin.pkg, bin.relPath);
|
|
126
|
+
// AM-8: a manifest can declare a `bin` whose target file does not exist (never built, moved,
|
|
127
|
+
// typo'd) — the OLD `cli.ts` bin-collection step silently DROPPED such a bin before this
|
|
128
|
+
// amendment, which read as "n/a: nothing to smoke" (or even skipped the whole gate when it
|
|
129
|
+
// was the batch's only bin). `test -f` is a dedicated, portable existence probe RUN AFTER THE
|
|
130
|
+
// REAL INSTALL — pass/fail here is judged into a specific, honest message
|
|
131
|
+
// ("declared bin missing after packed install") instead of being folded into whatever
|
|
132
|
+
// `node <bin> --version` happens to print for a missing file (a generic MODULE_NOT_FOUND).
|
|
133
|
+
steps.push({
|
|
134
|
+
id: `bin-exists:${bin.pkg}:${bin.binName}`,
|
|
135
|
+
kind: 'bin-exists',
|
|
136
|
+
cmd: `test -f ${JSON.stringify(absBinPath)}`,
|
|
137
|
+
cwd: opts.installDir,
|
|
138
|
+
timeoutMs: versionTimeoutMs,
|
|
139
|
+
pkg: bin.pkg,
|
|
140
|
+
binName: bin.binName,
|
|
141
|
+
});
|
|
142
|
+
steps.push({
|
|
143
|
+
id: `bin:${bin.pkg}:${bin.binName}`,
|
|
144
|
+
kind: 'bin-version',
|
|
145
|
+
cmd: `node ${JSON.stringify(absBinPath)} --version`,
|
|
146
|
+
cwd: opts.installDir,
|
|
147
|
+
timeoutMs: versionTimeoutMs,
|
|
148
|
+
pkg: bin.pkg,
|
|
149
|
+
binName: bin.binName,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { steps, tarballs };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface PackedInstallExecution {
|
|
157
|
+
readonly stepId: string;
|
|
158
|
+
readonly exitCode: number;
|
|
159
|
+
readonly stdout: string;
|
|
160
|
+
readonly stderr: string;
|
|
161
|
+
readonly timedOut?: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface PackedInstallBinVerdict {
|
|
165
|
+
readonly pkg: string;
|
|
166
|
+
readonly binName: string;
|
|
167
|
+
readonly ok: boolean;
|
|
168
|
+
readonly stdout: string;
|
|
169
|
+
/** First 3 non-empty lines of stderr (falling back to stdout), present only when !ok. */
|
|
170
|
+
readonly detail?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface PackedInstallVerdict {
|
|
174
|
+
readonly ok: boolean;
|
|
175
|
+
readonly packOk: boolean;
|
|
176
|
+
readonly installOk: boolean;
|
|
177
|
+
/** First failure's detail, from whichever of pack/install failed first. */
|
|
178
|
+
readonly failureDetail?: string;
|
|
179
|
+
readonly bins: readonly PackedInstallBinVerdict[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* First 3 lines (FR-3), EXCEPT when they are pure source context. MEASURED 2026-09-13 reproducing
|
|
184
|
+
* the exact incident this gate targets — a bin whose `import` names a missing export — Node
|
|
185
|
+
* prints the location, the offending source line and a caret BEFORE the actual
|
|
186
|
+
* `SyntaxError: … does not provide an export named …` message (identically for a plain uncaught
|
|
187
|
+
* `throw`: file:line / code / `^` / blank / `Error: …`). A literal first-3-lines slice therefore
|
|
188
|
+
* shows three lines of code and caret marks and never the reason — useless for the incident it
|
|
189
|
+
* exists to surface. When stderr contains a line that LOOKS like an error header
|
|
190
|
+
* (`SomethingError: …` or bare `Error: …`), the snippet starts there instead.
|
|
191
|
+
*/
|
|
192
|
+
function firstLines(text: string, n: number): string {
|
|
193
|
+
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
|
|
194
|
+
if (lines.length === 0) return '(no output)';
|
|
195
|
+
const errorHeaderAt = lines.findIndex((l) => /^[A-Za-z][A-Za-z0-9_]*Error:|^Error\b/.test(l));
|
|
196
|
+
const start = errorHeaderAt >= 0 ? errorHeaderAt : 0;
|
|
197
|
+
return lines.slice(start, start + n).join(' | ');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const NO_EXECUTION_RECORD = '(no execution record — under-executed plan)';
|
|
201
|
+
|
|
202
|
+
function stepOk(exec: PackedInstallExecution | undefined): boolean {
|
|
203
|
+
return exec !== undefined && exec.timedOut !== true && exec.exitCode === 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function stepDetail(exec: PackedInstallExecution | undefined): string {
|
|
207
|
+
if (exec === undefined) return NO_EXECUTION_RECORD;
|
|
208
|
+
if (exec.timedOut === true) return 'timed out';
|
|
209
|
+
return firstLines(exec.stderr || exec.stdout, 3);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Classify a plan's executions. `plan` may be the `{ steps }` half of {@link PackedInstallPlan}.
|
|
214
|
+
* A missing execution for a planned step is a FAILURE (an under-executed plan cannot pass) —
|
|
215
|
+
* never treated as "nothing to judge, so it passed".
|
|
216
|
+
*/
|
|
217
|
+
export function judgePackedInstallSmoke(
|
|
218
|
+
plan: { readonly steps: readonly PackedInstallStep[] },
|
|
219
|
+
executions: readonly PackedInstallExecution[],
|
|
220
|
+
): PackedInstallVerdict {
|
|
221
|
+
const byId = new Map(executions.map((e) => [e.stepId, e]));
|
|
222
|
+
|
|
223
|
+
let packOk = true;
|
|
224
|
+
let failureDetail: string | undefined;
|
|
225
|
+
for (const step of plan.steps.filter((s) => s.kind === 'pack')) {
|
|
226
|
+
const exec = byId.get(step.id);
|
|
227
|
+
if (!stepOk(exec)) {
|
|
228
|
+
packOk = false;
|
|
229
|
+
failureDetail ??= stepDetail(exec);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let installOk = true;
|
|
234
|
+
const installStep = plan.steps.find((s) => s.kind === 'install');
|
|
235
|
+
if (installStep !== undefined) {
|
|
236
|
+
const exec = byId.get(installStep.id);
|
|
237
|
+
if (!stepOk(exec)) {
|
|
238
|
+
installOk = false;
|
|
239
|
+
failureDetail ??= stepDetail(exec);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const bins: PackedInstallBinVerdict[] = plan.steps
|
|
244
|
+
.filter((s) => s.kind === 'bin-version')
|
|
245
|
+
.map((step) => {
|
|
246
|
+
const exec = byId.get(step.id);
|
|
247
|
+
if (!packOk || !installOk) {
|
|
248
|
+
// Pack/install already failed for the whole batch — the generic pack/install detail is
|
|
249
|
+
// more informative than a bin-specific message about a step that never had a chance to run.
|
|
250
|
+
return { pkg: step.pkg!, binName: step.binName!, ok: false, stdout: '', detail: stepDetail(exec) };
|
|
251
|
+
}
|
|
252
|
+
// AM-8: a declared bin missing from the REAL post-install tree is its own failure class —
|
|
253
|
+
// checked and reported BEFORE the generic stdout rule below, whose message ("empty stdout")
|
|
254
|
+
// would otherwise misdescribe a file that was never there to boot at all.
|
|
255
|
+
const existsStep = plan.steps.find((s) => s.kind === 'bin-exists' && s.pkg === step.pkg && s.binName === step.binName);
|
|
256
|
+
if (existsStep !== undefined && !stepOk(byId.get(existsStep.id))) {
|
|
257
|
+
return { pkg: step.pkg!, binName: step.binName!, ok: false, stdout: '', detail: 'declared bin missing after packed install' };
|
|
258
|
+
}
|
|
259
|
+
// FR-3 / lesson "publisher output is not a receipt": exit 0 alone is not enough — the
|
|
260
|
+
// version output must be non-empty, or a bin that silently no-ops would read as healthy.
|
|
261
|
+
const ok = stepOk(exec) && (exec?.stdout ?? '').trim() !== '';
|
|
262
|
+
return {
|
|
263
|
+
pkg: step.pkg!,
|
|
264
|
+
binName: step.binName!,
|
|
265
|
+
ok,
|
|
266
|
+
stdout: exec?.stdout ?? '',
|
|
267
|
+
...(ok ? {} : { detail: exec !== undefined && stepOk(exec) ? '(exit 0 but empty stdout)' : stepDetail(exec) }),
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const ok = packOk && installOk && bins.every((b) => b.ok);
|
|
272
|
+
return { ok, packOk, installOk, ...(failureDetail !== undefined ? { failureDetail } : {}), bins };
|
|
273
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sibling-drift gate — feature `publish-sibling-drift-gate`, ADR-001 (Decision 1).
|
|
3
|
+
*
|
|
4
|
+
* `rewriteWorkspaceSpecs` (publish.ts) pins a sibling `workspace:^`/`workspace:~`/`workspace:*`
|
|
5
|
+
* dependency to the EXACT version currently on disk. That version may be published on the
|
|
6
|
+
* registry carrying an OLDER build than the workspace — the sibling changed without a version
|
|
7
|
+
* bump. The pinned range then resolves at install time to a package that does not match the
|
|
8
|
+
* workspace's current behavior, and a fresh `npm install` reproduces whatever regressed.
|
|
9
|
+
*
|
|
10
|
+
* Detection (ADR-001, Decision 1, alternative А3 — accepted): hash every file under the
|
|
11
|
+
* published tarball's `dist/**` plus its `package.json` (with `version`/`gitHead`/`_*` fields
|
|
12
|
+
* stripped, since those legitimately differ between the registry copy and the workspace copy),
|
|
13
|
+
* and compare against the same hash of the workspace copy. Any difference is drift. A published
|
|
14
|
+
* `dist/index.js` missing an export the workspace's `dist/index.js` declares is surfaced as a
|
|
15
|
+
* SECOND, more readable signal (`missingExports`) — the exact shape of the 2026-09-13 incident
|
|
16
|
+
* ("does not provide an export named …") — but the hash comparison is the load-bearing check:
|
|
17
|
+
* it also catches behavior changes that keep every export name intact.
|
|
18
|
+
*
|
|
19
|
+
* Network access is NOT this module's concern (NFR-2: pure, no network, fixture-testable):
|
|
20
|
+
* `fetchPublished` is injected. The CLI implementation packs the sibling from the registry via
|
|
21
|
+
* `npm pack <name>@<version>` into a temp dir; tests inject a local directory. A fetch that
|
|
22
|
+
* returns `null` (offline, 404, timeout) is reported as `'unavailable'` — never silently treated
|
|
23
|
+
* as `'same'` (the "a gate that infers a pass from silence breaks on the next failure path"
|
|
24
|
+
* lesson): the caller decides whether `'unavailable'` blocks or is overridden.
|
|
25
|
+
*
|
|
26
|
+
* @packageDocumentation
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
30
|
+
import { createHash } from 'node:crypto';
|
|
31
|
+
import { join, relative } from 'node:path';
|
|
32
|
+
|
|
33
|
+
export type SiblingDriftStatus = 'same' | 'drift' | 'unavailable';
|
|
34
|
+
|
|
35
|
+
export interface SiblingDriftResult {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly version: string;
|
|
38
|
+
readonly status: SiblingDriftStatus;
|
|
39
|
+
/** Relative paths (dist/** or package.json) whose hash differs, or is present on only one side. */
|
|
40
|
+
readonly changedFiles: readonly string[];
|
|
41
|
+
/** Export names the workspace's dist/index.js declares that the published one lacks (А2, secondary signal). */
|
|
42
|
+
readonly missingExports: readonly string[];
|
|
43
|
+
/** Present only when status === 'unavailable'. */
|
|
44
|
+
readonly reason?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface FetchedPublished {
|
|
48
|
+
/** Directory holding the extracted published tarball (contains dist/, package.json). */
|
|
49
|
+
readonly dir: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Fetch the published build of `name@version`. `null` = unavailable (network/404/timeout). */
|
|
53
|
+
export type FetchPublished = (name: string, version: string) => FetchedPublished | null;
|
|
54
|
+
|
|
55
|
+
export interface DetectSiblingDriftOptions {
|
|
56
|
+
readonly dependencies: Record<string, string> | undefined;
|
|
57
|
+
/** pnpm rewrites `workspace:` in peerDependencies too (mirrors findUnpublishedWorkspaceFloors). */
|
|
58
|
+
readonly peerDependencies?: Record<string, string> | undefined;
|
|
59
|
+
/** AM-3: ships and pins exactly like `dependencies` — checked the same way. */
|
|
60
|
+
readonly optionalDependencies?: Record<string, string> | undefined;
|
|
61
|
+
/** name -> version on DISK, for every package in the workspace. */
|
|
62
|
+
readonly workspaceVersions: ReadonlyMap<string, string>;
|
|
63
|
+
/** name -> absolute package dir on disk, for every package in the workspace. */
|
|
64
|
+
readonly workspaceDirs: ReadonlyMap<string, string>;
|
|
65
|
+
/** Names being published in THIS batch — they publish fresh, so drift cannot be measured against them. */
|
|
66
|
+
readonly batch: ReadonlySet<string>;
|
|
67
|
+
readonly fetchPublished: FetchPublished;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function listFilesRecursive(root: string, dir: string): string[] {
|
|
71
|
+
if (!existsSync(dir)) return [];
|
|
72
|
+
const out: string[] = [];
|
|
73
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
74
|
+
const abs = join(dir, entry);
|
|
75
|
+
const st = statSync(abs);
|
|
76
|
+
if (st.isDirectory()) out.push(...listFilesRecursive(root, abs));
|
|
77
|
+
else out.push(relative(root, abs));
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sha256(data: string | Buffer): string {
|
|
83
|
+
return createHash('sha256').update(data).digest('hex');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* package.json PARSED and validated. `null` (never `undefined`) means "this side cannot be built
|
|
88
|
+
* at all" — AM-3: a missing or unparseable manifest on EITHER side must surface as `unavailable`,
|
|
89
|
+
* never as an empty/omitted comparison field that a hash-mismatch loop could silently read as
|
|
90
|
+
* "nothing differs here".
|
|
91
|
+
*/
|
|
92
|
+
function readManifest(dir: string): Record<string, unknown> | null {
|
|
93
|
+
const p = join(dir, 'package.json');
|
|
94
|
+
if (!existsSync(p)) return null;
|
|
95
|
+
try {
|
|
96
|
+
const raw = JSON.parse(readFileSync(p, 'utf-8'));
|
|
97
|
+
return raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** package.json normalized for comparison: strip fields that legitimately differ (version, gitHead, npm-internal `_*`). */
|
|
104
|
+
function normalizedPackageJsonText(raw: Record<string, unknown>): string {
|
|
105
|
+
// Lead edit after the live dry-run on the hub (2026-09-13 10:40): the packer strips
|
|
106
|
+
// `scripts.prepublishOnly`, drops devDependencies/publishConfig and rewrites `workspace:` specs to
|
|
107
|
+
// pinned versions — every freshly published sibling read as "1 file drifted". Compare only what
|
|
108
|
+
// shapes the SHIPPED behavior: entry points, bins, files, engines, and dependency NAMES (values
|
|
109
|
+
// are the workspace-floor preflight's business, not this gate's).
|
|
110
|
+
//
|
|
111
|
+
// AM-4: `imports`/`browser`/`sideEffects`/`man` added — each one changes what a consumer actually
|
|
112
|
+
// resolves or ships, exactly like `main`/`exports`/`bin` already did; omitting them was a real gap
|
|
113
|
+
// the round-1 review named (finding 4), not a stylistic nicety.
|
|
114
|
+
const SHIPPING_FIELDS = [
|
|
115
|
+
'name', 'type', 'main', 'module', 'types', 'exports', 'imports', 'browser', 'sideEffects', 'man',
|
|
116
|
+
'bin', 'files', 'engines', 'os', 'cpu',
|
|
117
|
+
];
|
|
118
|
+
const DEP_TABLES = ['dependencies', 'peerDependencies', 'optionalDependencies'];
|
|
119
|
+
const kept: Record<string, unknown> = {};
|
|
120
|
+
for (const key of SHIPPING_FIELDS) if (key in raw) kept[key] = raw[key];
|
|
121
|
+
for (const key of DEP_TABLES) {
|
|
122
|
+
const table = raw[key];
|
|
123
|
+
if (table !== null && typeof table === 'object') kept[key] = Object.keys(table as Record<string, unknown>).sort();
|
|
124
|
+
}
|
|
125
|
+
return JSON.stringify(kept);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Every relative path (from `dir`) that a `bin` field in a parsed manifest resolves to. */
|
|
129
|
+
function binPaths(raw: Record<string, unknown>): string[] {
|
|
130
|
+
const bin = raw['bin'];
|
|
131
|
+
if (typeof bin === 'string') return [bin.replace(/^\.\//, '')];
|
|
132
|
+
if (bin !== null && typeof bin === 'object' && !Array.isArray(bin)) {
|
|
133
|
+
return Object.values(bin as Record<string, unknown>)
|
|
134
|
+
.filter((v): v is string => typeof v === 'string')
|
|
135
|
+
.map((v) => v.replace(/^\.\//, ''));
|
|
136
|
+
}
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* AM-4: the round-1 gate hashed only `dist/**` — a changed bin script, template, or other
|
|
142
|
+
* top-level asset that ships (declared in `package.json#files`, or the `bin` target itself) was
|
|
143
|
+
* invisible to the drift check even though npm ships it byte-for-byte. This is a documented,
|
|
144
|
+
* honest APPROXIMATION of "the whole tarball inventory" (the literal ADR wording), not a full
|
|
145
|
+
* re-implementation of npm's pack-time file-inclusion rules (`.npmignore`, default excludes,
|
|
146
|
+
* nested `.gitignore`): it walks `dist/**` (unconditional — the common case) plus every path
|
|
147
|
+
* named in `files` (directories walked recursively, files hashed directly) plus every resolved
|
|
148
|
+
* `bin` target, deduplicated. A package with no `files` field declared keeps exactly the
|
|
149
|
+
* pre-amendment `dist/**`-only scope, named here rather than silently pretended-away.
|
|
150
|
+
*/
|
|
151
|
+
function shippedInventoryDirs(dir: string, raw: Record<string, unknown>): string[] {
|
|
152
|
+
const rels = new Set<string>(['dist']);
|
|
153
|
+
const files = raw['files'];
|
|
154
|
+
if (Array.isArray(files)) {
|
|
155
|
+
for (const entry of files) {
|
|
156
|
+
if (typeof entry === 'string' && entry.trim() !== '') rels.add(entry.replace(/^\.\//, '').replace(/\/+$/, ''));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const bin of binPaths(raw)) rels.add(bin);
|
|
160
|
+
return [...rels].filter((rel) => existsSync(join(dir, rel)));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Hash the shipped inventory (AM-4) plus the normalized package.json, keyed by a stable relative path. */
|
|
164
|
+
function hashTree(dir: string, manifest: Record<string, unknown>): Map<string, string> {
|
|
165
|
+
const map = new Map<string, string>();
|
|
166
|
+
for (const rel of shippedInventoryDirs(dir, manifest)) {
|
|
167
|
+
const abs = join(dir, rel);
|
|
168
|
+
if (statSync(abs).isDirectory()) {
|
|
169
|
+
for (const sub of listFilesRecursive(abs, abs)) map.set(join(rel, sub), sha256(readFileSync(join(abs, sub))));
|
|
170
|
+
} else {
|
|
171
|
+
map.set(rel, sha256(readFileSync(abs)));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
map.set('package.json', sha256(normalizedPackageJsonText(manifest)));
|
|
175
|
+
return map;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function extractExportNames(source: string): Set<string> {
|
|
179
|
+
const names = new Set<string>();
|
|
180
|
+
for (const m of source.matchAll(/export\s+(?:const|function|class|async\s+function)\s+([A-Za-z0-9_$]+)/g)) {
|
|
181
|
+
names.add(m[1]!);
|
|
182
|
+
}
|
|
183
|
+
for (const m of source.matchAll(/export\s*\{([^}]*)\}/g)) {
|
|
184
|
+
for (const part of m[1]!.split(',')) {
|
|
185
|
+
const name = part.trim().split(/\s+as\s+/).pop()?.trim();
|
|
186
|
+
if (name) names.add(name);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return names;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** А2 (ADR-001, rejected as the sole signal, kept as a readable second signal). */
|
|
193
|
+
function missingExportNames(publishedDir: string, workspaceDir: string): string[] {
|
|
194
|
+
const pubIndex = join(publishedDir, 'dist', 'index.js');
|
|
195
|
+
const wsIndex = join(workspaceDir, 'dist', 'index.js');
|
|
196
|
+
if (!existsSync(pubIndex) || !existsSync(wsIndex)) return [];
|
|
197
|
+
const pubExports = extractExportNames(readFileSync(pubIndex, 'utf-8'));
|
|
198
|
+
const wsExports = extractExportNames(readFileSync(wsIndex, 'utf-8'));
|
|
199
|
+
return [...wsExports].filter((n) => !pubExports.has(n)).sort();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* For every `workspace:`-declared dependency of a package that is NOT part of `batch` (i.e. will
|
|
204
|
+
* be pinned to whatever is already on the registry, not published fresh in this run), compare the
|
|
205
|
+
* build that will be pinned against the workspace copy. Pure: all IO (fetch, fs) is either
|
|
206
|
+
* injected or scoped to reading local dist/package.json files — no network call is made here.
|
|
207
|
+
*/
|
|
208
|
+
export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDriftResult[] {
|
|
209
|
+
const results: SiblingDriftResult[] = [];
|
|
210
|
+
const seen = new Set<string>();
|
|
211
|
+
// AM-3: `optionalDependencies` ships and pins EXACTLY like `dependencies`/`peerDependencies` —
|
|
212
|
+
// checking only the first two let a stale optional sibling through untouched (round-1 finding 3).
|
|
213
|
+
const entries = [
|
|
214
|
+
...Object.entries(opts.dependencies ?? {}),
|
|
215
|
+
...Object.entries(opts.peerDependencies ?? {}),
|
|
216
|
+
...Object.entries(opts.optionalDependencies ?? {}),
|
|
217
|
+
];
|
|
218
|
+
for (const [dep, spec] of entries) {
|
|
219
|
+
if (!String(spec).startsWith('workspace:')) continue;
|
|
220
|
+
if (seen.has(dep)) continue;
|
|
221
|
+
seen.add(dep);
|
|
222
|
+
if (opts.batch.has(dep)) continue; // publishes fresh in this batch — nothing stale to drift from
|
|
223
|
+
const version = opts.workspaceVersions.get(dep);
|
|
224
|
+
const workspaceDir = opts.workspaceDirs.get(dep);
|
|
225
|
+
// AM-3: a `workspace:`-spec'd dependency this caller does not recognize used to be silently
|
|
226
|
+
// SKIPPED — an input this gate cannot build is a HARD gate that cannot say "same", never a
|
|
227
|
+
// quiet pass-through (round-1 finding 3: pnpm would die packing it anyway; die here, named).
|
|
228
|
+
if (version === undefined || workspaceDir === undefined) {
|
|
229
|
+
results.push({
|
|
230
|
+
name: dep,
|
|
231
|
+
version: version ?? '(not in workspace)',
|
|
232
|
+
status: 'unavailable',
|
|
233
|
+
changedFiles: [],
|
|
234
|
+
missingExports: [],
|
|
235
|
+
reason: `${dep} is declared workspace:-protocol but is not a known workspace package`,
|
|
236
|
+
});
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const fetched = opts.fetchPublished(dep, version);
|
|
241
|
+
if (fetched === null) {
|
|
242
|
+
results.push({
|
|
243
|
+
name: dep,
|
|
244
|
+
version,
|
|
245
|
+
status: 'unavailable',
|
|
246
|
+
changedFiles: [],
|
|
247
|
+
missingExports: [],
|
|
248
|
+
reason: `could not fetch ${dep}@${version} from the registry (network unavailable or the version was not found)`,
|
|
249
|
+
});
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// AM-3: a missing/unparseable package.json on EITHER side must not silently drop out of the
|
|
254
|
+
// comparison (the old `hashTree` simply omitted the key, which — with an empty/matching
|
|
255
|
+
// `dist/**` on both sides — could report `same` about an input that was never actually read).
|
|
256
|
+
const publishedManifest = readManifest(fetched.dir);
|
|
257
|
+
const workspaceManifest = readManifest(workspaceDir);
|
|
258
|
+
if (publishedManifest === null || workspaceManifest === null) {
|
|
259
|
+
const side = publishedManifest === null ? 'the published tarball' : 'the workspace copy';
|
|
260
|
+
results.push({
|
|
261
|
+
name: dep,
|
|
262
|
+
version,
|
|
263
|
+
status: 'unavailable',
|
|
264
|
+
changedFiles: [],
|
|
265
|
+
missingExports: [],
|
|
266
|
+
reason: `${dep}@${version}: package.json in ${side} is missing or not valid JSON — cannot compare`,
|
|
267
|
+
});
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const publishedHashes = hashTree(fetched.dir, publishedManifest);
|
|
272
|
+
const workspaceHashes = hashTree(workspaceDir, workspaceManifest);
|
|
273
|
+
const allKeys = new Set<string>([...publishedHashes.keys(), ...workspaceHashes.keys()]);
|
|
274
|
+
const changed: string[] = [];
|
|
275
|
+
for (const key of allKeys) {
|
|
276
|
+
if (publishedHashes.get(key) !== workspaceHashes.get(key)) changed.push(key);
|
|
277
|
+
}
|
|
278
|
+
changed.sort();
|
|
279
|
+
|
|
280
|
+
if (changed.length === 0) {
|
|
281
|
+
results.push({ name: dep, version, status: 'same', changedFiles: [], missingExports: [] });
|
|
282
|
+
} else {
|
|
283
|
+
results.push({
|
|
284
|
+
name: dep,
|
|
285
|
+
version,
|
|
286
|
+
status: 'drift',
|
|
287
|
+
changedFiles: changed,
|
|
288
|
+
missingExports: missingExportNames(fetched.dir, workspaceDir),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return results;
|
|
293
|
+
}
|