@dzhechkov/harness-core 0.8.32 → 0.8.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.dz-manifest.json +107 -47
  2. package/README.md +108 -0
  3. package/dist/agentdb-index.d.ts +17 -6
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +61 -17
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/apply-leg.d.ts +68 -5
  8. package/dist/apply-leg.d.ts.map +1 -1
  9. package/dist/apply-leg.js +466 -37
  10. package/dist/apply-leg.js.map +1 -1
  11. package/dist/embed-socket-path.d.ts +65 -0
  12. package/dist/embed-socket-path.d.ts.map +1 -0
  13. package/dist/embed-socket-path.js +100 -0
  14. package/dist/embed-socket-path.js.map +1 -0
  15. package/dist/index.d.ts +11 -4
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +9 -3
  18. package/dist/index.js.map +1 -1
  19. package/dist/operations.d.ts.map +1 -1
  20. package/dist/operations.js +105 -3
  21. package/dist/operations.js.map +1 -1
  22. package/dist/packed-install-smoke.d.ts +108 -0
  23. package/dist/packed-install-smoke.d.ts.map +1 -0
  24. package/dist/packed-install-smoke.js +172 -0
  25. package/dist/packed-install-smoke.js.map +1 -0
  26. package/dist/publish-sibling-drift.d.ts +139 -0
  27. package/dist/publish-sibling-drift.d.ts.map +1 -0
  28. package/dist/publish-sibling-drift.js +408 -0
  29. package/dist/publish-sibling-drift.js.map +1 -0
  30. package/dist/publish.d.ts +44 -0
  31. package/dist/publish.d.ts.map +1 -1
  32. package/dist/publish.js +243 -24
  33. package/dist/publish.js.map +1 -1
  34. package/dist/qe-bridge.d.ts +16 -0
  35. package/dist/qe-bridge.d.ts.map +1 -1
  36. package/dist/qe-bridge.js +1 -0
  37. package/dist/qe-bridge.js.map +1 -1
  38. package/dist/release.d.ts +91 -0
  39. package/dist/release.d.ts.map +1 -1
  40. package/dist/release.js +317 -21
  41. package/dist/release.js.map +1 -1
  42. package/dist/setup.d.ts +36 -0
  43. package/dist/setup.d.ts.map +1 -1
  44. package/dist/setup.js +96 -2
  45. package/dist/setup.js.map +1 -1
  46. package/dist/vector-tier.d.ts +27 -2
  47. package/dist/vector-tier.d.ts.map +1 -1
  48. package/dist/vector-tier.js +112 -3
  49. package/dist/vector-tier.js.map +1 -1
  50. package/package.json +23 -23
  51. package/sbom.json +196 -46
  52. package/src/agentdb-index.ts +65 -17
  53. package/src/apply-leg.ts +461 -37
  54. package/src/embed-socket-path.ts +113 -0
  55. package/src/index.ts +44 -3
  56. package/src/operations.ts +104 -3
  57. package/src/packed-install-smoke.ts +273 -0
  58. package/src/publish-sibling-drift.ts +498 -0
  59. package/src/publish.ts +280 -25
  60. package/src/qe-bridge.ts +14 -0
  61. package/src/release.ts +366 -19
  62. package/src/setup.ts +108 -2
  63. package/src/vector-tier.ts +147 -5
