checkly 8.23.0 → 8.23.1

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.
@@ -1,6 +1,7 @@
1
1
  import { isDeepStrictEqual } from 'node:util';
2
2
  import Debug from 'debug';
3
- import { parsePackageNamePattern, patternsSelectName, } from '../embedded-packages/spec.js';
3
+ import { quotedList } from '../embedded-packages/diagnostics.js';
4
+ import { parsePackageNamePattern, patternsSelectName, specMatchesPackageName, } from '../embedded-packages/spec.js';
4
5
  const debug = Debug('checkly:cli:services:check-parser:package-prune');
5
6
  export const DEPENDENCY_CLASSES = [
6
7
  'dependencies',
@@ -9,35 +10,41 @@ export const DEPENDENCY_CLASSES = [
9
10
  'optionalDependencies',
10
11
  ];
11
12
  const SHAPE_ERROR = `must be an array of package name patterns or an object keyed by dependency class`;
13
+ const ENTRY_SHAPE_ERROR = `each entry must be a package name pattern`
14
+ + ` or a member-scoped object with 'member' and one of 'remove' or 'keep'`;
15
+ const CATCH_ALL_PATTERN = parsePackageNamePattern('**');
16
+ function isPlainObject(value) {
17
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
18
+ return false;
19
+ }
20
+ // A non-plain object (a Set, a Map, a Date) has no own enumerable
21
+ // string keys, so without this check it would silently normalize to
22
+ // "nothing to do" instead of being rejected.
23
+ const proto = Object.getPrototypeOf(value);
24
+ return proto === Object.prototype || proto === null;
25
+ }
12
26
  /**
13
- * Validates and normalizes a `bundle.packages.prune` value. Returns
14
- * `undefined` when there is nothing to do the value is absent, or every
15
- * shape it carries is empty. Throws `InvalidPackageNamePatternError` on an
16
- * invalid pattern and a plain `Error` on an invalid shape; the config
17
- * loader relies on that to reject plain-JS configs that bypass the
18
- * TypeScript type.
27
+ * Parses one element of a pattern list. A plain-object element gets a
28
+ * pointed rejection: the likely mistake is a member-scoped entry nested
29
+ * inside a class-keyed map or another entry's selection, which would
30
+ * otherwise fail as an unreadable `'[object Object]' is not a valid
31
+ * pattern`. Anything else arrays included, which the hint would only
32
+ * misdirect — falls through to the generic pattern error.
19
33
  */
