@mmnto/cli 1.107.1 → 1.109.0
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/dist/commands/config-drift.test.js +51 -0
- package/dist/commands/config-drift.test.js.map +1 -1
- package/dist/commands/docs.d.ts +1 -1
- package/dist/commands/docs.d.ts.map +1 -1
- package/dist/commands/docs.js +1 -1
- package/dist/commands/doctor-parity.d.ts.map +1 -1
- package/dist/commands/doctor-parity.js +25 -0
- package/dist/commands/doctor-parity.js.map +1 -1
- package/dist/commands/doctor-parity.test.js +86 -0
- package/dist/commands/doctor-parity.test.js.map +1 -1
- package/dist/commands/init-templates.d.ts +4 -4
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +85 -2
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.test.js +94 -0
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/install-hooks.d.ts.map +1 -1
- package/dist/commands/install-hooks.js +13 -0
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/install-hooks.test.js +69 -0
- package/dist/commands/install-hooks.test.js.map +1 -1
- package/dist/commands/shield-banner.test.d.ts +17 -0
- package/dist/commands/shield-banner.test.d.ts.map +1 -0
- package/dist/commands/shield-banner.test.js +118 -0
- package/dist/commands/shield-banner.test.js.map +1 -0
- package/dist/commands/shield.d.ts.map +1 -1
- package/dist/commands/shield.js +11 -0
- package/dist/commands/shield.js.map +1 -1
- package/dist/commands/spec-templates.d.ts +1 -1
- package/dist/commands/spec-templates.d.ts.map +1 -1
- package/dist/commands/spec-templates.js +1 -1
- package/dist/commands/verify-lockfile-sync.d.ts +11 -0
- package/dist/commands/verify-lockfile-sync.d.ts.map +1 -1
- package/dist/commands/verify-lockfile-sync.js +457 -2
- package/dist/commands/verify-lockfile-sync.js.map +1 -1
- package/dist/commands/verify-lockfile-sync.test.js +1112 -0
- package/dist/commands/verify-lockfile-sync.test.js.map +1 -1
- package/dist/git.d.ts +7 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +75 -4
- package/dist/git.js.map +1 -1
- package/dist/git.test.js +125 -1
- package/dist/git.test.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -52,12 +52,332 @@ index aaaaaa..bbbbbb 100644
|
|
|
52
52
|
"commander": "^11.0.0"
|
|
53
53
|
}
|
|
54
54
|
`;
|
|
55
|
+
// The workspace at HEAD for the pin-add branch: importers `.`, `apps/web`, and
|
|
56
|
+
// `packages/cli`. Key forms match real pnpm emission — only scoped names are
|
|
57
|
+
// quoted; unscoped `packages:`/`snapshots:` keys are bare.
|
|
58
|
+
const HEAD_LOCKFILE_WORKSPACE = `lockfileVersion: '9.0'
|
|
59
|
+
|
|
60
|
+
settings:
|
|
61
|
+
autoInstallPeers: true
|
|
62
|
+
|
|
63
|
+
importers:
|
|
64
|
+
|
|
65
|
+
.:
|
|
66
|
+
dependencies:
|
|
67
|
+
'@mmnto/cli':
|
|
68
|
+
specifier: ^1.43.2
|
|
69
|
+
version: 1.43.2
|
|
70
|
+
|
|
71
|
+
apps/web:
|
|
72
|
+
dependencies:
|
|
73
|
+
foo:
|
|
74
|
+
specifier: ^1.2.3
|
|
75
|
+
version: 1.2.3
|
|
76
|
+
|
|
77
|
+
packages/cli:
|
|
78
|
+
dependencies:
|
|
79
|
+
commander:
|
|
80
|
+
specifier: ^11.0.0
|
|
81
|
+
version: 11.0.0
|
|
82
|
+
|
|
83
|
+
packages:
|
|
84
|
+
|
|
85
|
+
'@mmnto/cli@1.43.2':
|
|
86
|
+
resolution: {integrity: sha512-aaaa}
|
|
87
|
+
commander@11.0.0:
|
|
88
|
+
resolution: {integrity: sha512-bbbb}
|
|
89
|
+
foo@1.2.3:
|
|
90
|
+
resolution: {integrity: sha512-cccc}
|
|
91
|
+
|
|
92
|
+
snapshots:
|
|
93
|
+
|
|
94
|
+
'@mmnto/cli@1.43.2': {}
|
|
95
|
+
commander@11.0.0: {}
|
|
96
|
+
foo@1.2.3: {}
|
|
97
|
+
`;
|
|
98
|
+
// ─── Removed-pin fixtures (mmnto-ai/totem-strategy#630) ───
|
|
99
|
+
//
|
|
100
|
+
// Provenance: live-fire 2026-08-01, mmnto-ai/totem-strategy#630 comment
|
|
101
|
+
// 5152301962 — a failed optional-dependency fetch (dead registry token) removed
|
|
102
|
+
// every `@mmnto/strategy-doctrine` entry from pnpm-lock.yaml while package.json
|
|
103
|
+
// kept the pin, and `pnpm install` exited 0. The key shapes below are modeled on
|
|
104
|
+
// the real pnpm-lock.yaml entries: importer key (:48), packages key (:865),
|
|
105
|
+
// snapshots key (:3697).
|
|
106
|
+
const DOCTRINE_PIN_REMOVAL_DIFF = `diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
|
|
107
|
+
index aaaaaa..bbbbbb 100644
|
|
108
|
+
--- a/pnpm-lock.yaml
|
|
109
|
+
+++ b/pnpm-lock.yaml
|
|
110
|
+
@@ -44,9 +44,6 @@ importers:
|
|
111
|
+
optionalDependencies:
|
|
112
|
+
'@mmnto/cli':
|
|
113
|
+
specifier: workspace:*
|
|
114
|
+
version: link:packages/cli
|
|
115
|
+
- '@mmnto/strategy-doctrine':
|
|
116
|
+
- specifier: 0.1.28
|
|
117
|
+
- version: 0.1.28
|
|
118
|
+
@@ -862,4 +859,1 @@ packages:
|
|
119
|
+
|
|
120
|
+
- '@mmnto/strategy-doctrine@0.1.28':
|
|
121
|
+
- resolution: {integrity: sha512-sxiE7gO3XK/kPx59f9ZJockuCdxcu9PN0Mqy2oCcTnFm1wnGu2uXC47u/tDOEHkWrLmG07i//ggfhcqqVS7baw==}
|
|
122
|
+
-
|
|
123
|
+
@@ -3694,4 +3688,1 @@ snapshots:
|
|
124
|
+
|
|
125
|
+
- '@mmnto/strategy-doctrine@0.1.28':
|
|
126
|
+
- optional: true
|
|
127
|
+
-
|
|
128
|
+
`;
|
|
129
|
+
// HEAD lockfile after the drop: the package resolves NOWHERE, which is what
|
|
130
|
+
// makes the removal decisive rather than a dedupe.
|
|
131
|
+
const HEAD_LOCKFILE_WITHOUT_DOCTRINE = `lockfileVersion: '9.0'
|
|
132
|
+
|
|
133
|
+
importers:
|
|
134
|
+
|
|
135
|
+
.:
|
|
136
|
+
optionalDependencies:
|
|
137
|
+
'@mmnto/cli':
|
|
138
|
+
specifier: workspace:*
|
|
139
|
+
version: link:packages/cli
|
|
140
|
+
|
|
141
|
+
packages:
|
|
142
|
+
|
|
143
|
+
'@mmnto/cli@1.60.0':
|
|
144
|
+
resolution: {integrity: sha512-aaaa}
|
|
145
|
+
|
|
146
|
+
snapshots:
|
|
147
|
+
|
|
148
|
+
'@mmnto/cli@1.60.0': {}
|
|
149
|
+
`;
|
|
150
|
+
// Same drop, but the workspace also has a `packages/app` importer.
|
|
151
|
+
const HEAD_LOCKFILE_WITH_APP_IMPORTER = `lockfileVersion: '9.0'
|
|
152
|
+
|
|
153
|
+
importers:
|
|
154
|
+
|
|
155
|
+
.:
|
|
156
|
+
dependencies:
|
|
157
|
+
'@mmnto/cli':
|
|
158
|
+
specifier: workspace:*
|
|
159
|
+
version: link:packages/cli
|
|
160
|
+
|
|
161
|
+
packages/app:
|
|
162
|
+
dependencies:
|
|
163
|
+
'@mmnto/cli':
|
|
164
|
+
specifier: workspace:*
|
|
165
|
+
version: link:packages/cli
|
|
166
|
+
|
|
167
|
+
packages:
|
|
168
|
+
|
|
169
|
+
'@mmnto/cli@1.60.0':
|
|
170
|
+
resolution: {integrity: sha512-aaaa}
|
|
171
|
+
|
|
172
|
+
snapshots:
|
|
173
|
+
|
|
174
|
+
'@mmnto/cli@1.60.0': {}
|
|
175
|
+
`;
|
|
176
|
+
// A pure version bump: the old key is removed and a new key for the SAME name is
|
|
177
|
+
// added in the same diff. The HEAD lockfile still resolves the name.
|
|
178
|
+
const DOCTRINE_VERSION_BUMP_DIFF = `diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
|
|
179
|
+
index aaaaaa..bbbbbb 100644
|
|
180
|
+
--- a/pnpm-lock.yaml
|
|
181
|
+
+++ b/pnpm-lock.yaml
|
|
182
|
+
@@ -44,7 +44,7 @@ importers:
|
|
183
|
+
'@mmnto/strategy-doctrine':
|
|
184
|
+
- specifier: 0.1.28
|
|
185
|
+
- version: 0.1.28
|
|
186
|
+
+ specifier: 0.1.29
|
|
187
|
+
+ version: 0.1.29
|
|
188
|
+
@@ -862,4 +862,4 @@ packages:
|
|
189
|
+
- '@mmnto/strategy-doctrine@0.1.28':
|
|
190
|
+
- resolution: {integrity: sha512-aaaa}
|
|
191
|
+
+ '@mmnto/strategy-doctrine@0.1.29':
|
|
192
|
+
+ resolution: {integrity: sha512-bbbb}
|
|
193
|
+
@@ -3694,4 +3694,4 @@ snapshots:
|
|
194
|
+
- '@mmnto/strategy-doctrine@0.1.28':
|
|
195
|
+
- optional: true
|
|
196
|
+
+ '@mmnto/strategy-doctrine@0.1.29':
|
|
197
|
+
+ optional: true
|
|
198
|
+
`;
|
|
199
|
+
const HEAD_LOCKFILE_WITH_DOCTRINE_29 = `lockfileVersion: '9.0'
|
|
200
|
+
|
|
201
|
+
importers:
|
|
202
|
+
|
|
203
|
+
.:
|
|
204
|
+
optionalDependencies:
|
|
205
|
+
'@mmnto/strategy-doctrine':
|
|
206
|
+
specifier: 0.1.29
|
|
207
|
+
version: 0.1.29
|
|
208
|
+
|
|
209
|
+
packages:
|
|
210
|
+
|
|
211
|
+
'@mmnto/strategy-doctrine@0.1.29':
|
|
212
|
+
resolution: {integrity: sha512-bbbb}
|
|
213
|
+
|
|
214
|
+
snapshots:
|
|
215
|
+
|
|
216
|
+
'@mmnto/strategy-doctrine@0.1.29': {}
|
|
217
|
+
`;
|
|
218
|
+
// A dedupe: one version's keys go away while the package still resolves at HEAD
|
|
219
|
+
// under another version. Unscoped keys are BARE — real pnpm quotes only names
|
|
220
|
+
// that need it.
|
|
221
|
+
const DEDUPE_DIFF = `diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
|
|
222
|
+
index aaaaaa..bbbbbb 100644
|
|
223
|
+
--- a/pnpm-lock.yaml
|
|
224
|
+
+++ b/pnpm-lock.yaml
|
|
225
|
+
@@ -1200,4 +1200,1 @@ packages:
|
|
226
|
+
- ajv@6.15.0:
|
|
227
|
+
- resolution: {integrity: sha512-aaaa}
|
|
228
|
+
-
|
|
229
|
+
@@ -4100,4 +4097,1 @@ snapshots:
|
|
230
|
+
- ajv@6.15.0: {}
|
|
231
|
+
-
|
|
232
|
+
`;
|
|
233
|
+
const HEAD_LOCKFILE_WITH_AJV_8 = `lockfileVersion: '9.0'
|
|
234
|
+
|
|
235
|
+
importers:
|
|
236
|
+
|
|
237
|
+
.:
|
|
238
|
+
dependencies:
|
|
239
|
+
ajv:
|
|
240
|
+
specifier: ^8.18.0
|
|
241
|
+
version: 8.18.0
|
|
242
|
+
|
|
243
|
+
packages:
|
|
244
|
+
|
|
245
|
+
ajv@8.18.0:
|
|
246
|
+
resolution: {integrity: sha512-bbbb}
|
|
247
|
+
|
|
248
|
+
snapshots:
|
|
249
|
+
|
|
250
|
+
ajv@8.18.0: {}
|
|
251
|
+
`;
|
|
252
|
+
// ── Metadata-laundering fixtures (falsification round 2) ──
|
|
253
|
+
// `zod` is dropped outright, but survives at HEAD ONLY as a peer-metadata child
|
|
254
|
+
// of another package. Those children are valueless key lines at a DEEPER indent
|
|
255
|
+
// than a `packages:` entry — the shape that made a shape-only harvest report
|
|
256
|
+
// "still resolves" and pass the #630 class through.
|
|
257
|
+
const ZOD_PIN_REMOVAL_DIFF = `diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
|
|
258
|
+
--- a/pnpm-lock.yaml
|
|
259
|
+
+++ b/pnpm-lock.yaml
|
|
260
|
+
@@ -44,9 +44,6 @@ importers:
|
|
261
|
+
dependencies:
|
|
262
|
+
- zod:
|
|
263
|
+
- specifier: ^3.25.76
|
|
264
|
+
- version: 3.25.76
|
|
265
|
+
@@ -862,4 +859,1 @@ packages:
|
|
266
|
+
- zod@3.25.76:
|
|
267
|
+
- resolution: {integrity: sha512-aaaa}
|
|
268
|
+
-
|
|
269
|
+
@@ -3694,4 +3688,1 @@ snapshots:
|
|
270
|
+
- zod@3.25.76: {}
|
|
271
|
+
-
|
|
272
|
+
`;
|
|
273
|
+
// Same drop, plus an ADDED peer-metadata child naming the dropped package — the
|
|
274
|
+
// second laundering vector (an added-name subtraction excluded the candidate).
|
|
275
|
+
const ZOD_PIN_REMOVAL_WITH_ADDED_PEER_META_DIFF = `${ZOD_PIN_REMOVAL_DIFF}@@ -900,3 +900,5 @@ packages:
|
|
276
|
+
ajv@8.18.0:
|
|
277
|
+
resolution: {integrity: sha512-bbbb}
|
|
278
|
+
+ peerDependenciesMeta:
|
|
279
|
+
+ zod:
|
|
280
|
+
+ optional: true
|
|
281
|
+
`;
|
|
282
|
+
const HEAD_LOCKFILE_ZOD_ONLY_IN_PEER_META = `lockfileVersion: '9.0'
|
|
283
|
+
|
|
284
|
+
importers:
|
|
285
|
+
|
|
286
|
+
.:
|
|
287
|
+
dependencies:
|
|
288
|
+
ajv:
|
|
289
|
+
specifier: ^8.18.0
|
|
290
|
+
version: 8.18.0
|
|
291
|
+
|
|
292
|
+
packages:
|
|
293
|
+
|
|
294
|
+
ajv@8.18.0:
|
|
295
|
+
resolution: {integrity: sha512-bbbb}
|
|
296
|
+
peerDependencies:
|
|
297
|
+
zod: ^3.25 || ^4.0
|
|
298
|
+
peerDependenciesMeta:
|
|
299
|
+
zod:
|
|
300
|
+
optional: true
|
|
301
|
+
|
|
302
|
+
snapshots:
|
|
303
|
+
|
|
304
|
+
ajv@8.18.0: {}
|
|
305
|
+
`;
|
|
306
|
+
// ── Transitive-drop fixture (falsification round 2) ──
|
|
307
|
+
// A transitive dep leaves the lockfile as a consequence of bumping its parent.
|
|
308
|
+
// A NON-importer manifest (a separately-deployed service) still declares it.
|
|
309
|
+
const HONO_DROP_DIFF = `diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
|
|
310
|
+
--- a/pnpm-lock.yaml
|
|
311
|
+
+++ b/pnpm-lock.yaml
|
|
312
|
+
@@ -862,4 +859,1 @@ packages:
|
|
313
|
+
- hono@4.12.3:
|
|
314
|
+
- resolution: {integrity: sha512-aaaa}
|
|
315
|
+
-
|
|
316
|
+
@@ -3694,4 +3688,1 @@ snapshots:
|
|
317
|
+
- hono@4.12.3: {}
|
|
318
|
+
-
|
|
319
|
+
`;
|
|
320
|
+
const HEAD_LOCKFILE_WITHOUT_HONO = `lockfileVersion: '9.0'
|
|
321
|
+
|
|
322
|
+
importers:
|
|
323
|
+
|
|
324
|
+
.:
|
|
325
|
+
dependencies:
|
|
326
|
+
'@modelcontextprotocol/sdk':
|
|
327
|
+
specifier: ^1.28.0
|
|
328
|
+
version: 1.28.0
|
|
329
|
+
|
|
330
|
+
packages:
|
|
331
|
+
|
|
332
|
+
'@modelcontextprotocol/sdk@1.28.0':
|
|
333
|
+
resolution: {integrity: sha512-bbbb}
|
|
334
|
+
|
|
335
|
+
snapshots:
|
|
336
|
+
|
|
337
|
+
'@modelcontextprotocol/sdk@1.28.0': {}
|
|
338
|
+
`;
|
|
339
|
+
/** package.json contents at HEAD, keyed by tracked path. */
|
|
340
|
+
const HEAD_PACKAGE_JSONS = {
|
|
341
|
+
'package.json': `{
|
|
342
|
+
"name": "totem",
|
|
343
|
+
"optionalDependencies": {
|
|
344
|
+
"@mmnto/strategy-doctrine": "0.1.28"
|
|
345
|
+
},
|
|
346
|
+
"dependencies": {
|
|
347
|
+
"ajv": "^8.18.0"
|
|
348
|
+
}
|
|
349
|
+
}`,
|
|
350
|
+
};
|
|
351
|
+
/** The workspace definition most fixtures assume at HEAD. */
|
|
352
|
+
const DEFAULT_WORKSPACE_YAML = `packages:
|
|
353
|
+
- 'packages/*'
|
|
354
|
+
`;
|
|
355
|
+
/**
|
|
356
|
+
* Emulates `git show HEAD:<path>`: the lockfile blob, the workspace definition,
|
|
357
|
+
* or a tracked manifest. A path absent from `manifests` throws, as git does.
|
|
358
|
+
*/
|
|
359
|
+
function showAtHead(args, headLockfile, manifests = {}, workspaceYaml = DEFAULT_WORKSPACE_YAML) {
|
|
360
|
+
const ref = args[1] ?? '';
|
|
361
|
+
if (ref === 'HEAD:pnpm-lock.yaml')
|
|
362
|
+
return headLockfile;
|
|
363
|
+
if (ref === 'HEAD:pnpm-workspace.yaml') {
|
|
364
|
+
// `null` models a repo with no workspace file (git exits non-zero).
|
|
365
|
+
if (workspaceYaml === null)
|
|
366
|
+
throw new Error(`fatal: ${ref} does not exist in HEAD`);
|
|
367
|
+
return workspaceYaml;
|
|
368
|
+
}
|
|
369
|
+
const content = manifests[ref.slice('HEAD:'.length)];
|
|
370
|
+
if (content === undefined)
|
|
371
|
+
throw new Error(`fatal: ${ref} does not exist in HEAD`);
|
|
372
|
+
return content;
|
|
373
|
+
}
|
|
55
374
|
describe('verifyLockfileSyncCommand', () => {
|
|
56
375
|
beforeEach(() => {
|
|
57
376
|
vi.resetModules();
|
|
58
377
|
});
|
|
59
378
|
afterEach(() => {
|
|
60
379
|
vi.unstubAllEnvs();
|
|
380
|
+
vi.restoreAllMocks();
|
|
61
381
|
});
|
|
62
382
|
it('passes when not inside a git repo', async () => {
|
|
63
383
|
vi.doMock('@mmnto/totem', async () => {
|
|
@@ -100,6 +420,8 @@ describe('verifyLockfileSyncCommand', () => {
|
|
|
100
420
|
}
|
|
101
421
|
if (args[0] === 'diff')
|
|
102
422
|
return CARET_BUMP_DIFF;
|
|
423
|
+
if (args[0] === 'show')
|
|
424
|
+
return showAtHead(args, HEAD_LOCKFILE_WORKSPACE);
|
|
103
425
|
throw new Error('unexpected');
|
|
104
426
|
},
|
|
105
427
|
};
|
|
@@ -123,6 +445,8 @@ describe('verifyLockfileSyncCommand', () => {
|
|
|
123
445
|
}
|
|
124
446
|
if (args[0] === 'diff')
|
|
125
447
|
return NESTED_PKG_BUMP_DIFF;
|
|
448
|
+
if (args[0] === 'show')
|
|
449
|
+
return showAtHead(args, HEAD_LOCKFILE_WORKSPACE);
|
|
126
450
|
throw new Error('unexpected');
|
|
127
451
|
},
|
|
128
452
|
};
|
|
@@ -130,9 +454,195 @@ describe('verifyLockfileSyncCommand', () => {
|
|
|
130
454
|
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
131
455
|
const result = await verifyLockfileSyncCommand();
|
|
132
456
|
expect(result.valid).toBe(false);
|
|
457
|
+
expect(result.failureClass).toBe('missing-lockfile');
|
|
133
458
|
expect(result.reason).toMatch(/Tracked lockfile detected/);
|
|
134
459
|
expect(result.reason).toMatch(/pnpm-lock\.yaml/);
|
|
135
460
|
});
|
|
461
|
+
it('passes when the pin is added to a NON-importer manifest (test fixture, not a workspace package)', async () => {
|
|
462
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
463
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
464
|
+
return {
|
|
465
|
+
...actual,
|
|
466
|
+
resolveGitRoot: () => '/repo',
|
|
467
|
+
getDefaultBranch: () => 'main',
|
|
468
|
+
safeExec: (_cmd, args) => {
|
|
469
|
+
if (args[0] === 'ls-files')
|
|
470
|
+
return 'pnpm-lock.yaml';
|
|
471
|
+
if (args[0] === 'diff' && args.includes('--name-only')) {
|
|
472
|
+
return 'tests/fixtures/sample/package.json';
|
|
473
|
+
}
|
|
474
|
+
if (args[0] === 'show')
|
|
475
|
+
return showAtHead(args, HEAD_LOCKFILE_WORKSPACE);
|
|
476
|
+
// Reaching the unified-diff pull would mean the non-importer manifest
|
|
477
|
+
// was still in scope.
|
|
478
|
+
throw new Error('unexpected');
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
});
|
|
482
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
483
|
+
const result = await verifyLockfileSyncCommand();
|
|
484
|
+
expect(result.valid).toBe(true);
|
|
485
|
+
});
|
|
486
|
+
it('still fails when a WORKSPACE importer manifest adds a pin without the lockfile', async () => {
|
|
487
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
488
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
489
|
+
return {
|
|
490
|
+
...actual,
|
|
491
|
+
resolveGitRoot: () => '/repo',
|
|
492
|
+
getDefaultBranch: () => 'main',
|
|
493
|
+
safeExec: (_cmd, args) => {
|
|
494
|
+
if (args[0] === 'ls-files')
|
|
495
|
+
return 'pnpm-lock.yaml';
|
|
496
|
+
if (args[0] === 'diff' && args.includes('--name-only')) {
|
|
497
|
+
// One importer manifest, one fixture manifest: only the importer
|
|
498
|
+
// may reach the pin scan.
|
|
499
|
+
return 'tests/fixtures/sample/package.json\napps/web/package.json';
|
|
500
|
+
}
|
|
501
|
+
if (args[0] === 'diff') {
|
|
502
|
+
expect(args).toContain('apps/web/package.json');
|
|
503
|
+
expect(args).not.toContain('tests/fixtures/sample/package.json');
|
|
504
|
+
return NESTED_PKG_BUMP_DIFF;
|
|
505
|
+
}
|
|
506
|
+
if (args[0] === 'show')
|
|
507
|
+
return showAtHead(args, HEAD_LOCKFILE_WORKSPACE);
|
|
508
|
+
throw new Error('unexpected');
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
});
|
|
512
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
513
|
+
const result = await verifyLockfileSyncCommand();
|
|
514
|
+
expect(result.valid).toBe(false);
|
|
515
|
+
expect(result.reason).toMatch(/Tracked lockfile detected/);
|
|
516
|
+
});
|
|
517
|
+
it('fails when a NEW workspace-glob manifest adds a pin without the lockfile', async () => {
|
|
518
|
+
// `packages/new` is not yet an importer at HEAD (that is exactly the shape
|
|
519
|
+
// of a branch adding a package) but its directory matches the workspace
|
|
520
|
+
// `packages:` glob, so the lockfile companion is still required.
|
|
521
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
522
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
523
|
+
return {
|
|
524
|
+
...actual,
|
|
525
|
+
resolveGitRoot: () => '/repo',
|
|
526
|
+
getDefaultBranch: () => 'main',
|
|
527
|
+
safeExec: (_cmd, args) => {
|
|
528
|
+
if (args[0] === 'ls-files')
|
|
529
|
+
return 'pnpm-lock.yaml';
|
|
530
|
+
if (args[0] === 'diff' && args.includes('--name-only')) {
|
|
531
|
+
return 'packages/new/package.json';
|
|
532
|
+
}
|
|
533
|
+
if (args[0] === 'diff') {
|
|
534
|
+
expect(args).toContain('packages/new/package.json');
|
|
535
|
+
return NESTED_PKG_BUMP_DIFF;
|
|
536
|
+
}
|
|
537
|
+
if (args[0] === 'show')
|
|
538
|
+
return showAtHead(args, HEAD_LOCKFILE_WORKSPACE);
|
|
539
|
+
throw new Error('unexpected');
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
});
|
|
543
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
544
|
+
const result = await verifyLockfileSyncCommand();
|
|
545
|
+
expect(result.valid).toBe(false);
|
|
546
|
+
expect(result.failureClass).toBe('missing-lockfile');
|
|
547
|
+
});
|
|
548
|
+
it('honors `!` negations in the workspace globs (an excluded directory is not admitted)', async () => {
|
|
549
|
+
const workspaceWithNegation = `packages:
|
|
550
|
+
- 'packages/*'
|
|
551
|
+
- '!packages/legacy'
|
|
552
|
+
`;
|
|
553
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
554
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
555
|
+
return {
|
|
556
|
+
...actual,
|
|
557
|
+
resolveGitRoot: () => '/repo',
|
|
558
|
+
getDefaultBranch: () => 'main',
|
|
559
|
+
safeExec: (_cmd, args) => {
|
|
560
|
+
if (args[0] === 'ls-files')
|
|
561
|
+
return 'pnpm-lock.yaml';
|
|
562
|
+
if (args[0] === 'diff' && args.includes('--name-only')) {
|
|
563
|
+
return 'packages/legacy/package.json';
|
|
564
|
+
}
|
|
565
|
+
if (args[0] === 'show') {
|
|
566
|
+
return showAtHead(args, HEAD_LOCKFILE_WORKSPACE, {}, workspaceWithNegation);
|
|
567
|
+
}
|
|
568
|
+
// Reaching the unified-diff pull would mean the excluded directory
|
|
569
|
+
// was admitted.
|
|
570
|
+
throw new Error('unexpected');
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
});
|
|
574
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
575
|
+
const result = await verifyLockfileSyncCommand();
|
|
576
|
+
expect(result.valid).toBe(true);
|
|
577
|
+
});
|
|
578
|
+
it('admits the ROOT manifest even when the lockfile lists no root importer', async () => {
|
|
579
|
+
const noRootImporter = `lockfileVersion: '9.0'
|
|
580
|
+
|
|
581
|
+
importers:
|
|
582
|
+
|
|
583
|
+
packages/cli:
|
|
584
|
+
dependencies:
|
|
585
|
+
commander:
|
|
586
|
+
specifier: ^11.0.0
|
|
587
|
+
version: 11.0.0
|
|
588
|
+
|
|
589
|
+
packages:
|
|
590
|
+
|
|
591
|
+
commander@11.0.0:
|
|
592
|
+
resolution: {integrity: sha512-bbbb}
|
|
593
|
+
`;
|
|
594
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
595
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
596
|
+
return {
|
|
597
|
+
...actual,
|
|
598
|
+
resolveGitRoot: () => '/repo',
|
|
599
|
+
getDefaultBranch: () => 'main',
|
|
600
|
+
safeExec: (_cmd, args) => {
|
|
601
|
+
if (args[0] === 'ls-files')
|
|
602
|
+
return 'pnpm-lock.yaml';
|
|
603
|
+
if (args[0] === 'diff' && args.includes('--name-only'))
|
|
604
|
+
return 'package.json';
|
|
605
|
+
if (args[0] === 'diff')
|
|
606
|
+
return CARET_BUMP_DIFF;
|
|
607
|
+
if (args[0] === 'show')
|
|
608
|
+
return showAtHead(args, noRootImporter);
|
|
609
|
+
throw new Error('unexpected');
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
});
|
|
613
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
614
|
+
const result = await verifyLockfileSyncCommand();
|
|
615
|
+
expect(result.valid).toBe(false);
|
|
616
|
+
});
|
|
617
|
+
it('declares a skip of the glob admission when pnpm-workspace.yaml is unreadable at HEAD', async () => {
|
|
618
|
+
const stderr = [];
|
|
619
|
+
vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
620
|
+
stderr.push(args.map(String).join(' '));
|
|
621
|
+
});
|
|
622
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
623
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
624
|
+
return {
|
|
625
|
+
...actual,
|
|
626
|
+
resolveGitRoot: () => '/repo',
|
|
627
|
+
getDefaultBranch: () => 'main',
|
|
628
|
+
safeExec: (_cmd, args) => {
|
|
629
|
+
if (args[0] === 'ls-files')
|
|
630
|
+
return 'pnpm-lock.yaml';
|
|
631
|
+
if (args[0] === 'diff' && args.includes('--name-only')) {
|
|
632
|
+
return 'packages/new/package.json';
|
|
633
|
+
}
|
|
634
|
+
// No workspace file at HEAD → importer-set-only admission.
|
|
635
|
+
if (args[0] === 'show')
|
|
636
|
+
return showAtHead(args, HEAD_LOCKFILE_WORKSPACE, {}, null);
|
|
637
|
+
throw new Error('unexpected');
|
|
638
|
+
},
|
|
639
|
+
};
|
|
640
|
+
});
|
|
641
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
642
|
+
const result = await verifyLockfileSyncCommand();
|
|
643
|
+
expect(result.valid).toBe(true);
|
|
644
|
+
expect(stderr.join('\n')).toMatch(/pnpm-workspace\.yaml/);
|
|
645
|
+
});
|
|
136
646
|
it('passes when package.json diff contains only deletions', async () => {
|
|
137
647
|
vi.doMock('@mmnto/totem', async () => {
|
|
138
648
|
const actual = await vi.importActual('@mmnto/totem');
|
|
@@ -147,6 +657,8 @@ describe('verifyLockfileSyncCommand', () => {
|
|
|
147
657
|
return 'package.json';
|
|
148
658
|
if (args[0] === 'diff')
|
|
149
659
|
return DELETIONS_ONLY_DIFF;
|
|
660
|
+
if (args[0] === 'show')
|
|
661
|
+
return showAtHead(args, HEAD_LOCKFILE_WORKSPACE);
|
|
150
662
|
throw new Error('unexpected');
|
|
151
663
|
},
|
|
152
664
|
};
|
|
@@ -170,6 +682,8 @@ describe('verifyLockfileSyncCommand', () => {
|
|
|
170
682
|
}
|
|
171
683
|
if (args[0] === 'diff')
|
|
172
684
|
return VERSION_FIELD_ONLY_DIFF;
|
|
685
|
+
if (args[0] === 'show')
|
|
686
|
+
return showAtHead(args, HEAD_LOCKFILE_WORKSPACE);
|
|
173
687
|
throw new Error('unexpected');
|
|
174
688
|
},
|
|
175
689
|
};
|
|
@@ -193,6 +707,8 @@ describe('verifyLockfileSyncCommand', () => {
|
|
|
193
707
|
}
|
|
194
708
|
if (args[0] === 'diff')
|
|
195
709
|
return WORKSPACE_REF_DIFF;
|
|
710
|
+
if (args[0] === 'show')
|
|
711
|
+
return showAtHead(args, HEAD_LOCKFILE_WORKSPACE);
|
|
196
712
|
throw new Error('unexpected');
|
|
197
713
|
},
|
|
198
714
|
};
|
|
@@ -236,6 +752,8 @@ describe('verifyLockfileSyncCommand', () => {
|
|
|
236
752
|
return 'package.json';
|
|
237
753
|
if (args[0] === 'diff')
|
|
238
754
|
return CARET_BUMP_DIFF;
|
|
755
|
+
if (args[0] === 'show')
|
|
756
|
+
return showAtHead(args, HEAD_LOCKFILE_WORKSPACE);
|
|
239
757
|
throw new Error('unexpected');
|
|
240
758
|
},
|
|
241
759
|
};
|
|
@@ -266,6 +784,566 @@ describe('verifyLockfileSyncCommand', () => {
|
|
|
266
784
|
const result = await verifyLockfileSyncCommand();
|
|
267
785
|
expect(result.valid).toBe(true);
|
|
268
786
|
});
|
|
787
|
+
// ─── Removed-pin gate (mmnto-ai/totem-strategy#630) ───
|
|
788
|
+
it('fails when the lockfile diff drops every entry for a package still pinned at HEAD (live-fire positive control)', async () => {
|
|
789
|
+
const showRefs = [];
|
|
790
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
791
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
792
|
+
return {
|
|
793
|
+
...actual,
|
|
794
|
+
resolveGitRoot: () => '/repo',
|
|
795
|
+
getDefaultBranch: () => 'main',
|
|
796
|
+
safeExec: (_cmd, args) => {
|
|
797
|
+
if (args[0] === 'ls-files')
|
|
798
|
+
return 'pnpm-lock.yaml';
|
|
799
|
+
if (args[0] === 'diff' && args.includes('--name-only'))
|
|
800
|
+
return 'pnpm-lock.yaml';
|
|
801
|
+
if (args[0] === 'diff')
|
|
802
|
+
return DOCTRINE_PIN_REMOVAL_DIFF;
|
|
803
|
+
if (args[0] === 'show') {
|
|
804
|
+
showRefs.push(args[1] ?? '');
|
|
805
|
+
return showAtHead(args, HEAD_LOCKFILE_WITHOUT_DOCTRINE, HEAD_PACKAGE_JSONS);
|
|
806
|
+
}
|
|
807
|
+
throw new Error('unexpected');
|
|
808
|
+
},
|
|
809
|
+
};
|
|
810
|
+
});
|
|
811
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
812
|
+
const result = await verifyLockfileSyncCommand();
|
|
813
|
+
expect(result.valid).toBe(false);
|
|
814
|
+
expect(result.failureClass).toBe('removed-pin');
|
|
815
|
+
expect(result.reason).toMatch(/@mmnto\/strategy-doctrine/);
|
|
816
|
+
// The declaration probe reads the ROOT importer's manifest at HEAD — the
|
|
817
|
+
// lockfile's own answer to "which manifests do I resolve?".
|
|
818
|
+
expect(showRefs).toContain('HEAD:pnpm-lock.yaml');
|
|
819
|
+
expect(showRefs).toContain('HEAD:package.json');
|
|
820
|
+
});
|
|
821
|
+
it('counts an optionalDependencies declaration in a NESTED importer manifest as declared', async () => {
|
|
822
|
+
const nestedOnly = {
|
|
823
|
+
'package.json': `{ "name": "totem" }`,
|
|
824
|
+
'packages/app/package.json': `{
|
|
825
|
+
"name": "@acme/app",
|
|
826
|
+
"optionalDependencies": {
|
|
827
|
+
"@mmnto/strategy-doctrine": "0.1.28"
|
|
828
|
+
}
|
|
829
|
+
}`,
|
|
830
|
+
};
|
|
831
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
832
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
833
|
+
return {
|
|
834
|
+
...actual,
|
|
835
|
+
resolveGitRoot: () => '/repo',
|
|
836
|
+
getDefaultBranch: () => 'main',
|
|
837
|
+
safeExec: (_cmd, args) => {
|
|
838
|
+
if (args[0] === 'ls-files')
|
|
839
|
+
return 'pnpm-lock.yaml';
|
|
840
|
+
if (args[0] === 'diff' && args.includes('--name-only'))
|
|
841
|
+
return 'pnpm-lock.yaml';
|
|
842
|
+
if (args[0] === 'diff')
|
|
843
|
+
return DOCTRINE_PIN_REMOVAL_DIFF;
|
|
844
|
+
if (args[0] === 'show') {
|
|
845
|
+
return showAtHead(args, HEAD_LOCKFILE_WITH_APP_IMPORTER, nestedOnly);
|
|
846
|
+
}
|
|
847
|
+
throw new Error('unexpected');
|
|
848
|
+
},
|
|
849
|
+
};
|
|
850
|
+
});
|
|
851
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
852
|
+
const result = await verifyLockfileSyncCommand();
|
|
853
|
+
expect(result.valid).toBe(false);
|
|
854
|
+
expect(result.reason).toMatch(/@mmnto\/strategy-doctrine/);
|
|
855
|
+
});
|
|
856
|
+
it('passes when a NON-importer manifest declares a dropped transitive dep (services/ repro)', async () => {
|
|
857
|
+
const showRefs = [];
|
|
858
|
+
const manifests = {
|
|
859
|
+
'package.json': `{
|
|
860
|
+
"name": "totem",
|
|
861
|
+
"dependencies": {
|
|
862
|
+
"@modelcontextprotocol/sdk": "^1.28.0"
|
|
863
|
+
}
|
|
864
|
+
}`,
|
|
865
|
+
// Not a workspace importer: this lockfile never resolved its pins.
|
|
866
|
+
'services/compile-worker/package.json': `{
|
|
867
|
+
"name": "compile-worker",
|
|
868
|
+
"dependencies": {
|
|
869
|
+
"hono": "^4.12.3"
|
|
870
|
+
}
|
|
871
|
+
}`,
|
|
872
|
+
};
|
|
873
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
874
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
875
|
+
return {
|
|
876
|
+
...actual,
|
|
877
|
+
resolveGitRoot: () => '/repo',
|
|
878
|
+
getDefaultBranch: () => 'main',
|
|
879
|
+
safeExec: (_cmd, args) => {
|
|
880
|
+
if (args[0] === 'ls-files')
|
|
881
|
+
return 'pnpm-lock.yaml';
|
|
882
|
+
if (args[0] === 'diff' && args.includes('--name-only'))
|
|
883
|
+
return 'pnpm-lock.yaml';
|
|
884
|
+
if (args[0] === 'diff')
|
|
885
|
+
return HONO_DROP_DIFF;
|
|
886
|
+
if (args[0] === 'show') {
|
|
887
|
+
showRefs.push(args[1] ?? '');
|
|
888
|
+
return showAtHead(args, HEAD_LOCKFILE_WITHOUT_HONO, manifests);
|
|
889
|
+
}
|
|
890
|
+
throw new Error('unexpected');
|
|
891
|
+
},
|
|
892
|
+
};
|
|
893
|
+
});
|
|
894
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
895
|
+
const result = await verifyLockfileSyncCommand();
|
|
896
|
+
expect(result.valid).toBe(true);
|
|
897
|
+
// The non-importer manifest is never even consulted.
|
|
898
|
+
expect(showRefs).not.toContain('HEAD:services/compile-worker/package.json');
|
|
899
|
+
});
|
|
900
|
+
it('fires when the dropped package survives at HEAD only as a peer-metadata child', async () => {
|
|
901
|
+
const manifests = {
|
|
902
|
+
'package.json': `{
|
|
903
|
+
"name": "totem",
|
|
904
|
+
"dependencies": {
|
|
905
|
+
"zod": "^3.25.76",
|
|
906
|
+
"ajv": "^8.18.0"
|
|
907
|
+
}
|
|
908
|
+
}`,
|
|
909
|
+
};
|
|
910
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
911
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
912
|
+
return {
|
|
913
|
+
...actual,
|
|
914
|
+
resolveGitRoot: () => '/repo',
|
|
915
|
+
getDefaultBranch: () => 'main',
|
|
916
|
+
safeExec: (_cmd, args) => {
|
|
917
|
+
if (args[0] === 'ls-files')
|
|
918
|
+
return 'pnpm-lock.yaml';
|
|
919
|
+
if (args[0] === 'diff' && args.includes('--name-only'))
|
|
920
|
+
return 'pnpm-lock.yaml';
|
|
921
|
+
if (args[0] === 'diff')
|
|
922
|
+
return ZOD_PIN_REMOVAL_DIFF;
|
|
923
|
+
if (args[0] === 'show') {
|
|
924
|
+
return showAtHead(args, HEAD_LOCKFILE_ZOD_ONLY_IN_PEER_META, manifests);
|
|
925
|
+
}
|
|
926
|
+
throw new Error('unexpected');
|
|
927
|
+
},
|
|
928
|
+
};
|
|
929
|
+
});
|
|
930
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
931
|
+
const result = await verifyLockfileSyncCommand();
|
|
932
|
+
expect(result.valid).toBe(false);
|
|
933
|
+
expect(result.reason).toMatch(/zod/);
|
|
934
|
+
});
|
|
935
|
+
it('fires even when the diff ADDS a peer-metadata line naming the dropped package', async () => {
|
|
936
|
+
const manifests = {
|
|
937
|
+
'package.json': `{
|
|
938
|
+
"name": "totem",
|
|
939
|
+
"dependencies": {
|
|
940
|
+
"zod": "^3.25.76",
|
|
941
|
+
"ajv": "^8.18.0"
|
|
942
|
+
}
|
|
943
|
+
}`,
|
|
944
|
+
};
|
|
945
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
946
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
947
|
+
return {
|
|
948
|
+
...actual,
|
|
949
|
+
resolveGitRoot: () => '/repo',
|
|
950
|
+
getDefaultBranch: () => 'main',
|
|
951
|
+
safeExec: (_cmd, args) => {
|
|
952
|
+
if (args[0] === 'ls-files')
|
|
953
|
+
return 'pnpm-lock.yaml';
|
|
954
|
+
if (args[0] === 'diff' && args.includes('--name-only'))
|
|
955
|
+
return 'pnpm-lock.yaml';
|
|
956
|
+
if (args[0] === 'diff')
|
|
957
|
+
return ZOD_PIN_REMOVAL_WITH_ADDED_PEER_META_DIFF;
|
|
958
|
+
if (args[0] === 'show') {
|
|
959
|
+
return showAtHead(args, HEAD_LOCKFILE_ZOD_ONLY_IN_PEER_META, manifests);
|
|
960
|
+
}
|
|
961
|
+
throw new Error('unexpected');
|
|
962
|
+
},
|
|
963
|
+
};
|
|
964
|
+
});
|
|
965
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
966
|
+
const result = await verifyLockfileSyncCommand();
|
|
967
|
+
expect(result.valid).toBe(false);
|
|
968
|
+
expect(result.reason).toMatch(/zod/);
|
|
969
|
+
});
|
|
970
|
+
it('fires on a MASS removal that hides one genuinely dropped, still-declared pin', async () => {
|
|
971
|
+
// The old candidate cap waived the gate on any large diff, so a real drop
|
|
972
|
+
// could ride in behind an ordinary big update. Per-candidate cost is now an
|
|
973
|
+
// O(1) lookup against one lockfile index, so volume buys nothing but false
|
|
974
|
+
// negatives.
|
|
975
|
+
const massRemoval = [
|
|
976
|
+
'diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml',
|
|
977
|
+
'--- a/pnpm-lock.yaml',
|
|
978
|
+
'+++ b/pnpm-lock.yaml',
|
|
979
|
+
'@@ -1,2000 +1,1 @@ packages:',
|
|
980
|
+
...Array.from({ length: 900 }, (_v, i) => `- pkg-${i}@1.0.0:`),
|
|
981
|
+
"- '@acme/dropped@2.0.0':",
|
|
982
|
+
'- resolution: {integrity: sha512-aaaa}',
|
|
983
|
+
].join('\n');
|
|
984
|
+
const manifests = {
|
|
985
|
+
'package.json': `{
|
|
986
|
+
"name": "totem",
|
|
987
|
+
"dependencies": {
|
|
988
|
+
"@acme/dropped": "^2.0.0"
|
|
989
|
+
}
|
|
990
|
+
}`,
|
|
991
|
+
};
|
|
992
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
993
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
994
|
+
return {
|
|
995
|
+
...actual,
|
|
996
|
+
resolveGitRoot: () => '/repo',
|
|
997
|
+
getDefaultBranch: () => 'main',
|
|
998
|
+
safeExec: (_cmd, args) => {
|
|
999
|
+
if (args[0] === 'ls-files')
|
|
1000
|
+
return 'pnpm-lock.yaml';
|
|
1001
|
+
if (args[0] === 'diff' && args.includes('--name-only'))
|
|
1002
|
+
return 'pnpm-lock.yaml';
|
|
1003
|
+
if (args[0] === 'diff')
|
|
1004
|
+
return massRemoval;
|
|
1005
|
+
if (args[0] === 'show') {
|
|
1006
|
+
return showAtHead(args, HEAD_LOCKFILE_WITHOUT_DOCTRINE, manifests);
|
|
1007
|
+
}
|
|
1008
|
+
throw new Error('unexpected');
|
|
1009
|
+
},
|
|
1010
|
+
};
|
|
1011
|
+
});
|
|
1012
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
1013
|
+
const result = await verifyLockfileSyncCommand();
|
|
1014
|
+
expect(result.valid).toBe(false);
|
|
1015
|
+
expect(result.failureClass).toBe('removed-pin');
|
|
1016
|
+
expect(result.reason).toMatch(/@acme\/dropped/);
|
|
1017
|
+
// The 900 undeclared removals are not named — only the declared one is.
|
|
1018
|
+
expect(result.reason).not.toMatch(/pkg-500/);
|
|
1019
|
+
});
|
|
1020
|
+
it('canonicalizes leading-slash keys emitted by old-vintage (v5/v6) lockfiles', async () => {
|
|
1021
|
+
const v6FormRemoval = `diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
|
|
1022
|
+
--- a/pnpm-lock.yaml
|
|
1023
|
+
+++ b/pnpm-lock.yaml
|
|
1024
|
+
@@ -1200,6 +1200,1 @@ packages:
|
|
1025
|
+
- /ajv@8.18.0:
|
|
1026
|
+
- resolution: {integrity: sha512-aaaa}
|
|
1027
|
+
- '/@scope/name@1.0.0':
|
|
1028
|
+
- resolution: {integrity: sha512-bbbb}
|
|
1029
|
+
`;
|
|
1030
|
+
const manifests = {
|
|
1031
|
+
'package.json': `{
|
|
1032
|
+
"name": "totem",
|
|
1033
|
+
"dependencies": {
|
|
1034
|
+
"ajv": "^8.18.0",
|
|
1035
|
+
"@scope/name": "^1.0.0"
|
|
1036
|
+
}
|
|
1037
|
+
}`,
|
|
1038
|
+
};
|
|
1039
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
1040
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
1041
|
+
return {
|
|
1042
|
+
...actual,
|
|
1043
|
+
resolveGitRoot: () => '/repo',
|
|
1044
|
+
getDefaultBranch: () => 'main',
|
|
1045
|
+
safeExec: (_cmd, args) => {
|
|
1046
|
+
if (args[0] === 'ls-files')
|
|
1047
|
+
return 'pnpm-lock.yaml';
|
|
1048
|
+
if (args[0] === 'diff' && args.includes('--name-only'))
|
|
1049
|
+
return 'pnpm-lock.yaml';
|
|
1050
|
+
if (args[0] === 'diff')
|
|
1051
|
+
return v6FormRemoval;
|
|
1052
|
+
if (args[0] === 'show') {
|
|
1053
|
+
return showAtHead(args, HEAD_LOCKFILE_WITHOUT_DOCTRINE, manifests);
|
|
1054
|
+
}
|
|
1055
|
+
throw new Error('unexpected');
|
|
1056
|
+
},
|
|
1057
|
+
};
|
|
1058
|
+
});
|
|
1059
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
1060
|
+
const result = await verifyLockfileSyncCommand();
|
|
1061
|
+
expect(result.valid).toBe(false);
|
|
1062
|
+
// Both key forms canonicalize to the manifest's own spelling.
|
|
1063
|
+
expect(result.reason).toMatch(/(^|[^/])ajv/);
|
|
1064
|
+
expect(result.reason).toMatch(/@scope\/name/);
|
|
1065
|
+
expect(result.reason).not.toMatch(/\/ajv/);
|
|
1066
|
+
expect(result.reason).not.toMatch(/\/@scope/);
|
|
1067
|
+
});
|
|
1068
|
+
it('passes on a pure version bump (the name still resolves at HEAD)', async () => {
|
|
1069
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
1070
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
1071
|
+
return {
|
|
1072
|
+
...actual,
|
|
1073
|
+
resolveGitRoot: () => '/repo',
|
|
1074
|
+
getDefaultBranch: () => 'main',
|
|
1075
|
+
safeExec: (_cmd, args) => {
|
|
1076
|
+
if (args[0] === 'ls-files')
|
|
1077
|
+
return 'pnpm-lock.yaml';
|
|
1078
|
+
if (args[0] === 'diff' && args.includes('--name-only'))
|
|
1079
|
+
return 'pnpm-lock.yaml';
|
|
1080
|
+
if (args[0] === 'diff')
|
|
1081
|
+
return DOCTRINE_VERSION_BUMP_DIFF;
|
|
1082
|
+
if (args[0] === 'show') {
|
|
1083
|
+
return showAtHead(args, HEAD_LOCKFILE_WITH_DOCTRINE_29, HEAD_PACKAGE_JSONS);
|
|
1084
|
+
}
|
|
1085
|
+
throw new Error('unexpected');
|
|
1086
|
+
},
|
|
1087
|
+
};
|
|
1088
|
+
});
|
|
1089
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
1090
|
+
const result = await verifyLockfileSyncCommand();
|
|
1091
|
+
expect(result.valid).toBe(true);
|
|
1092
|
+
});
|
|
1093
|
+
it('passes on a legitimate removal (the declaration is gone from the importer manifest at HEAD)', async () => {
|
|
1094
|
+
const withoutDeclaration = { 'package.json': `{ "name": "totem", "dependencies": {} }` };
|
|
1095
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
1096
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
1097
|
+
return {
|
|
1098
|
+
...actual,
|
|
1099
|
+
resolveGitRoot: () => '/repo',
|
|
1100
|
+
getDefaultBranch: () => 'main',
|
|
1101
|
+
safeExec: (_cmd, args) => {
|
|
1102
|
+
if (args[0] === 'ls-files')
|
|
1103
|
+
return 'pnpm-lock.yaml';
|
|
1104
|
+
if (args[0] === 'diff' && args.includes('--name-only'))
|
|
1105
|
+
return 'pnpm-lock.yaml';
|
|
1106
|
+
if (args[0] === 'diff')
|
|
1107
|
+
return DOCTRINE_PIN_REMOVAL_DIFF;
|
|
1108
|
+
if (args[0] === 'show') {
|
|
1109
|
+
return showAtHead(args, HEAD_LOCKFILE_WITHOUT_DOCTRINE, withoutDeclaration);
|
|
1110
|
+
}
|
|
1111
|
+
throw new Error('unexpected');
|
|
1112
|
+
},
|
|
1113
|
+
};
|
|
1114
|
+
});
|
|
1115
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
1116
|
+
const result = await verifyLockfileSyncCommand();
|
|
1117
|
+
expect(result.valid).toBe(true);
|
|
1118
|
+
});
|
|
1119
|
+
it('passes on a dedupe (the removed key is gone but the name still resolves at HEAD)', async () => {
|
|
1120
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
1121
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
1122
|
+
return {
|
|
1123
|
+
...actual,
|
|
1124
|
+
resolveGitRoot: () => '/repo',
|
|
1125
|
+
getDefaultBranch: () => 'main',
|
|
1126
|
+
safeExec: (_cmd, args) => {
|
|
1127
|
+
if (args[0] === 'ls-files')
|
|
1128
|
+
return 'pnpm-lock.yaml';
|
|
1129
|
+
if (args[0] === 'diff' && args.includes('--name-only'))
|
|
1130
|
+
return 'pnpm-lock.yaml';
|
|
1131
|
+
if (args[0] === 'diff')
|
|
1132
|
+
return DEDUPE_DIFF;
|
|
1133
|
+
// Still declared — only the HEAD-lockfile resolution saves this case.
|
|
1134
|
+
if (args[0] === 'show')
|
|
1135
|
+
return showAtHead(args, HEAD_LOCKFILE_WITH_AJV_8, HEAD_PACKAGE_JSONS);
|
|
1136
|
+
throw new Error('unexpected');
|
|
1137
|
+
},
|
|
1138
|
+
};
|
|
1139
|
+
});
|
|
1140
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
1141
|
+
const result = await verifyLockfileSyncCommand();
|
|
1142
|
+
expect(result.valid).toBe(true);
|
|
1143
|
+
});
|
|
1144
|
+
it('passes when the removed name only collides with a MANIFEST key (`type` vs "type": "module")', async () => {
|
|
1145
|
+
// `type` is a real npm package AND a package.json manifest key: membership
|
|
1146
|
+
// must be decided inside the dependency blocks, never by a text match.
|
|
1147
|
+
const typeRemovalDiff = `diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
|
|
1148
|
+
--- a/pnpm-lock.yaml
|
|
1149
|
+
+++ b/pnpm-lock.yaml
|
|
1150
|
+
@@ -1200,4 +1200,1 @@ packages:
|
|
1151
|
+
- type@2.7.3:
|
|
1152
|
+
- resolution: {integrity: sha512-aaaa}
|
|
1153
|
+
-
|
|
1154
|
+
@@ -4100,4 +4097,1 @@ snapshots:
|
|
1155
|
+
- type@2.7.3: {}
|
|
1156
|
+
-
|
|
1157
|
+
`;
|
|
1158
|
+
const manifestsWithTypeField = {
|
|
1159
|
+
'package.json': `{
|
|
1160
|
+
"name": "totem",
|
|
1161
|
+
"type": "module",
|
|
1162
|
+
"dependencies": {
|
|
1163
|
+
"ajv": "^8.18.0"
|
|
1164
|
+
}
|
|
1165
|
+
}`,
|
|
1166
|
+
};
|
|
1167
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
1168
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
1169
|
+
return {
|
|
1170
|
+
...actual,
|
|
1171
|
+
resolveGitRoot: () => '/repo',
|
|
1172
|
+
getDefaultBranch: () => 'main',
|
|
1173
|
+
safeExec: (_cmd, args) => {
|
|
1174
|
+
if (args[0] === 'ls-files')
|
|
1175
|
+
return 'pnpm-lock.yaml';
|
|
1176
|
+
if (args[0] === 'diff' && args.includes('--name-only'))
|
|
1177
|
+
return 'pnpm-lock.yaml';
|
|
1178
|
+
if (args[0] === 'diff')
|
|
1179
|
+
return typeRemovalDiff;
|
|
1180
|
+
if (args[0] === 'show') {
|
|
1181
|
+
return showAtHead(args, HEAD_LOCKFILE_WITHOUT_DOCTRINE, manifestsWithTypeField);
|
|
1182
|
+
}
|
|
1183
|
+
throw new Error('unexpected');
|
|
1184
|
+
},
|
|
1185
|
+
};
|
|
1186
|
+
});
|
|
1187
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
1188
|
+
const result = await verifyLockfileSyncCommand();
|
|
1189
|
+
expect(result.valid).toBe(true);
|
|
1190
|
+
});
|
|
1191
|
+
it('does not count an `overrides`-only pin as a dependency declaration', async () => {
|
|
1192
|
+
const overridesOnly = {
|
|
1193
|
+
'package.json': `{
|
|
1194
|
+
"name": "totem",
|
|
1195
|
+
"pnpm": {
|
|
1196
|
+
"overrides": {
|
|
1197
|
+
"@mmnto/strategy-doctrine": "0.1.28"
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}`,
|
|
1201
|
+
};
|
|
1202
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
1203
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
1204
|
+
return {
|
|
1205
|
+
...actual,
|
|
1206
|
+
resolveGitRoot: () => '/repo',
|
|
1207
|
+
getDefaultBranch: () => 'main',
|
|
1208
|
+
safeExec: (_cmd, args) => {
|
|
1209
|
+
if (args[0] === 'ls-files')
|
|
1210
|
+
return 'pnpm-lock.yaml';
|
|
1211
|
+
if (args[0] === 'diff' && args.includes('--name-only'))
|
|
1212
|
+
return 'pnpm-lock.yaml';
|
|
1213
|
+
if (args[0] === 'diff')
|
|
1214
|
+
return DOCTRINE_PIN_REMOVAL_DIFF;
|
|
1215
|
+
if (args[0] === 'show') {
|
|
1216
|
+
return showAtHead(args, HEAD_LOCKFILE_WITHOUT_DOCTRINE, overridesOnly);
|
|
1217
|
+
}
|
|
1218
|
+
throw new Error('unexpected');
|
|
1219
|
+
},
|
|
1220
|
+
};
|
|
1221
|
+
});
|
|
1222
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
1223
|
+
const result = await verifyLockfileSyncCommand();
|
|
1224
|
+
expect(result.valid).toBe(true);
|
|
1225
|
+
});
|
|
1226
|
+
it('declares a skip for an importer manifest that cannot be parsed at HEAD', async () => {
|
|
1227
|
+
const stderr = [];
|
|
1228
|
+
vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
1229
|
+
stderr.push(args.map(String).join(' '));
|
|
1230
|
+
});
|
|
1231
|
+
const brokenManifest = {
|
|
1232
|
+
'package.json': `{ "optionalDependencies": { "@mmnto/strategy-doctrine": "0.1.28" `,
|
|
1233
|
+
};
|
|
1234
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
1235
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
1236
|
+
return {
|
|
1237
|
+
...actual,
|
|
1238
|
+
resolveGitRoot: () => '/repo',
|
|
1239
|
+
getDefaultBranch: () => 'main',
|
|
1240
|
+
safeExec: (_cmd, args) => {
|
|
1241
|
+
if (args[0] === 'ls-files')
|
|
1242
|
+
return 'pnpm-lock.yaml';
|
|
1243
|
+
if (args[0] === 'diff' && args.includes('--name-only'))
|
|
1244
|
+
return 'pnpm-lock.yaml';
|
|
1245
|
+
if (args[0] === 'diff')
|
|
1246
|
+
return DOCTRINE_PIN_REMOVAL_DIFF;
|
|
1247
|
+
if (args[0] === 'show') {
|
|
1248
|
+
return showAtHead(args, HEAD_LOCKFILE_WITHOUT_DOCTRINE, brokenManifest);
|
|
1249
|
+
}
|
|
1250
|
+
throw new Error('unexpected');
|
|
1251
|
+
},
|
|
1252
|
+
};
|
|
1253
|
+
});
|
|
1254
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
1255
|
+
const result = await verifyLockfileSyncCommand();
|
|
1256
|
+
expect(result.valid).toBe(true);
|
|
1257
|
+
// The skip is DECLARED — never silent.
|
|
1258
|
+
expect(stderr.join('\n')).toMatch(/package\.json/);
|
|
1259
|
+
});
|
|
1260
|
+
it('passes when the diff removes a lockfile GRAMMAR key (last devDependency dropped)', async () => {
|
|
1261
|
+
const lastDevDepDiff = `diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
|
|
1262
|
+
--- a/pnpm-lock.yaml
|
|
1263
|
+
+++ b/pnpm-lock.yaml
|
|
1264
|
+
@@ -10,6 +10,1 @@ importers:
|
|
1265
|
+
.:
|
|
1266
|
+
- devDependencies:
|
|
1267
|
+
- vitest:
|
|
1268
|
+
- specifier: ^4.0.0
|
|
1269
|
+
- version: 4.0.0
|
|
1270
|
+
`;
|
|
1271
|
+
const headPkgKeepsEmptyBlock = {
|
|
1272
|
+
'package.json': `{
|
|
1273
|
+
"name": "totem",
|
|
1274
|
+
"devDependencies": {}
|
|
1275
|
+
}`,
|
|
1276
|
+
};
|
|
1277
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
1278
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
1279
|
+
return {
|
|
1280
|
+
...actual,
|
|
1281
|
+
resolveGitRoot: () => '/repo',
|
|
1282
|
+
getDefaultBranch: () => 'main',
|
|
1283
|
+
safeExec: (_cmd, args) => {
|
|
1284
|
+
if (args[0] === 'ls-files')
|
|
1285
|
+
return 'pnpm-lock.yaml';
|
|
1286
|
+
if (args[0] === 'diff' && args.includes('--name-only'))
|
|
1287
|
+
return 'pnpm-lock.yaml';
|
|
1288
|
+
if (args[0] === 'diff')
|
|
1289
|
+
return lastDevDepDiff;
|
|
1290
|
+
if (args[0] === 'show') {
|
|
1291
|
+
return showAtHead(args, HEAD_LOCKFILE_WITHOUT_DOCTRINE, headPkgKeepsEmptyBlock);
|
|
1292
|
+
}
|
|
1293
|
+
throw new Error('unexpected');
|
|
1294
|
+
},
|
|
1295
|
+
};
|
|
1296
|
+
});
|
|
1297
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
1298
|
+
const result = await verifyLockfileSyncCommand();
|
|
1299
|
+
expect(result.valid).toBe(true);
|
|
1300
|
+
});
|
|
1301
|
+
it('falls through to pass when the lockfile diff read fails (declared skip)', async () => {
|
|
1302
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
1303
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
1304
|
+
return {
|
|
1305
|
+
...actual,
|
|
1306
|
+
resolveGitRoot: () => '/repo',
|
|
1307
|
+
getDefaultBranch: () => 'main',
|
|
1308
|
+
safeExec: (_cmd, args) => {
|
|
1309
|
+
if (args[0] === 'ls-files')
|
|
1310
|
+
return 'pnpm-lock.yaml';
|
|
1311
|
+
if (args[0] === 'diff' && args.includes('--name-only'))
|
|
1312
|
+
return 'pnpm-lock.yaml';
|
|
1313
|
+
if (args[0] === 'diff')
|
|
1314
|
+
throw new Error('fatal: bad object');
|
|
1315
|
+
throw new Error('unexpected');
|
|
1316
|
+
},
|
|
1317
|
+
};
|
|
1318
|
+
});
|
|
1319
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
1320
|
+
const result = await verifyLockfileSyncCommand();
|
|
1321
|
+
expect(result.valid).toBe(true);
|
|
1322
|
+
});
|
|
1323
|
+
it('falls through to pass when the HEAD-lockfile probe fails (declared skip)', async () => {
|
|
1324
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
1325
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
1326
|
+
return {
|
|
1327
|
+
...actual,
|
|
1328
|
+
resolveGitRoot: () => '/repo',
|
|
1329
|
+
getDefaultBranch: () => 'main',
|
|
1330
|
+
safeExec: (_cmd, args) => {
|
|
1331
|
+
if (args[0] === 'ls-files')
|
|
1332
|
+
return 'pnpm-lock.yaml';
|
|
1333
|
+
if (args[0] === 'diff' && args.includes('--name-only'))
|
|
1334
|
+
return 'pnpm-lock.yaml';
|
|
1335
|
+
if (args[0] === 'diff')
|
|
1336
|
+
return DOCTRINE_PIN_REMOVAL_DIFF;
|
|
1337
|
+
if (args[0] === 'show')
|
|
1338
|
+
throw new Error('fatal: path does not exist in HEAD');
|
|
1339
|
+
throw new Error('unexpected');
|
|
1340
|
+
},
|
|
1341
|
+
};
|
|
1342
|
+
});
|
|
1343
|
+
const { verifyLockfileSyncCommand } = await import('./verify-lockfile-sync.js');
|
|
1344
|
+
const result = await verifyLockfileSyncCommand();
|
|
1345
|
+
expect(result.valid).toBe(true);
|
|
1346
|
+
});
|
|
269
1347
|
it('falls through to pass when both origin/<base> and local <base> diff lookups fail', async () => {
|
|
270
1348
|
vi.doMock('@mmnto/totem', async () => {
|
|
271
1349
|
const actual = await vi.importActual('@mmnto/totem');
|
|
@@ -305,6 +1383,8 @@ describe('verifyLockfileSyncCliCommand', () => {
|
|
|
305
1383
|
return 'package.json';
|
|
306
1384
|
if (args[0] === 'diff')
|
|
307
1385
|
return CARET_BUMP_DIFF;
|
|
1386
|
+
if (args[0] === 'show')
|
|
1387
|
+
return showAtHead(args, HEAD_LOCKFILE_WORKSPACE);
|
|
308
1388
|
throw new Error('unexpected');
|
|
309
1389
|
},
|
|
310
1390
|
};
|
|
@@ -322,6 +1402,38 @@ describe('verifyLockfileSyncCliCommand', () => {
|
|
|
322
1402
|
expect(err.recoveryHint).toMatch(/pnpm install/);
|
|
323
1403
|
}
|
|
324
1404
|
});
|
|
1405
|
+
it('carries the removed-pin recovery hint (distinct from the pin-add hint) on the #630 failure class', async () => {
|
|
1406
|
+
vi.doMock('@mmnto/totem', async () => {
|
|
1407
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
1408
|
+
return {
|
|
1409
|
+
...actual,
|
|
1410
|
+
resolveGitRoot: () => '/repo',
|
|
1411
|
+
getDefaultBranch: () => 'main',
|
|
1412
|
+
safeExec: (_cmd, args) => {
|
|
1413
|
+
if (args[0] === 'ls-files')
|
|
1414
|
+
return 'pnpm-lock.yaml';
|
|
1415
|
+
if (args[0] === 'diff' && args.includes('--name-only'))
|
|
1416
|
+
return 'pnpm-lock.yaml';
|
|
1417
|
+
if (args[0] === 'diff')
|
|
1418
|
+
return DOCTRINE_PIN_REMOVAL_DIFF;
|
|
1419
|
+
if (args[0] === 'show') {
|
|
1420
|
+
return showAtHead(args, HEAD_LOCKFILE_WITHOUT_DOCTRINE, HEAD_PACKAGE_JSONS);
|
|
1421
|
+
}
|
|
1422
|
+
throw new Error('unexpected');
|
|
1423
|
+
},
|
|
1424
|
+
};
|
|
1425
|
+
});
|
|
1426
|
+
const { verifyLockfileSyncCliCommand } = await import('./verify-lockfile-sync.js');
|
|
1427
|
+
const thrown = await verifyLockfileSyncCliCommand().then(() => null, (err) => err);
|
|
1428
|
+
expect(thrown).toBeInstanceOf(Error);
|
|
1429
|
+
const hint = thrown.recoveryHint ?? '';
|
|
1430
|
+
expect(hint).toMatch(/npm whoami/);
|
|
1431
|
+
expect(hint).toMatch(/pnpm update/);
|
|
1432
|
+
expect(hint).toMatch(/--lockfile-only/);
|
|
1433
|
+
// The two failure classes must not share a remedy: this one must NOT send
|
|
1434
|
+
// the user to the plain `pnpm install` the pin-add class prescribes.
|
|
1435
|
+
expect(hint).not.toMatch(/Run `pnpm install` from the repo root/);
|
|
1436
|
+
});
|
|
325
1437
|
it('resolves cleanly when the underlying check passes', async () => {
|
|
326
1438
|
vi.doMock('@mmnto/totem', async () => {
|
|
327
1439
|
const actual = await vi.importActual('@mmnto/totem');
|