@@ -0,0 +1,498 @@
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, isAbsolute } from 'node:path';
32
+
33
+ export type SiblingDriftStatus = 'same' | 'drift' | 'unavailable';
34
+
35
+ /**
36
+ * AM-6 (feature publish-gate-audit-durable): which mechanism produced BOTH sides' file inventory
37
+ * for this comparison — named on every result, never left implicit. `'npm-pack'`: the caller
38
+ * injected {@link DetectSiblingDriftOptions.localInventory} (the CLI's `npm pack --dry-run --json`
39
+ * via {@link parseNpmPackInventory}); the workspace side is exactly what npm will ship, and the
40
+ * published side is hashed by a FULL recursive walk of the already-unpacked tarball (AM-1 — the
41
+ * two sides must be symmetric: "every file npm put there" on one side, "every file npm will put
42
+ * there" on the other). `'readdir-approximation'`: no provider was injected — BOTH sides fall back
43
+ * to the pre-existing `dist`/`files`/`bin` walk ({@link shippedInventoryDirs}), which stays
44
+ * symmetric by construction (same function, same rules, both sides) but can miss a file
45
+ * `.npmignore` excludes or include one npm would never ship.
46
+ */
47
+ export type InventorySource = 'npm-pack' | 'pnpm-pack' | 'readdir-approximation';
48
+
49
+ export interface SiblingDriftResult {
50
+ readonly name: string;
51
+ readonly version: string;
52
+ readonly status: SiblingDriftStatus;
53
+ /** Relative paths (dist/** or package.json) whose hash differs, or is present on only one side. */
54
+ readonly changedFiles: readonly string[];
55
+ /** Export names the workspace's dist/index.js declares that the published one lacks (А2, secondary signal). */
56
+ readonly missingExports: readonly string[];
57
+ /** Present only when status === 'unavailable'. */
58
+ readonly reason?: string;
59
+ /**
60
+ * AM-6: named per-result (not merely per-call) because `detectSiblingDrift` short-circuits to
61
+ * `'unavailable'` before ever reaching the hashing step for some entries — those still carry the
62
+ * source that WOULD have been used, so a reader never has to guess.
63
+ */
64
+ readonly inventorySource: InventorySource;
65
+ }
66
+
67
+ export interface FetchedPublished {
68
+ /** Directory holding the extracted published tarball (contains dist/, package.json). */
69
+ readonly dir: string;
70
+ }
71
+
72
+ /** Fetch the published build of `name@version`. `null` = unavailable (network/404/timeout). */
73
+ export type FetchPublished = (name: string, version: string) => FetchedPublished | null;
74
+
75
+ export interface DetectSiblingDriftOptions {
76
+ readonly dependencies: Record<string, string> | undefined;
77
+ /** pnpm rewrites `workspace:` in peerDependencies too (mirrors findUnpublishedWorkspaceFloors). */
78
+ readonly peerDependencies?: Record<string, string> | undefined;
79
+ /** AM-3: ships and pins exactly like `dependencies` — checked the same way. */
80
+ readonly optionalDependencies?: Record<string, string> | undefined;
81
+ /** name -> version on DISK, for every package in the workspace. */
82
+ readonly workspaceVersions: ReadonlyMap<string, string>;
83
+ /** name -> absolute package dir on disk, for every package in the workspace. */
84
+ readonly workspaceDirs: ReadonlyMap<string, string>;
85
+ /** Names being published in THIS batch — they publish fresh, so drift cannot be measured against them. */
86
+ readonly batch: ReadonlySet<string>;
87
+ readonly fetchPublished: FetchPublished;
88
+ /**
89
+ * FR-3 (feature publish-gate-audit-durable): the LOCAL (workspace) package's shipped-file
90
+ * inventory, asked from npm instead of approximated by walking `dist`/`files`/`bin` by hand —
91
+ * `.npmignore` and nested ignore rules make the hand-rolled walk wrong in both directions (a file
92
+ * npm will never ship can still be read off disk, producing a false drift). No production default
93
+ * lives in THIS module — core stays pure (never spawns `npm`, per the core-boundary import
94
+ * ratchet). The CLI runs `npm pack --dry-run --json` and hands the stdout to
95
+ * {@link parseNpmPackInventory}, then passes the resulting closure here; a caller that injects
96
+ * nothing (`undefined`) makes `detectSiblingDrift` fall back to the named
97
+ * `'readdir-approximation'` {@link InventorySource} on BOTH sides (AM-1) — never a silent "no
98
+ * drift".
99
+ */
100
+ readonly localInventory?: LocalInventory;
101
+ /** Label for the injected provider's source (default `'npm-pack'`); the CLI passes `'pnpm-pack'` for a packed tree. */
102
+ readonly localInventorySource?: InventorySource;
103
+ }
104
+
105
+ function listFilesRecursive(root: string, dir: string): string[] {
106
+ if (!existsSync(dir)) return [];
107
+ const out: string[] = [];
108
+ for (const entry of readdirSync(dir).sort()) {
109
+ const abs = join(dir, entry);
110
+ const st = statSync(abs);
111
+ if (st.isDirectory()) out.push(...listFilesRecursive(root, abs));
112
+ else out.push(relative(root, abs));
113
+ }
114
+ return out;
115
+ }
116
+
117
+ function sha256(data: string | Buffer): string {
118
+ return createHash('sha256').update(data).digest('hex');
119
+ }
120
+
121
+ // ── FR-3 (feature publish-gate-audit-durable): ask npm, don't approximate ──────────────────────
122
+
123
+ /** The exact set of relative paths `npm pack` will ship for a package — no `.npmignore` guessing. */
124
+ export interface PackInventory {
125
+ readonly paths: readonly string[];
126
+ }
127
+
128
+ /** `npm pack --dry-run --json` could not be run or answered in a shape this code cannot use. */
129
+ export interface PackInventoryUnavailable {
130
+ readonly unavailable: string;
131
+ }
132
+
133
+ /**
134
+ * Lead fix after the fix-round's live dry-run (2026-09-14 01:02): the workspace side PACKED BY THE
135
+ * LIVE TRANSPORT (`pnpm pack`) and unpacked into `packedDir`. pnpm synthesises a LICENSE from the
136
+ * workspace root into the tarball of a package whose own tree has none; `npm pack --dry-run --json`
137
+ * never lists that file, so a `paths` inventory read every such sibling as "LICENSE only in the
138
+ * published copy" — 2 false drifts (harness-presets, scout) on a tree unchanged since publication.
139
+ * A packed tree is hashed by the SAME full walk as the published side, symmetric by construction.
140
+ */
141
+ export interface PackedTree {
142
+ readonly packedDir: string;
143
+ }
144
+
145
+ export type LocalInventoryResult = PackInventory | PackedTree | PackInventoryUnavailable;
146
+
147
+ /** Ask what npm would ship for the package rooted at `dir`. Injected in tests (no subprocess). */
148
+ export type LocalInventory = (dir: string) => LocalInventoryResult;
149
+
150
+ /**
151
+ * C-1/AM-4: `npm pack --dry-run --json` is a real subprocess call — the CLI caches its result per
152
+ * absolute directory for the lifetime of ONE `dz publish` run (not per package being checked), so
153
+ * a run that checks the same sibling from more than one dependent package packs it only once. Core
154
+ * itself never runs the subprocess or owns the cache (core-boundary import ratchet) — this parser
155
+ * is the pure half only.
156
+ *
157
+ * Parses `npm pack --dry-run --json`'s stdout (an array with one element; `files[]` holds
158
+ * `{path,size,mode}` per shipped path, plus `integrity`/`shasum`/`entryCount`) into the exact set of
159
+ * relative paths npm intends to ship, honouring `.npmignore`/`files`/default-ignore exactly the way
160
+ * a real `npm publish` would. A failure to run, parse, or make sense of the shape — including a
161
+ * malformed individual `files[]` element (AM-5: a corrupt entry is a reason to say the WHOLE
162
+ * inventory is untrustworthy, never a file to silently drop) — is `{ unavailable: reason }`: an
163
+ * input this gate cannot read is a reason to say so, never a silent "nothing to compare".
164
+ */
165
+ export function parseNpmPackInventory(stdout: string): LocalInventoryResult {
166
+ try {
167
+ const parsed: unknown = JSON.parse(stdout);
168
+ const entry = Array.isArray(parsed) ? (parsed[0] as unknown) : undefined;
169
+ const files = entry !== null && typeof entry === 'object' ? (entry as Record<string, unknown>)['files'] : undefined;
170
+ if (!Array.isArray(files)) return { unavailable: 'npm pack --dry-run --json returned no files[] array' };
171
+ // AM-5 (Codex round-1 finding 6, medium): a malformed element used to be `.filter()`ed out
172
+ // silently — a `files[]` entry npm itself always shapes as `{path,size,mode}` should never fail
173
+ // to parse; if one DOES (missing/non-string `path`, or a non-object element), that is a signal
174
+ // this output cannot be trusted, not a single file to quietly drop from the comparison. Say so.
175
+ const paths: string[] = [];
176
+ for (let i = 0; i < files.length; i++) {
177
+ const f = files[i];
178
+ if (f === null || typeof f !== 'object') {
179
+ return { unavailable: `npm pack --dry-run --json files[${i}] is not an object (got ${JSON.stringify(f)})` };
180
+ }
181
+ const path = (f as Record<string, unknown>)['path'];
182
+ if (typeof path !== 'string' || path === '') {
183
+ return { unavailable: `npm pack --dry-run --json files[${i}].path is missing or not a non-empty string (got ${JSON.stringify(path)})` };
184
+ }
185
+ paths.push(path);
186
+ }
187
+ return { paths };
188
+ } catch (err) {
189
+ return { unavailable: `npm pack --dry-run --json output could not be parsed: ${(err as Error).message.split('\n')[0]}` };
190
+ }
191
+ }
192
+
193
+ /** Hash exactly the paths `npm pack` names (package.json normalized separately, as {@link hashTree} does). */
194
+ /**
195
+ * Codex round-2 (2026-09-14) new findings 1+2: a listed path that is absent, a directory, absolute,
196
+ * or that climbs out of `dir` via `..` used to be SKIPPED silently — a comparison over a listing
197
+ * the tree does not match is not a comparison, it is `unavailable`; and an inventory must never
198
+ * read outside the package directory. Thrown here, turned into an `unavailable` result by the caller.
199
+ */
200
+ class InventoryListingError extends Error {}
201
+
202
+ function hashTreeFromPaths(dir: string, paths: readonly string[], manifest: Record<string, unknown>): Map<string, string> {
203
+ const map = new Map<string, string>();
204
+ for (const rel of paths) {
205
+ if (rel === 'package.json') continue; // normalized below, not hashed raw
206
+ if (isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) {
207
+ throw new InventoryListingError(`inventory path "${rel}" is absolute or leaves the package directory`);
208
+ }
209
+ const abs = join(dir, rel);
210
+ if (!existsSync(abs)) throw new InventoryListingError(`inventory path "${rel}" does not exist in the workspace copy`);
211
+ if (statSync(abs).isDirectory()) throw new InventoryListingError(`inventory path "${rel}" is a directory, not a file`);
212
+ map.set(rel, sha256(readFileSync(abs)));
213
+ }
214
+ map.set('package.json', sha256(normalizedPackageJsonText(manifest)));
215
+ return map;
216
+ }
217
+
218
+ /**
219
+ * AM-1: hash EVERY file under `dir` (the already-unpacked published tarball) — the literal "full
220
+ * recursive walk of what npm put there" the amendment names, used ONLY as the symmetric partner to
221
+ * {@link hashTreeFromPaths} (i.e. only when a `localInventory` provider is injected). `dir` here is
222
+ * always an extracted tarball, never the workspace tree, so there is no `.npmignore` to consult:
223
+ * everything that exists on disk is, by construction, exactly what npm shipped.
224
+ */
225
+ function hashTreeFull(dir: string, manifest: Record<string, unknown>): Map<string, string> {
226
+ const map = new Map<string, string>();
227
+ for (const rel of listFilesRecursive(dir, dir)) {
228
+ if (rel === 'package.json') continue; // normalized below, not hashed raw
229
+ map.set(rel, sha256(readFileSync(join(dir, rel))));
230
+ }
231
+ map.set('package.json', sha256(normalizedPackageJsonText(manifest)));
232
+ return map;
233
+ }
234
+
235
+ /**
236
+ * package.json PARSED and validated. `null` (never `undefined`) means "this side cannot be built
237
+ * at all" — AM-3: a missing or unparseable manifest on EITHER side must surface as `unavailable`,
238
+ * never as an empty/omitted comparison field that a hash-mismatch loop could silently read as
239
+ * "nothing differs here".
240
+ */
241
+ function readManifest(dir: string): Record<string, unknown> | null {
242
+ const p = join(dir, 'package.json');
243
+ if (!existsSync(p)) return null;
244
+ try {
245
+ const raw = JSON.parse(readFileSync(p, 'utf-8'));
246
+ return raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : null;
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+
252
+ /** package.json normalized for comparison: strip fields that legitimately differ (version, gitHead, npm-internal `_*`). */
253
+ function normalizedPackageJsonText(raw: Record<string, unknown>): string {
254
+ // Lead edit after the live dry-run on the hub (2026-09-13 10:40): the packer strips
255
+ // `scripts.prepublishOnly`, drops devDependencies/publishConfig and rewrites `workspace:` specs to
256
+ // pinned versions — every freshly published sibling read as "1 file drifted". Compare only what
257
+ // shapes the SHIPPED behavior: entry points, bins, files, engines, and dependency NAMES (values
258
+ // are the workspace-floor preflight's business, not this gate's).
259
+ //
260
+ // AM-4: `imports`/`browser`/`sideEffects`/`man` added — each one changes what a consumer actually
261
+ // resolves or ships, exactly like `main`/`exports`/`bin` already did; omitting them was a real gap
262
+ // the round-1 review named (finding 4), not a stylistic nicety.
263
+ const SHIPPING_FIELDS = [
264
+ 'name', 'type', 'main', 'module', 'types', 'exports', 'imports', 'browser', 'sideEffects', 'man',
265
+ 'bin', 'files', 'engines', 'os', 'cpu',
266
+ ];
267
+ const DEP_TABLES = ['dependencies', 'peerDependencies', 'optionalDependencies'];
268
+ const kept: Record<string, unknown> = {};
269
+ for (const key of SHIPPING_FIELDS) if (key in raw) kept[key] = raw[key];
270
+ for (const key of DEP_TABLES) {
271
+ const table = raw[key];
272
+ if (table !== null && typeof table === 'object') kept[key] = Object.keys(table as Record<string, unknown>).sort();
273
+ }
274
+ return JSON.stringify(kept);
275
+ }
276
+
277
+ /** Every relative path (from `dir`) that a `bin` field in a parsed manifest resolves to. */
278
+ function binPaths(raw: Record<string, unknown>): string[] {
279
+ const bin = raw['bin'];
280
+ if (typeof bin === 'string') return [bin.replace(/^\.\//, '')];
281
+ if (bin !== null && typeof bin === 'object' && !Array.isArray(bin)) {
282
+ return Object.values(bin as Record<string, unknown>)
283
+ .filter((v): v is string => typeof v === 'string')
284
+ .map((v) => v.replace(/^\.\//, ''));
285
+ }
286
+ return [];
287
+ }
288
+
289
+ /**
290
+ * AM-4: the round-1 gate hashed only `dist/**` — a changed bin script, template, or other
291
+ * top-level asset that ships (declared in `package.json#files`, or the `bin` target itself) was
292
+ * invisible to the drift check even though npm ships it byte-for-byte. This is a documented,
293
+ * honest APPROXIMATION of "the whole tarball inventory" (the literal ADR wording), not a full
294
+ * re-implementation of npm's pack-time file-inclusion rules (`.npmignore`, default excludes,
295
+ * nested `.gitignore`): it walks `dist/**` (unconditional — the common case) plus every path
296
+ * named in `files` (directories walked recursively, files hashed directly) plus every resolved
297
+ * `bin` target, deduplicated. A package with no `files` field declared keeps exactly the
298
+ * pre-amendment `dist/**`-only scope, named here rather than silently pretended-away.
299
+ */
300
+ function shippedInventoryDirs(dir: string, raw: Record<string, unknown>): string[] {
301
+ const rels = new Set<string>(['dist']);
302
+ const files = raw['files'];
303
+ if (Array.isArray(files)) {
304
+ for (const entry of files) {
305
+ if (typeof entry === 'string' && entry.trim() !== '') rels.add(entry.replace(/^\.\//, '').replace(/\/+$/, ''));
306
+ }
307
+ }
308
+ for (const bin of binPaths(raw)) rels.add(bin);
309
+ return [...rels].filter((rel) => existsSync(join(dir, rel)));
310
+ }
311
+
312
+ /** Hash the shipped inventory (AM-4) plus the normalized package.json, keyed by a stable relative path. */
313
+ function hashTree(dir: string, manifest: Record<string, unknown>): Map<string, string> {
314
+ const map = new Map<string, string>();
315
+ for (const rel of shippedInventoryDirs(dir, manifest)) {
316
+ const abs = join(dir, rel);
317
+ if (statSync(abs).isDirectory()) {
318
+ for (const sub of listFilesRecursive(abs, abs)) map.set(join(rel, sub), sha256(readFileSync(join(abs, sub))));
319
+ } else {
320
+ map.set(rel, sha256(readFileSync(abs)));
321
+ }
322
+ }
323
+ map.set('package.json', sha256(normalizedPackageJsonText(manifest)));
324
+ return map;
325
+ }
326
+
327
+ function extractExportNames(source: string): Set<string> {
328
+ const names = new Set<string>();
329
+ for (const m of source.matchAll(/export\s+(?:const|function|class|async\s+function)\s+([A-Za-z0-9_$]+)/g)) {
330
+ names.add(m[1]!);
331
+ }
332
+ for (const m of source.matchAll(/export\s*\{([^}]*)\}/g)) {
333
+ for (const part of m[1]!.split(',')) {
334
+ const name = part.trim().split(/\s+as\s+/).pop()?.trim();
335
+ if (name) names.add(name);
336
+ }
337
+ }
338
+ return names;
339
+ }
340
+
341
+ /** А2 (ADR-001, rejected as the sole signal, kept as a readable second signal). */
342
+ function missingExportNames(publishedDir: string, workspaceDir: string): string[] {
343
+ const pubIndex = join(publishedDir, 'dist', 'index.js');
344
+ const wsIndex = join(workspaceDir, 'dist', 'index.js');
345
+ if (!existsSync(pubIndex) || !existsSync(wsIndex)) return [];
346
+ const pubExports = extractExportNames(readFileSync(pubIndex, 'utf-8'));
347
+ const wsExports = extractExportNames(readFileSync(wsIndex, 'utf-8'));
348
+ return [...wsExports].filter((n) => !pubExports.has(n)).sort();
349
+ }
350
+
351
+ /**
352
+ * For every `workspace:`-declared dependency of a package that is NOT part of `batch` (i.e. will
353
+ * be pinned to whatever is already on the registry, not published fresh in this run), compare the
354
+ * build that will be pinned against the workspace copy. Pure: all IO (fetch, fs) is either
355
+ * injected or scoped to reading local dist/package.json files — no network call is made here.
356
+ */
357
+ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDriftResult[] {
358
+ const results: SiblingDriftResult[] = [];
359
+ const seen = new Set<string>();
360
+ // AM-6: named ONCE per call — every result below (including the short-circuited `unavailable`
361
+ // ones) carries the source that is or would have been used for this comparison.
362
+ const inventorySource: InventorySource = opts.localInventory !== undefined ? (opts.localInventorySource ?? 'npm-pack') : 'readdir-approximation';
363
+ // AM-3: `optionalDependencies` ships and pins EXACTLY like `dependencies`/`peerDependencies` —
364
+ // checking only the first two let a stale optional sibling through untouched (round-1 finding 3).
365
+ const entries = [
366
+ ...Object.entries(opts.dependencies ?? {}),
367
+ ...Object.entries(opts.peerDependencies ?? {}),
368
+ ...Object.entries(opts.optionalDependencies ?? {}),
369
+ ];
370
+ for (const [dep, spec] of entries) {
371
+ if (!String(spec).startsWith('workspace:')) continue;
372
+ if (seen.has(dep)) continue;
373
+ seen.add(dep);
374
+ if (opts.batch.has(dep)) continue; // publishes fresh in this batch — nothing stale to drift from
375
+ const version = opts.workspaceVersions.get(dep);
376
+ const workspaceDir = opts.workspaceDirs.get(dep);
377
+ // AM-3: a `workspace:`-spec'd dependency this caller does not recognize used to be silently
378
+ // SKIPPED — an input this gate cannot build is a HARD gate that cannot say "same", never a
379
+ // quiet pass-through (round-1 finding 3: pnpm would die packing it anyway; die here, named).
380
+ if (version === undefined || workspaceDir === undefined) {
381
+ results.push({
382
+ name: dep,
383
+ version: version ?? '(not in workspace)',
384
+ status: 'unavailable',
385
+ changedFiles: [],
386
+ missingExports: [],
387
+ reason: `${dep} is declared workspace:-protocol but is not a known workspace package`,
388
+ inventorySource,
389
+ });
390
+ continue;
391
+ }
392
+
393
+ const fetched = opts.fetchPublished(dep, version);
394
+ if (fetched === null) {
395
+ results.push({
396
+ name: dep,
397
+ version,
398
+ status: 'unavailable',
399
+ changedFiles: [],
400
+ missingExports: [],
401
+ reason: `could not fetch ${dep}@${version} from the registry (network unavailable or the version was not found)`,
402
+ inventorySource,
403
+ });
404
+ continue;
405
+ }
406
+
407
+ // AM-3: a missing/unparseable package.json on EITHER side must not silently drop out of the
408
+ // comparison (the old `hashTree` simply omitted the key, which — with an empty/matching
409
+ // `dist/**` on both sides — could report `same` about an input that was never actually read).
410
+ const publishedManifest = readManifest(fetched.dir);
411
+ const workspaceManifest = readManifest(workspaceDir);
412
+ if (publishedManifest === null || workspaceManifest === null) {
413
+ const side = publishedManifest === null ? 'the published tarball' : 'the workspace copy';
414
+ results.push({
415
+ name: dep,
416
+ version,
417
+ status: 'unavailable',
418
+ changedFiles: [],
419
+ missingExports: [],
420
+ reason: `${dep}@${version}: package.json in ${side} is missing or not valid JSON — cannot compare`,
421
+ inventorySource,
422
+ });
423
+ continue;
424
+ }
425
+
426
+ // FR-3/AM-1: the LOCAL package's inventory comes from npm, not from a hand-rolled dist/files/bin
427
+ // walk — `.npmignore` (and nested ignore rules) can exclude a file this gate would otherwise walk
428
+ // straight into, producing a false drift about a file npm was never going to ship. AM-1 (Codex
429
+ // review, round-1 finding 3, high): the two sides must stay SYMMETRIC. With a provider injected,
430
+ // the workspace side is npm's OWN shipped-path list; the published side must then be hashed by a
431
+ // FULL recursive walk of the already-unpacked tarball (every file npm actually put there —
432
+ // README/LICENSE included, since npm auto-packs those regardless of `files`), not the narrower
433
+ // `dist`/`files`/`bin` approximation `hashTree` uses — that approximation would silently OMIT an
434
+ // auto-packed README/LICENSE from the published side while the workspace side (via real `npm
435
+ // pack`) correctly includes them, reading as a false "only in workspace" drift. WITHOUT a
436
+ // provider, core has no way to ask npm on either side, so it degrades to the SAME approximation
437
+ // on BOTH sides (symmetry preserved, just cruder) — a named approximation, never a subprocess.
438
+ let workspaceHashes: Map<string, string>;
439
+ let publishedHashes: Map<string, string>;
440
+ if (opts.localInventory !== undefined) {
441
+ const localResult = opts.localInventory(workspaceDir);
442
+ if ('unavailable' in localResult) {
443
+ results.push({
444
+ name: dep,
445
+ version,
446
+ status: 'unavailable',
447
+ changedFiles: [],
448
+ missingExports: [],
449
+ reason: `${dep}@${version}: local package inventory unavailable (${localResult.unavailable})`,
450
+ inventorySource,
451
+ });
452
+ continue;
453
+ }
454
+ try {
455
+ workspaceHashes = 'packedDir' in localResult
456
+ ? hashTreeFull(localResult.packedDir, workspaceManifest)
457
+ : hashTreeFromPaths(workspaceDir, localResult.paths, workspaceManifest);
458
+ } catch (err) {
459
+ if (!(err instanceof InventoryListingError)) throw err;
460
+ results.push({
461
+ name: dep,
462
+ version,
463
+ status: 'unavailable',
464
+ changedFiles: [],
465
+ missingExports: [],
466
+ reason: `${dep}@${version}: local package inventory unusable (${err.message})`,
467
+ inventorySource,
468
+ });
469
+ continue;
470
+ }
471
+ publishedHashes = hashTreeFull(fetched.dir, publishedManifest);
472
+ } else {
473
+ workspaceHashes = hashTree(workspaceDir, workspaceManifest);
474
+ publishedHashes = hashTree(fetched.dir, publishedManifest);
475
+ }
476
+
477
+ const allKeys = new Set<string>([...publishedHashes.keys(), ...workspaceHashes.keys()]);
478
+ const changed: string[] = [];
479
+ for (const key of allKeys) {
480
+ if (publishedHashes.get(key) !== workspaceHashes.get(key)) changed.push(key);
481
+ }
482
+ changed.sort();
483
+
484
+ if (changed.length === 0) {
485
+ results.push({ name: dep, version, status: 'same', changedFiles: [], missingExports: [], inventorySource });
486
+ } else {
487
+ results.push({
488
+ name: dep,
489
+ version,
490
+ status: 'drift',
491
+ changedFiles: changed,
492
+ missingExports: missingExportNames(fetched.dir, workspaceDir),
493
+ inventorySource,
494
+ });
495
+ }
496
+ }
497
+ return results;
498
+ }