20
- export function normalizePackagePrune(raw) {
21
- if (raw === undefined) {
22
- return undefined;
23
- }
24
- let perClass;
25
- if (Array.isArray(raw)) {
26
- perClass = Object.fromEntries(DEPENDENCY_CLASSES.map(dependencyClass => [dependencyClass, raw]));
27
- }
28
- else if (raw !== null && typeof raw === 'object') {
29
- // A non-plain object (a Set, a Map, a Date) has no own enumerable
30
- // string keys, so without this check it would silently normalize to
31
- // "nothing to do" instead of being rejected.
32
- const proto = Object.getPrototypeOf(raw);
33
- if (proto !== Object.prototype && proto !== null) {
34
- throw new Error(SHAPE_ERROR);
35
- }
36
- perClass = raw;
37
- }
38
- else {
39
- throw new Error(SHAPE_ERROR);
34
+ function parseNamePatternEntry(entry, context) {
35
+ if (isPlainObject(entry)) {
36
+ throw new Error(`'${context}' entries must be package name patterns`
37
+ + ` (member-scoped objects are only valid in the top-level prune array)`);
40
38
  }
39
+ return parsePackageNamePattern(entry);
40
+ }
41
+ /**
42
+ * Validates and normalizes a class-keyed pattern map. `true` values are
43
+ * kept as `true` for the standalone class-keyed form and desugared to the
44
+ * catch-all `'**'` pattern inside member-scoped entries, where selections
45
+ * compose in listed order and a later exclusion may subtract from them.
46
+ */
47
+ function normalizeClassMap(perClass, desugarTrue) {
41
48
  const normalized = {};
42
49
  for (const [key, value] of Object.entries(perClass)) {
43
50
  if (!DEPENDENCY_CLASSES.includes(key)) {
@@ -47,7 +54,7 @@ export function normalizePackagePrune(raw) {
47
54
  continue;
48
55
  }
49
56
  if (value === true) {
50
- normalized[key] = true;
57
+ normalized[key] = desugarTrue ? [CATCH_ALL_PATTERN] : true;
51
58
  continue;
52
59
  }
53
60
  if (Array.isArray(value)) {
@@ -57,19 +64,402 @@ export function normalizePackagePrune(raw) {
57
64
  // Parsed per class even for the array shape, so no two classes share
58
65
  // one pattern array instance and a consumer mutating one cannot
59
66
  // silently change the others.
60
- normalized[key] = value.map(entry => parsePackageNamePattern(entry));
67
+ normalized[key] = value.map(entry => parseNamePatternEntry(entry, key));
61
68
  continue;
62
69
  }
63
70
  throw new Error(`'${key}' must be true or an array of package name patterns`);
64
71
  }
65
- if (Object.keys(normalized).length === 0) {
72
+ return normalized;
73
+ }
74
+ /**
75
+ * Normalizes a member-scoped `remove`/`keep` value: a flat pattern list
76
+ * fans out to every dependency class, a class-keyed map stays per class.
77
+ * For `keep`, an absent class means "keep nothing from it" — so an empty
78
+ * flat list or map legitimately describes a member kept dependency-free.
79
+ */
80
+ function normalizeSelection(value, field) {
81
+ if (Array.isArray(value)) {
82
+ const patterns = value.map(entry => parseNamePatternEntry(entry, field));
83
+ if (patterns.length === 0) {
84
+ return {};
85
+ }
86
+ return Object.fromEntries(DEPENDENCY_CLASSES.map(dependencyClass => [dependencyClass, patterns.slice()]));
87
+ }
88
+ if (isPlainObject(value)) {
89
+ // With `true` desugared no `true` values remain, only pattern arrays.
90
+ return normalizeClassMap(value, true);
91
+ }
92
+ throw new Error(`'${field}' ${SHAPE_ERROR}`);
93
+ }
94
+ function normalizeMemberPatterns(raw) {
95
+ const list = typeof raw === 'string' ? [raw] : raw;
96
+ if (!Array.isArray(list) || list.length === 0) {
97
+ throw new Error(`'member' must be a workspace member name pattern or a non-empty array of them`);
98
+ }
99
+ return list.map(element => {
100
+ if (element === '.' || element === '!.') {
101
+ return { root: true, exclude: element === '!.' };
102
+ }
103
+ if (element !== null && typeof element === 'object') {
104
+ throw new Error(`'member' entries must be member name patterns or the '.' root token`);
105
+ }
106
+ return parsePackageNamePattern(element);
107
+ });
108
+ }
109
+ function normalizeMemberScopedEntry(entry) {
110
+ if (!isPlainObject(entry)) {
111
+ throw new Error(ENTRY_SHAPE_ERROR);
112
+ }
113
+ for (const key of Object.keys(entry)) {
114
+ if (key !== 'member' && key !== 'remove' && key !== 'keep') {
115
+ throw new Error(`'${key}' is not a member-scoped prune entry field (expected member, remove, keep)`);
116
+ }
117
+ }
118
+ const member = normalizeMemberPatterns(entry.member);
119
+ const hasRemove = entry.remove !== undefined;
120
+ const hasKeep = entry.keep !== undefined;
121
+ if (hasRemove === hasKeep) {
122
+ throw new Error(`a member-scoped prune entry must have exactly one of 'remove' and 'keep'`);
123
+ }
124
+ if (hasRemove) {
125
+ return { kind: 'remove', member, remove: normalizeSelection(entry.remove, 'remove') };
126
+ }
127
+ return {
128
+ kind: 'keep',
129
+ member,
130
+ keep: normalizeSelection(entry.keep, 'keep'),
131
+ flat: Array.isArray(entry.keep),
132
+ };
133
+ }
134
+ /**
135
+ * Validates and normalizes a `bundle.packages.prune` value. Returns
136
+ * `undefined` when there is nothing to do — the value is absent, or every
137
+ * shape it carries is empty. Throws `InvalidPackageNamePatternError` on an
138
+ * invalid pattern and a plain `Error` on an invalid shape; the config
139
+ * loader relies on that to reject plain-JS configs that bypass the
140
+ * TypeScript type.
141
+ */
142
+ export function normalizePackagePrune(raw) {
143
+ if (raw === undefined) {
66
144
  return undefined;
67
145
  }
68
- return normalized;
146
+ if (Array.isArray(raw)) {
147
+ const entries = [];
148
+ for (const entry of raw) {
149
+ if (typeof entry === 'string') {
150
+ entries.push({ kind: 'global', pattern: parsePackageNamePattern(entry) });
151
+ continue;
152
+ }
153
+ entries.push(normalizeMemberScopedEntry(entry));
154
+ }
155
+ if (entries.length === 0) {
156
+ return undefined;
157
+ }
158
+ return { form: 'entries', entries };
159
+ }
160
+ if (!isPlainObject(raw)) {
161
+ throw new Error(SHAPE_ERROR);
162
+ }
163
+ const classes = normalizeClassMap(raw, false);
164
+ if (Object.keys(classes).length === 0) {
165
+ return undefined;
166
+ }
167
+ return { form: 'classes', classes };
168
+ }
169
+ function memberPatternsSelect(patterns, member) {
170
+ let selected = false;
171
+ for (const pattern of patterns) {
172
+ const matches = 'root' in pattern
173
+ ? member.root
174
+ : typeof member.name === 'string' && member.name !== ''
175
+ && specMatchesPackageName(pattern, member.name);
176
+ if (matches) {
177
+ selected = !pattern.exclude;
178
+ }
179
+ }
180
+ return selected;
181
+ }
182
+ /**
183
+ * Reduces a normalized prune to what applies to the given manifest:
184
+ * global patterns and matching member-scoped `remove` selections combine
185
+ * per class in listed order (so a later `!` exclusion subtracts from
186
+ * earlier entries' selections), unless a `keep` entry matches the member,
187
+ * in which case the keep selections govern alone. Returns `undefined`
188
+ * when nothing targets the manifest.
189
+ */
190
+ export function resolveManifestPrune(prune, member) {
191
+ if (prune.form === 'classes') {
192
+ return { mode: 'remove', classes: prune.classes };
193
+ }
194
+ const keeps = [];
195
+ for (const entry of prune.entries) {
196
+ if (entry.kind === 'keep' && memberPatternsSelect(entry.member, member)) {
197
+ keeps.push(entry.keep);
198
+ }
199
+ }
200
+ if (keeps.length > 0) {
201
+ return { mode: 'keep', keeps };
202
+ }
203
+ const classes = {};
204
+ const append = (dependencyClass, patterns) => {
205
+ (classes[dependencyClass] ??= []).push(...patterns);
206
+ };
207
+ for (const entry of prune.entries) {
208
+ if (entry.kind === 'global') {
209
+ for (const dependencyClass of DEPENDENCY_CLASSES) {
210
+ append(dependencyClass, [entry.pattern]);
211
+ }
212
+ continue;
213
+ }
214
+ if (entry.kind === 'remove' && memberPatternsSelect(entry.member, member)) {
215
+ for (const dependencyClass of DEPENDENCY_CLASSES) {
216
+ const patterns = entry.remove[dependencyClass];
217
+ if (patterns !== undefined) {
218
+ append(dependencyClass, patterns);
219
+ }
220
+ }
221
+ }
222
+ }
223
+ if (Object.keys(classes).length === 0) {
224
+ return undefined;
225
+ }
226
+ return { mode: 'remove', classes };
227
+ }
228
+ /** Formats a member pattern back to its config spelling. */
229
+ function formatMemberPattern(pattern) {
230
+ return `${pattern.exclude ? '!' : ''}${'root' in pattern ? '.' : pattern.name}`;
231
+ }
232
+ /**
233
+ * How a member is named in warnings. Only the root can lack a `name` at
234
+ * runtime (see {@link PruneMemberIdentity.name}), so its token stands in.
235
+ */
236
+ function displayMemberName(member) {
237
+ return member.name ?? '.';
238
+ }
239
+ /**
240
+ * Whether the pattern contributes anything to the manifest's kept set:
241
+ * some entry in one of the given classes both matches the pattern and is
242
+ * ultimately retained — judged against the union of every keep entry
243
+ * matching the member, exactly what {@link applyPrune} keeps. Judged
244
+ * against retention rather than the raw match so a keep list whose later
245
+ * `!` exclusions cancel everything a pattern selected still reports
246
+ * (that silent gutting is exactly what this net exists to catch), while
247
+ * a name another matching keep entry rescues does not.
248
+ */
249
+ function patternReaches(pattern, keeps, parsed, dependencyClasses) {
250
+ return dependencyClasses.some(dependencyClass => {
251
+ const section = parsed[dependencyClass];
252
+ return isPlainSection(section) && Object.keys(section).some(name => specMatchesPackageName(pattern, name) && keepsSelectName(keeps, dependencyClass, name));
253
+ });
254
+ }
255
+ /**
256
+ * Lints member-scoped prune entries. Two nets, both warnings rather than
257
+ * errors because a pattern reaching nothing is legal in every other
258
+ * `bundle.packages` position: a `member` selector matching no workspace
259
+ * member at all (typo net), and a `keep` pattern selecting nothing in a
260
+ * matched bundled member (the auto-enrollment net — a member swept in by
261
+ * a wildcard later almost never declares another member's keep set, so
262
+ * the gutting self-reports on the next run; a right name in the wrong
263
+ * class reports the same way). The typo net is judged against the whole
264
+ * discovered workspace, not the bundle: which members land in a bundle
265
+ * varies per run with the check filter (`checkly test --grep`, a single
266
+ * `checkly pw-test` config), and a selector for a member the current run
267
+ * merely did not bundle is not a typo — that state shows up in the debug
268
+ * reach log instead. The keep net runs on the bundled physical manifests
269
+ * only — faux shims carry no real dependency classes — and only on ones
270
+ * the caller could read, so a symlinked or unreadable manifest's own
271
+ * failure warns separately.
272
+ */
273
+ export class MemberPruneDiagnostics {
274
+ /** One record per scoped entry, in entry order. */
275
+ #state = [];
276
+ /**
277
+ * Keep misses keyed by entry, place and pattern, accumulating the
278
+ * missing members: a shared keep list over a wildcard selector then
279
+ * warns once per pattern naming every member it missed, instead of
280
+ * bursting one line per member on every run.
281
+ */
282
+ #keepMisses = new Map();
283
+ constructor(prune) {
284
+ if (prune?.form === 'entries') {
285
+ this.#state = prune.entries
286
+ .filter(entry => entry.kind !== 'global')
287
+ .map(entry => ({ entry, workspaceMatched: new Set(), bundledMatched: new Set() }));
288
+ }
289
+ }
290
+ /**
291
+ * Marks the scoped entries whose member selector selects this workspace
292
+ * member, bundled or not — the input of the typo net.
293
+ */
294
+ observeWorkspaceMember(member) {
295
+ for (const state of this.#state) {
296
+ if (memberPatternsSelect(state.entry.member, member)) {
297
+ state.workspaceMatched.add(displayMemberName(member));
298
+ }
299
+ }
300
+ }
301
+ /**
302
+ * Marks the scoped entries whose member selector selects this bundled
303
+ * physical manifest — the input of the debug reach log.
304
+ */
305
+ observeBundledMember(member) {
306
+ for (const state of this.#state) {
307
+ if (memberPatternsSelect(state.entry.member, member)) {
308
+ state.bundledMatched.add(displayMemberName(member));
309
+ }
310
+ }
311
+ }
312
+ /** Checks matching keep selections against the manifest's contents. */
313
+ observeManifestContent(member, content) {
314
+ let parsed;
315
+ try {
316
+ parsed = JSON.parse(content);
317
+ }
318
+ catch {
319
+ return;
320
+ }
321
+ if (!isPlainSection(parsed)) {
322
+ return;
323
+ }
324
+ // Reach is judged against the union of every matching keep entry —
325
+ // the member's actual retention — so an entry's pattern whose kept
326
+ // names another entry supplies does not misreport.
327
+ const matched = [];
328
+ const keeps = [];
329
+ for (const [index, state] of this.#state.entries()) {
330
+ if (state.entry.kind === 'keep' && memberPatternsSelect(state.entry.member, member)) {
331
+ matched.push({ index, entry: state.entry });
332
+ keeps.push(state.entry.keep);
333
+ }
334
+ }
335
+ for (const { index, entry } of matched) {
336
+ this.#checkKeepEntry(index, entry, keeps, member, parsed);
337
+ }
338
+ }
339
+ #checkKeepEntry(entryIndex, entry, keeps, member, parsed) {
340
+ const memberName = displayMemberName(member);
341
+ const check = (patterns, classes, where) => {
342
+ for (const pattern of patterns) {
343
+ if (this.#exemptFromMatchCheck(pattern)) {
344
+ continue;
345
+ }
346
+ if (!patternReaches(pattern, keeps, parsed, classes)) {
347
+ const key = `${entryIndex}\u0000${where}\u0000${pattern.name}`;
348
+ let miss = this.#keepMisses.get(key);
349
+ if (miss === undefined) {
350
+ this.#keepMisses.set(key, miss = { pattern: pattern.name, where, members: [] });
351
+ }
352
+ if (!miss.members.includes(memberName)) {
353
+ miss.members.push(memberName);
354
+ }
355
+ }
356
+ }
357
+ };
358
+ if (entry.flat) {
359
+ // A flat list fans out to every class identically, so its patterns
360
+ // are checked across all classes and warn once — a per-class report
361
+ // would repeat every miss four times.
362
+ check(entry.keep.dependencies ?? [], DEPENDENCY_CLASSES, 'any dependency class');
363
+ return;
364
+ }
365
+ for (const dependencyClass of DEPENDENCY_CLASSES) {
366
+ check(entry.keep[dependencyClass] ?? [], [dependencyClass], dependencyClass);
367
+ }
368
+ }
369
+ /**
370
+ * Exclusions only subtract, so one deselecting nothing is benign — the
371
+ * same rule embed applies. The bare catch-all is exempt too: it is what
372
+ * `keep: { <class>: true }` desugars to, and "everything in an empty
373
+ * class" matching nothing is not a signal worth a warning.
374
+ */
375
+ #exemptFromMatchCheck(pattern) {
376
+ return pattern.exclude || pattern.name === '**';
377
+ }
378
+ #selector(entry) {
379
+ return entry.member.map(formatMemberPattern).join(`', '`);
380
+ }
381
+ /**
382
+ * Logs each scoped entry's resolved member set — workspace-wide and
383
+ * within this run's bundle — mirroring embed's pattern reach logging.
384
+ * This is also where "the selector is fine, the member just is not in
385
+ * this bundle" is visible. A separate call rather than a side effect of
386
+ * {@link MemberPruneDiagnostics.warnings}: a caller that gates or drops
387
+ * the warnings must not silently lose the reach log, which is the tool
388
+ * for answering the very question the warnings raise.
389
+ */
390
+ logMemberReach() {
391
+ for (const { entry, workspaceMatched, bundledMatched } of this.#state) {
392
+ debug(`Prune entry for member '${this.#selector(entry)}' matched workspace members:`
393
+ + ` ${[...workspaceMatched].join(', ') || '(none)'};`
394
+ + ` bundled: ${[...bundledMatched].join(', ') || '(none)'}`);
395
+ }
396
+ }
397
+ /** The accumulated lint messages, without the `Warning:` prefix. */
398
+ warnings() {
399
+ const out = [];
400
+ for (const { entry, workspaceMatched } of this.#state) {
401
+ if (workspaceMatched.size === 0) {
402
+ out.push(`bundle.packages.prune entry for member '${this.#selector(entry)}'`
403
+ + ` matched no workspace member`);
404
+ }
405
+ }
406
+ for (const { pattern, where, members } of this.#keepMisses.values()) {
407
+ const consequence = members.length > 1
408
+ ? 'their bundled manifests do not keep it'
409
+ : 'its bundled manifest does not keep it';
410
+ out.push(`bundle.packages.prune keep pattern '${pattern}' matched nothing`
411
+ + ` in ${where} of ${quotedList(members)}; ${consequence}`);
412
+ }
413
+ return out;
414
+ }
69
415
  }
70
416
  function isPlainSection(section) {
71
417
  return section !== null && typeof section === 'object' && !Array.isArray(section);
72
418
  }
419
+ /** Whether any of the matched keep selections keeps the given entry. */
420
+ function keepsSelectName(keeps, dependencyClass, name) {
421
+ return keeps.some(keep => {
422
+ const patterns = keep[dependencyClass];
423
+ return patterns !== undefined && patternsSelectName(patterns, name);
424
+ });
425
+ }
426
+ /**
427
+ * Deletes the class's entries the predicate selects, recording removals
428
+ * as `class:name` labels. Carries the structural rules shared by both
429
+ * prune modes: a removed peer takes its `peerDependenciesMeta` entry with
430
+ * it, and a section or meta object emptied by these removals is deleted —
431
+ * one that was already empty is none of this feature's business.
432
+ */
433
+ function removeSelectedNames(parsed, dependencyClass, selects, removed) {
434
+ const section = parsed[dependencyClass];
435
+ if (!isPlainSection(section)) {
436
+ return false;
437
+ }
438
+ let lost = false;
439
+ for (const name of Object.keys(section)) {
440
+ if (!selects(name)) {
441
+ continue;
442
+ }
443
+ delete section[name];
444
+ removed.push(`${dependencyClass}:${name}`);
445
+ lost = true;
446
+ if (dependencyClass === 'peerDependencies') {
447
+ const meta = parsed.peerDependenciesMeta;
448
+ if (isPlainSection(meta)) {
449
+ delete meta[name];
450
+ }
451
+ }
452
+ }
453
+ if (lost && Object.keys(section).length === 0) {
454
+ delete parsed[dependencyClass];
455
+ }
456
+ if (lost && dependencyClass === 'peerDependencies'
457
+ && isPlainSection(parsed.peerDependenciesMeta)
458
+ && Object.keys(parsed.peerDependenciesMeta).length === 0) {
459
+ delete parsed.peerDependenciesMeta;
460
+ }
461
+ return lost;
462
+ }
73
463
  /**
74
464
  * Deletes the pruned entries from a parsed manifest, recording removals as
75
465
  * `class:name` labels. The verification below replays the same edit from
@@ -78,21 +468,27 @@ function isPlainSection(section) {
78
468
  * not a plain object is never touched; `true` deletes the class (and, for
79
469
  * `peerDependencies`, the whole `peerDependenciesMeta`); a removed peer
80
470
  * takes its meta entry with it; and a section or meta object emptied by
81
- * these removals is deleted. `dependenciesMeta` is deliberately not
82
- * followed: only the peer meta matters for the auto-install-peers
83
- * promotion this feature exists to counter, and a dangling
84
- * `dependenciesMeta` entry is inert for the regenerated lockfile.
471
+ * these removals is deleted. In keep mode the selection inverts — an entry
472
+ * no matched keep selects is removed under the same structural rules.
473
+ * `dependenciesMeta` is deliberately not followed: only the peer meta
474
+ * matters for the auto-install-peers promotion this feature exists to
475
+ * counter, and a dangling `dependenciesMeta` entry is inert for the
476
+ * regenerated lockfile.
85
477
  */
86
- function applyPrune(parsed, prune) {
478
+ function applyPrune(parsed, resolved) {
87
479
  const removed = [];
88
480
  let changed = false;
89
481
  for (const dependencyClass of DEPENDENCY_CLASSES) {
90
- const matcher = prune[dependencyClass];
482
+ if (resolved.mode === 'keep') {
483
+ changed = removeSelectedNames(parsed, dependencyClass, name => !keepsSelectName(resolved.keeps, dependencyClass, name), removed) || changed;
484
+ continue;
485
+ }
486
+ const matcher = resolved.classes[dependencyClass];
91
487
  if (matcher === undefined) {
92
488
  continue;
93
489
  }
94
- const section = parsed[dependencyClass];
95
490
  if (matcher === true) {
491
+ const section = parsed[dependencyClass];
96
492
  if (isPlainSection(section)) {
97
493
  for (const name of Object.keys(section)) {
98
494
  removed.push(`${dependencyClass}:${name}`);
@@ -110,47 +506,19 @@ function applyPrune(parsed, prune) {
110
506
  }
111
507
  continue;
112
508
  }
113
- if (!isPlainSection(section)) {
114
- continue;
115
- }
116
- let lost = false;
117
- for (const name of Object.keys(section)) {
118
- if (!patternsSelectName(matcher, name)) {
119
- continue;
120
- }
121
- delete section[name];
122
- removed.push(`${dependencyClass}:${name}`);
123
- changed = true;
124
- lost = true;
125
- if (dependencyClass === 'peerDependencies') {
126
- const meta = parsed.peerDependenciesMeta;
127
- if (isPlainSection(meta)) {
128
- delete meta[name];
129
- }
130
- }
131
- }
132
- // Only a section (or meta object) this pass emptied is dropped; one
133
- // that was already empty is none of this feature's business.
134
- if (lost && Object.keys(section).length === 0) {
135
- delete parsed[dependencyClass];
136
- }
137
- if (lost && dependencyClass === 'peerDependencies'
138
- && isPlainSection(parsed.peerDependenciesMeta)
139
- && Object.keys(parsed.peerDependenciesMeta).length === 0) {
140
- delete parsed.peerDependenciesMeta;
141
- }
509
+ changed = removeSelectedNames(parsed, dependencyClass, name => patternsSelectName(matcher, name), removed) || changed;
142
510
  }
143
511
  return { removed, changed };
144
512
  }
145
513
  /**
146
- * Applies a normalized `bundle.packages.prune` to a package.json's
147
- * contents. Returns the rewritten content and the removed entries, or
148
- * `undefined` when the content cannot be parsed or the rewrite fails
149
- * verification — the caller ships the original in that case. A prune that
150
- * changes nothing returns the original content with `changed: false`, so
151
- * the caller can leave the bundle entry untouched.
514
+ * Applies a resolved per-manifest prune (see {@link resolveManifestPrune})
515
+ * to a package.json's contents. Returns the rewritten content and the
516
+ * removed entries, or `undefined` when the content cannot be parsed or the
517
+ * rewrite fails verification — the caller ships the original in that case.
518
+ * A prune that changes nothing returns the original content with
519
+ * `changed: false`, so the caller can leave the bundle entry untouched.
152
520
  */
153
- export function prunePackageJson(content, prune) {
521
+ export function prunePackageJson(content, resolved) {
154
522
  let parsed;
155
523
  try {
156
524
  parsed = JSON.parse(content);
@@ -163,7 +531,7 @@ export function prunePackageJson(content, prune) {
163
531
  debug(`Refusing to prune a package.json that is not an object`);
164
532
  return undefined;
165
533
  }
166
- const { removed, changed } = applyPrune(parsed, prune);
534
+ const { removed, changed } = applyPrune(parsed, resolved);
167
535
  if (!changed) {
168
536
  return { content, removed, changed };
169
537
  }
@@ -172,7 +540,7 @@ export function prunePackageJson(content, prune) {
172
540
  // text — and matching the original's formatting would mean carrying a
173
541
  // JSON editor for no behavioral gain.
174
542
  const rewritten = JSON.stringify(parsed, null, 2);
175
- if (verifyPrunedManifest(content, rewritten, prune, removed) === undefined) {
543
+ if (verifyPrunedManifest(content, rewritten, resolved, removed) === undefined) {
176
544
  return undefined;
177
545
  }
178
546
  return { content: rewritten, removed, changed };
@@ -181,13 +549,16 @@ export function prunePackageJson(content, prune) {
181
549
  * Asserts that a prune rewrite changed nothing but the reported entries,
182
550
  * by replaying the deletion on a fresh parse of the original — from the
183
551
  * `class:name` labels rather than the pattern matching, with each label
184
- * additionally checked against the configured prune — so a matcher that
552
+ * additionally checked against the resolved prune — so a matcher that
185
553
  * deleted the wrong entry, deleted from a class the prune never
186
- * configured, or strayed outside the dependency classes fails the
187
- * comparison instead of shipping. The structural rules (`true` deleting a
188
- * class, emptied sections and meta dropping out) are mirrored from
189
- * applyPrune rather than independently derived, so only the per-entry
190
- * path carries independent evidence; a maintainer extending the
554
+ * configured, deleted a keep-selected entry, or strayed outside the
555
+ * dependency classes fails the comparison instead of shipping. The
556
+ * verification is inherently per manifest its inputs are one manifest's
557
+ * original and rewrite so the resolved model already carries any member
558
+ * scoping and the labels need no member qualifier. The structural rules
559
+ * (`true` deleting a class, emptied sections and meta dropping out) are
560
+ * mirrored from applyPrune rather than independently derived, so only the
561
+ * per-entry path carries independent evidence; a maintainer extending the
191
562
  * structural rules must extend both sides. What the comparison cannot see
192
563
  * is a field whose value is already altered by JSON.parse itself (an
193
564
  * integer beyond 2^53 loses precision on both sides alike); asymmetric
@@ -196,7 +567,7 @@ export function prunePackageJson(content, prune) {
196
567
  * fail-closed verifier in patched-dependencies.ts (`verifyRewrite`);
197
568
  * hardening either against a manifest quirk likely applies to both.
198
569
  */
199
- export function verifyPrunedManifest(original, rewritten, prune, removed) {
570
+ export function verifyPrunedManifest(original, rewritten, resolved, removed) {
200
571
  let expected;
201
572
  let actual;
202
573
  try {
@@ -207,18 +578,25 @@ export function verifyPrunedManifest(original, rewritten, prune, removed) {
207
578
  debug(`Could not reparse a pruned package.json for verification: ${err}`);
208
579
  return undefined;
209
580
  }
581
+ const classTrue = (dependencyClass) => resolved.mode === 'remove' && resolved.classes[dependencyClass] === true;
582
+ // Whether the resolved prune selects the entry for removal. Only called
583
+ // with the known dependency classes — the caller checks membership
584
+ // first, so a label like `__proto__:x` fails closed instead of reading
585
+ // a matcher off Object.prototype.
586
+ const selectsRemoval = (dependencyClass, name) => {
587
+ if (resolved.mode === 'keep') {
588
+ return !keepsSelectName(resolved.keeps, dependencyClass, name);
589
+ }
590
+ const matcher = resolved.classes[dependencyClass];
591
+ return matcher !== undefined && (matcher === true || patternsSelectName(matcher, name));
592
+ };
210
593
  // Every reported removal must be one the configuration allows: its class
211
- // a known dependency class the prune configured, and its name selected
212
- // by `true` or a configured pattern. The class-membership check comes
213
- // first so a label like `__proto__:x` fails closed instead of reading a
214
- // matcher off Object.prototype.
594
+ // a known dependency class, and its name one the resolved prune selects.
215
595
  for (const label of removed) {
216
596
  const separator = label.indexOf(':');
217
597
  const dependencyClass = label.slice(0, separator);
218
598
  const name = label.slice(separator + 1);
219
- const matcher = DEPENDENCY_CLASSES.includes(dependencyClass) ? prune[dependencyClass] : undefined;
220
- if (matcher === undefined
221
- || (matcher !== true && !patternsSelectName(matcher, name))) {
599
+ if (!DEPENDENCY_CLASSES.includes(dependencyClass) || !selectsRemoval(dependencyClass, name)) {
222
600
  debug(`A pruned package.json removed '${label}', which the configuration does not select;`
223
601
  + ` leaving the bundle alone`);
224
602
  return undefined;
@@ -238,7 +616,7 @@ export function verifyPrunedManifest(original, rewritten, prune, removed) {
238
616
  }
239
617
  delete section[name];
240
618
  lostClasses.add(dependencyClass);
241
- if (dependencyClass === 'peerDependencies' && prune.peerDependencies !== true) {
619
+ if (dependencyClass === 'peerDependencies' && !classTrue('peerDependencies')) {
242
620
  lostPeers = true;
243
621
  const meta = expected.peerDependenciesMeta;
244
622
  if (isPlainSection(meta)) {
@@ -247,7 +625,7 @@ export function verifyPrunedManifest(original, rewritten, prune, removed) {
247
625
  }
248
626
  }
249
627
  for (const dependencyClass of DEPENDENCY_CLASSES) {
250
- if (prune[dependencyClass] === true) {
628
+ if (classTrue(dependencyClass)) {
251
629
  if (isPlainSection(expected[dependencyClass])) {
252
630
  delete expected[dependencyClass];
253
631
  }