@pnpm/releasing.versioning 1100.1.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.
@@ -0,0 +1,699 @@
1
+ import path from 'node:path';
2
+ import { PnpmError } from '@pnpm/error';
3
+ import { WorkspaceSpec } from '@pnpm/workspace.spec-parser';
4
+ import { compare, diff, inc, prerelease as parsePrerelease, satisfies, valid, validRange } from 'semver';
5
+ import { buildConsumptionIndex, normalizeProjectDir } from './ledger.js';
6
+ const BUMP_ORDER = { patch: 1, minor: 2, major: 3 };
7
+ const PROPAGATED_DEP_FIELDS = ['dependencies', 'optionalDependencies', 'peerDependencies'];
8
+ /**
9
+ * Whether a package reference is a workspace-relative directory path rather
10
+ * than a package name — the additive extension to the changesets format,
11
+ * needed only when workspace projects share a published name.
12
+ */
13
+ export function isDirRef(ref) {
14
+ return ref.startsWith('./');
15
+ }
16
+ export function indexProjectRefs(projects, workspaceDir) {
17
+ const dirs = new Set();
18
+ const dirsByName = new Map();
19
+ for (const project of projects) {
20
+ const dir = toProjectDir(workspaceDir, project.rootDir);
21
+ dirs.add(dir);
22
+ const name = project.manifest.name;
23
+ if (name == null)
24
+ continue;
25
+ let named = dirsByName.get(name);
26
+ if (named == null) {
27
+ named = [];
28
+ dirsByName.set(name, named);
29
+ }
30
+ named.push(dir);
31
+ }
32
+ return {
33
+ refToDirs: (ref) => {
34
+ if (isDirRef(ref)) {
35
+ const dir = normalizeProjectDir(ref);
36
+ return dirs.has(dir) ? [dir] : [];
37
+ }
38
+ return dirsByName.get(ref) ?? [];
39
+ },
40
+ nameToDirs: (name) => dirsByName.get(name) ?? [],
41
+ };
42
+ }
43
+ /** The workspace-relative directory of a project, in canonical spelling. */
44
+ export function toProjectDir(workspaceDir, rootDir) {
45
+ return normalizeProjectDir(path.relative(workspaceDir, rootDir));
46
+ }
47
+ export function assembleReleasePlan(opts) {
48
+ const refs = indexProjectRefs(opts.projects, opts.workspaceDir);
49
+ const participants = collectParticipants(opts.projects, refs, opts);
50
+ const lanesByDir = resolveLanes(refs, participants, opts.versioning);
51
+ const fixedGroups = resolveFixedGroups(refs, participants, opts.versioning);
52
+ validateFixedGroupLanes(fixedGroups, lanesByDir, opts.versioning);
53
+ const epics = resolveEpics(refs, participants, opts.versioning);
54
+ validateEpics(epics, fixedGroups);
55
+ const intentBumps = resolveIntents(opts.intents, refs, participants);
56
+ if (opts.enforceWorkspaceProtocol) {
57
+ assertInternalDepsUseWorkspaceProtocol(participants);
58
+ }
59
+ const consumptionOf = buildConsumptionIndex(opts.ledger, refs.nameToDirs);
60
+ const ctx = { participants, lanesByDir, fixedGroups, epics, intentBumps, consumptionOf, opts };
61
+ let selection = opts.filter;
62
+ for (;;) {
63
+ const plan = assemble(ctx, selection);
64
+ if (selection == null)
65
+ return plan;
66
+ const expanded = new Set(selection);
67
+ for (const release of plan.releases) {
68
+ expanded.add(release.dir);
69
+ }
70
+ if (expanded.size === selection.size)
71
+ return plan;
72
+ selection = expanded;
73
+ }
74
+ }
75
+ function assemble(ctx, selection) {
76
+ const { participants, lanesByDir, fixedGroups, epics, opts } = ctx;
77
+ const pendingByDir = collectPendingIntents(ctx);
78
+ const laneConsumedByDir = collectLaneConsumedIntents(ctx);
79
+ const state = new Map();
80
+ const bumpAtLeast = (dir, bumpType, cause) => {
81
+ const existing = state.get(dir);
82
+ if (existing == null) {
83
+ state.set(dir, { bumpType, causes: new Set([cause]), dependencyUpdates: new Map() });
84
+ return true;
85
+ }
86
+ existing.causes.add(cause);
87
+ if (BUMP_ORDER[bumpType] > BUMP_ORDER[existing.bumpType]) {
88
+ existing.bumpType = bumpType;
89
+ return true;
90
+ }
91
+ return false;
92
+ };
93
+ const intentBumpFor = (intent, dir) => ctx.intentBumps.get(intent.id)?.get(dir);
94
+ for (const [dir, pending] of pendingByDir.entries()) {
95
+ if (selection != null && !selection.has(dir))
96
+ continue;
97
+ const direct = maxBumpType(pending.map((intent) => intentBumpFor(intent, dir)));
98
+ if (direct != null) {
99
+ bumpAtLeast(dir, direct, 'intent');
100
+ }
101
+ }
102
+ // A package that left its lane releases the accumulated stable
103
+ // version even when no new intents are pending.
104
+ for (const [dir, laneConsumed] of laneConsumedByDir.entries()) {
105
+ if (selection != null && !selection.has(dir))
106
+ continue;
107
+ if (lanesByDir.has(dir) || laneConsumed.length === 0)
108
+ continue;
109
+ const graduated = maxBumpType(laneConsumed.map((intent) => intentBumpFor(intent, dir)));
110
+ if (graduated != null) {
111
+ bumpAtLeast(dir, graduated, 'intent');
112
+ }
113
+ }
114
+ const cumulativeBump = (dir, planned) => {
115
+ const laneConsumed = laneConsumedByDir.get(dir) ?? [];
116
+ return maxBumpType([planned, ...laneConsumed.map((intent) => intentBumpFor(intent, dir))]) ?? planned;
117
+ };
118
+ const newVersions = new Map();
119
+ const computeVersions = () => {
120
+ newVersions.clear();
121
+ for (const [dir, pkgState] of state.entries()) {
122
+ const participant = participants.get(dir);
123
+ newVersions.set(dir, computeNewVersion(participant, pkgState.bumpType, {
124
+ laneTag: lanesByDir.get(dir),
125
+ cumulativeBump: cumulativeBump(dir, pkgState.bumpType),
126
+ }));
127
+ }
128
+ applyFixedGroupVersions({ participants, state, newVersions, cumulativeBump, fixedGroups, lanesByDir });
129
+ applyEpicBandVersions({ participants, state, newVersions, epics, lanesByDir });
130
+ };
131
+ for (let changed = true; changed;) {
132
+ changed = false;
133
+ computeVersions();
134
+ for (const dependent of participants.values()) {
135
+ for (const dep of dependent.internalDeps) {
136
+ const target = participants.get(dep.targetDir);
137
+ const targetNewVersion = newVersions.get(dep.targetDir);
138
+ if (target == null || targetNewVersion == null)
139
+ continue;
140
+ const materializedRange = materializeWorkspaceRange(dep.spec, target.currentVersion);
141
+ if (materializedRange == null || satisfies(targetNewVersion, materializedRange))
142
+ continue;
143
+ if (bumpAtLeast(dependent.dir, 'patch', 'dependencies')) {
144
+ changed = true;
145
+ }
146
+ state.get(dependent.dir).dependencyUpdates.set(dep.targetName, targetNewVersion);
147
+ }
148
+ }
149
+ for (const group of fixedGroups) {
150
+ const groupBump = maxBumpType(group.map((dir) => state.get(dir)?.bumpType));
151
+ if (groupBump == null)
152
+ continue;
153
+ for (const dir of group) {
154
+ if (bumpAtLeast(dir, groupBump, 'fixed')) {
155
+ changed = true;
156
+ }
157
+ }
158
+ }
159
+ // When the lead crosses to a new stable major, every member re-bases to
160
+ // the band floor. Seed a release for each so the override in
161
+ // applyEpicBandVersions has a version to replace and dependents propagate.
162
+ for (const epic of epics) {
163
+ if (epicRebaseFloor(epic, participants, newVersions) == null)
164
+ continue;
165
+ for (const memberDir of epic.memberDirs) {
166
+ if (bumpAtLeast(memberDir, 'major', 'epic')) {
167
+ changed = true;
168
+ }
169
+ }
170
+ }
171
+ }
172
+ computeVersions();
173
+ const releases = [];
174
+ for (const [dir, pkgState] of state.entries()) {
175
+ const participant = participants.get(dir);
176
+ const consumedForChangelog = [
177
+ ...(pendingByDir.get(dir) ?? []),
178
+ ...(lanesByDir.has(dir) ? [] : laneConsumedByDir.get(dir) ?? []),
179
+ ];
180
+ releases.push({
181
+ name: participant.name,
182
+ dir,
183
+ rootDir: participant.rootDir,
184
+ currentVersion: participant.currentVersion,
185
+ newVersion: opts.snapshotSuffix != null ? `0.0.0-${opts.snapshotSuffix}` : newVersions.get(dir),
186
+ bumpType: pkgState.bumpType,
187
+ intents: consumedForChangelog,
188
+ dependencyUpdates: Array.from(pkgState.dependencyUpdates.entries())
189
+ .map(([depName, newVersion]) => ({ name: depName, newVersion }))
190
+ .sort((left, right) => left.name.localeCompare(right.name)),
191
+ causes: Array.from(pkgState.causes).sort(),
192
+ });
193
+ }
194
+ releases.sort((left, right) => left.name.localeCompare(right.name) || left.dir.localeCompare(right.dir));
195
+ assertNoDuplicateReleaseIdentity(releases);
196
+ if (opts.snapshotSuffix == null) {
197
+ enforceEpicBands(epics, participants, newVersions);
198
+ enforceMaxBump(releases, opts.versioning);
199
+ }
200
+ return { releases };
201
+ }
202
+ /**
203
+ * A published `package@version` identifies exactly one artifact, so two
204
+ * projects that share a name cannot both release the same version — the
205
+ * registry would reject the second publish, and the name-keyed ledger entry
206
+ * would collide. Caught here, before any manifest is written, naming both
207
+ * directories.
208
+ */
209
+ function assertNoDuplicateReleaseIdentity(releases) {
210
+ const byIdentity = new Map();
211
+ for (const release of releases) {
212
+ const identity = `${release.name}@${release.newVersion}`;
213
+ const other = byIdentity.get(identity);
214
+ if (other != null) {
215
+ throw new PnpmError('VERSIONING_DUPLICATE_RELEASE', `Two projects both release ${identity}: ./${other} and ./${release.dir}. ` +
216
+ 'A package name and version identify one published artifact, so same-named projects must release on different version lines (e.g. different lanes or majors).');
217
+ }
218
+ byIdentity.set(identity, release.dir);
219
+ }
220
+ }
221
+ function collectParticipants(projects, refs, opts) {
222
+ const ignoredDirs = new Set();
223
+ for (const ref of opts.versioning?.ignore ?? []) {
224
+ for (const dir of resolveConfigRef(refs, ref, 'versioning.ignore')) {
225
+ ignoredDirs.add(dir);
226
+ }
227
+ }
228
+ const participants = new Map();
229
+ for (const project of projects) {
230
+ const { name, version } = project.manifest;
231
+ const dir = toProjectDir(opts.workspaceDir, project.rootDir);
232
+ // What cannot release is excluded automatically: unnamed and versionless
233
+ // (private) packages, packages with non-semver placeholder versions, and
234
+ // the explicitly frozen ones.
235
+ if (name == null || version == null || valid(version) == null || ignoredDirs.has(dir))
236
+ continue;
237
+ participants.set(dir, {
238
+ name,
239
+ dir,
240
+ rootDir: project.rootDir,
241
+ currentVersion: version,
242
+ manifest: project.manifest,
243
+ internalDeps: [],
244
+ });
245
+ }
246
+ for (const participant of participants.values()) {
247
+ for (const fieldName of PROPAGATED_DEP_FIELDS) {
248
+ for (const [alias, spec] of Object.entries(participant.manifest[fieldName] ?? {})) {
249
+ const targetName = internalDepTargetName(alias, spec, refs);
250
+ if (targetName == null)
251
+ continue;
252
+ const targetDirs = refs.nameToDirs(targetName).filter((dir) => participants.has(dir));
253
+ if (targetDirs.length === 0)
254
+ continue;
255
+ // A workspace: range naming an ambiguous package cannot be linked at
256
+ // install time, so the release engine never legitimately sees one.
257
+ if (targetDirs.length > 1) {
258
+ throw new PnpmError('VERSIONING_AMBIGUOUS_PACKAGE', `Package ${participant.name} (./${participant.dir}) depends on ${targetName}, which matches multiple workspace projects: ${targetDirs.map((dir) => `./${dir}`).join(', ')}`);
259
+ }
260
+ participant.internalDeps.push({ targetDir: targetDirs[0], targetName, fieldName, alias, spec });
261
+ }
262
+ }
263
+ }
264
+ return participants;
265
+ }
266
+ /**
267
+ * Decides whether a dependency entry points at a workspace package. Aliased
268
+ * specs targeting somewhere else (`npm:`, `file:`, git URLs, …) are external
269
+ * even when the alias collides with a workspace package name; a plain semver
270
+ * range or `catalog:` entry on a workspace name is internal — it is exactly
271
+ * the declaration the workspace-protocol check must reject.
272
+ */
273
+ function internalDepTargetName(alias, spec, refs) {
274
+ if (spec.startsWith('workspace:')) {
275
+ const targetName = WorkspaceSpec.parse(spec)?.alias ?? alias;
276
+ return refs.nameToDirs(targetName).length > 0 ? targetName : null;
277
+ }
278
+ if (refs.nameToDirs(alias).length === 0)
279
+ return null;
280
+ if (spec.startsWith('catalog:') || validRange(spec) != null)
281
+ return alias;
282
+ return null;
283
+ }
284
+ /**
285
+ * Resolves a package reference from `versioning` configuration. An unknown
286
+ * reference is skipped — configuration may outlive a removed project — but an
287
+ * ambiguous name is an error: it cannot be attributed, and silence here is
288
+ * exactly the name-keying flaw this engine exists to fix.
289
+ */
290
+ function resolveConfigRef(refs, ref, settingName) {
291
+ const dirs = refs.refToDirs(ref);
292
+ if (dirs.length > 1) {
293
+ throw new PnpmError('VERSIONING_AMBIGUOUS_PACKAGE', `${settingName} references ${ref}, which matches multiple workspace projects: ${dirs.map((dir) => `./${dir}`).join(', ')}. Reference the project by directory instead.`);
294
+ }
295
+ return dirs;
296
+ }
297
+ function resolveLanes(refs, participants, versioning) {
298
+ const lanesByDir = new Map();
299
+ for (const [ref, lane] of Object.entries(versioning?.lanes ?? {})) {
300
+ if (lane.toLowerCase() === 'main') {
301
+ throw new PnpmError('VERSIONING_INVALID_LANE_NAME', `versioning.lanes assigns ${ref} to the "${lane}" lane, but "main" is the reserved default lane. Remove the entry instead.`);
302
+ }
303
+ for (const dir of resolveConfigRef(refs, ref, 'versioning.lanes')) {
304
+ if (participants.has(dir)) {
305
+ lanesByDir.set(dir, lane);
306
+ }
307
+ }
308
+ }
309
+ return lanesByDir;
310
+ }
311
+ function resolveFixedGroups(refs, participants, versioning) {
312
+ return (versioning?.fixed ?? []).map((group) => group
313
+ .flatMap((ref) => resolveConfigRef(refs, ref, 'versioning.fixed'))
314
+ .filter((dir) => participants.has(dir)));
315
+ }
316
+ function validateFixedGroupLanes(fixedGroups, lanesByDir, versioning) {
317
+ for (const [index, group] of fixedGroups.entries()) {
318
+ const tags = new Set(group.map((dir) => lanesByDir.get(dir)));
319
+ if (tags.size > 1) {
320
+ throw new PnpmError('VERSIONING_CONFLICTING_CONFIG', `The fixed group [${(versioning?.fixed ?? [])[index].join(', ')}] mixes packages on different lanes. A fixed group must move between lanes together.`);
321
+ }
322
+ }
323
+ }
324
+ /**
325
+ * Resolves each configured epic to its lead directory and the set of member
326
+ * directories its selectors match. The lead — a single named package with a
327
+ * semver version — is excluded from its own membership; a selector matching
328
+ * it is a no-op. Membership selectors match name globs, `./`-prefixed
329
+ * directory globs, and `!`-prefixed negations.
330
+ */
331
+ function resolveEpics(refs, participants, versioning) {
332
+ return (versioning?.epics ?? []).map((epic) => {
333
+ const leadDir = resolveConfigRef(refs, epic.lead, 'versioning.epics lead')[0];
334
+ if (leadDir == null || !participants.has(leadDir)) {
335
+ throw new PnpmError('VERSIONING_EPIC_UNKNOWN_LEAD', `versioning.epics lead "${epic.lead}" is not a releasable workspace project (it must be a named package with a semver version).`);
336
+ }
337
+ const selectors = epic.packages.map(compileEpicSelector);
338
+ const memberDirs = new Set();
339
+ for (const participant of participants.values()) {
340
+ if (participant.dir === leadDir)
341
+ continue;
342
+ if (matchesEpicSelectors(selectors, participant.dir, participant.name)) {
343
+ memberDirs.add(participant.dir);
344
+ }
345
+ }
346
+ return { leadRef: epic.lead, leadDir, memberDirs };
347
+ });
348
+ }
349
+ function compileEpicSelector(selector) {
350
+ const negated = selector.startsWith('!');
351
+ const body = negated ? selector.slice(1) : selector;
352
+ const onDir = isDirRef(body);
353
+ return { negated, onDir, match: wildcardMatch(onDir ? normalizeProjectDir(body) : body) };
354
+ }
355
+ /**
356
+ * Whether a project is an epic member under pnpm's order-dependent selector
357
+ * rule: each matching selector overrides the previous verdict, so the last one
358
+ * to match decides — a positive include or a `!` negation — mirroring
359
+ * `@pnpm/config.matcher`, where a later include can re-include a package an
360
+ * earlier negation excluded.
361
+ */
362
+ function matchesEpicSelectors(selectors, dir, name) {
363
+ let included = false;
364
+ for (const selector of selectors) {
365
+ if (selector.match(selector.onDir ? dir : name)) {
366
+ included = !selector.negated;
367
+ }
368
+ }
369
+ return included;
370
+ }
371
+ /**
372
+ * Compiles a selector where `*` matches any run of characters and every other
373
+ * character is literal, mirroring `@pnpm/config.matcher`'s wildcard semantics
374
+ * so epic membership globs behave like pnpm's other package selectors.
375
+ */
376
+ function wildcardMatch(pattern) {
377
+ if (pattern === '*')
378
+ return () => true;
379
+ let source = '^';
380
+ for (const character of pattern) {
381
+ source += character === '*' ? '.*' : character.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
382
+ }
383
+ source += '$';
384
+ const regexp = new RegExp(source);
385
+ return (input) => regexp.test(input);
386
+ }
387
+ /**
388
+ * Rejects epic configurations that cannot be attributed unambiguously: a
389
+ * package matched by two epics, and a fixed group that straddles an epic
390
+ * boundary (a group must sit entirely inside or entirely outside an epic, so
391
+ * its members never disagree on whether they are band-constrained).
392
+ */
393
+ function validateEpics(epics, fixedGroups) {
394
+ const epicOfMember = new Map();
395
+ for (const epic of epics) {
396
+ for (const memberDir of epic.memberDirs) {
397
+ const other = epicOfMember.get(memberDir);
398
+ if (other != null && other !== epic.leadRef) {
399
+ throw new PnpmError('VERSIONING_EPIC_OVERLAP', `Package ./${memberDir} is matched by two epics (leads "${other}" and "${epic.leadRef}"). A package can belong to at most one epic.`);
400
+ }
401
+ epicOfMember.set(memberDir, epic.leadRef);
402
+ }
403
+ }
404
+ for (const epic of epics) {
405
+ for (const group of fixedGroups) {
406
+ if (!group.some((dir) => epic.memberDirs.has(dir)))
407
+ continue;
408
+ const outsiders = group.filter((dir) => !epic.memberDirs.has(dir));
409
+ if (outsiders.length > 0) {
410
+ throw new PnpmError('VERSIONING_EPIC_FIXED_GROUP_CONFLICT', `A fixed group straddles the epic led by "${epic.leadRef}": it mixes epic members with outside package(s) ${outsiders.map((dir) => `./${dir}`).join(', ')}. A fixed group must sit entirely inside or entirely outside an epic.`);
411
+ }
412
+ }
413
+ }
414
+ }
415
+ /**
416
+ * Resolves every intent's package references to participant directories,
417
+ * validating along the way: unknown references and names matching several
418
+ * projects are hard errors, and a release can only be demanded from a
419
+ * participant — otherwise the intent could never be consumed and the file
420
+ * would linger forever. A `none` decline is fine for any workspace package.
421
+ */
422
+ function resolveIntents(intents, refs, participants) {
423
+ const intentBumps = new Map();
424
+ for (const intent of intents) {
425
+ const byDir = new Map();
426
+ for (const [ref, bumpType] of Object.entries(intent.releases)) {
427
+ const dirs = refs.refToDirs(ref);
428
+ if (dirs.length === 0) {
429
+ throw new PnpmError('VERSIONING_UNKNOWN_PACKAGE', `Change intent file ${intent.filePath} names ${ref}, which is not a package in this workspace`);
430
+ }
431
+ if (dirs.length > 1) {
432
+ throw new PnpmError('VERSIONING_AMBIGUOUS_PACKAGE', `Change intent file ${intent.filePath} names ${ref}, which matches multiple workspace projects: ${dirs.map((dir) => `./${dir}`).join(', ')}. ` +
433
+ 'Reference the project by directory instead, e.g. "./' + dirs[0] + '": ' + bumpType);
434
+ }
435
+ const dir = dirs[0];
436
+ if (bumpType !== 'none' && !participants.has(dir)) {
437
+ throw new PnpmError('VERSIONING_UNRELEASABLE_PACKAGE', `Change intent file ${intent.filePath} requests a ${bumpType} release of ${ref}, which cannot release ` +
438
+ '(it is listed in versioning.ignore, has no version field, or has a non-semver version). ' +
439
+ 'Remove the entry or change it to "none".');
440
+ }
441
+ const existing = byDir.get(dir);
442
+ if (existing == null || (bumpType !== 'none' && BUMP_ORDER[bumpType] > (existing === 'none' ? 0 : BUMP_ORDER[existing]))) {
443
+ byDir.set(dir, bumpType);
444
+ }
445
+ }
446
+ intentBumps.set(intent.id, byDir);
447
+ }
448
+ return intentBumps;
449
+ }
450
+ function assertInternalDepsUseWorkspaceProtocol(participants) {
451
+ for (const participant of participants.values()) {
452
+ for (const dep of participant.internalDeps) {
453
+ if (!dep.spec.startsWith('workspace:')) {
454
+ throw new PnpmError('VERSIONING_INTERNAL_RANGE', `Package ${participant.name} declares the internal dependency ${dep.alias} in ${dep.fieldName} as "${dep.spec}". ` +
455
+ 'Internal dependencies must use the workspace: protocol so that dependency ranges never need rewriting at release time.');
456
+ }
457
+ }
458
+ }
459
+ }
460
+ function collectPendingIntents(ctx) {
461
+ const pending = new Map();
462
+ for (const dir of ctx.participants.keys()) {
463
+ const consumed = ctx.consumptionOf(dir);
464
+ const pkgIntents = ctx.opts.intents.filter((intent) => {
465
+ const bump = ctx.intentBumps.get(intent.id)?.get(dir);
466
+ return bump != null && bump !== 'none' && !consumed.allIds.has(intent.id);
467
+ });
468
+ if (pkgIntents.length > 0) {
469
+ pending.set(dir, pkgIntents);
470
+ }
471
+ }
472
+ return pending;
473
+ }
474
+ /**
475
+ * Intents already consumed by prereleases of a package that has not graduated
476
+ * to a stable version yet. They participate in the cumulative bump computation
477
+ * of the package's lane and compose the stable changelog section at
478
+ * graduation.
479
+ */
480
+ function collectLaneConsumedIntents(ctx) {
481
+ const laneConsumed = new Map();
482
+ for (const dir of ctx.participants.keys()) {
483
+ const consumed = ctx.consumptionOf(dir);
484
+ if (consumed.prereleaseOnlyIds.size === 0)
485
+ continue;
486
+ const pkgIntents = ctx.opts.intents.filter((intent) => {
487
+ const bump = ctx.intentBumps.get(intent.id)?.get(dir);
488
+ return bump != null && bump !== 'none' && consumed.prereleaseOnlyIds.has(intent.id);
489
+ });
490
+ if (pkgIntents.length > 0) {
491
+ laneConsumed.set(dir, pkgIntents);
492
+ }
493
+ }
494
+ return laneConsumed;
495
+ }
496
+ function maxBumpType(types) {
497
+ let result = null;
498
+ for (const type of types) {
499
+ if (type !== 'patch' && type !== 'minor' && type !== 'major')
500
+ continue;
501
+ if (result == null || BUMP_ORDER[type] > BUMP_ORDER[result]) {
502
+ result = type;
503
+ }
504
+ }
505
+ return result;
506
+ }
507
+ function computeNewVersion(participant, bumpType, opts) {
508
+ const current = participant.currentVersion;
509
+ if (opts.laneTag == null) {
510
+ if (parsePrerelease(current) == null) {
511
+ return inc(current, bumpType);
512
+ }
513
+ // Graduation: the accumulated stable version the lane was
514
+ // building toward.
515
+ return escalateStableTarget(stablePart(current), opts.cumulativeBump);
516
+ }
517
+ const target = parsePrerelease(current) == null
518
+ ? inc(current, opts.cumulativeBump)
519
+ : escalateStableTarget(stablePart(current), opts.cumulativeBump);
520
+ return `${target}-${opts.laneTag}.${nextPrereleaseNumber(current, target, opts.laneTag)}`;
521
+ }
522
+ /**
523
+ * Re-derives the stable version a lane is building toward when the
524
+ * cumulative bump escalates. The invariant: the stable part of the current
525
+ * prerelease already reflects the previous cumulative bump applied to the
526
+ * version the line started from, so only an escalation changes it.
527
+ */
528
+ function escalateStableTarget(target, cumulativeBump) {
529
+ const [major, minor, patch] = target.split('.').map(Number);
530
+ switch (cumulativeBump) {
531
+ case 'major':
532
+ return minor === 0 && patch === 0 ? target : `${major + 1}.0.0`;
533
+ case 'minor':
534
+ return patch === 0 ? target : `${major}.${minor + 1}.0`;
535
+ case 'patch':
536
+ return target;
537
+ }
538
+ }
539
+ function stablePart(version) {
540
+ return version.split('-')[0];
541
+ }
542
+ function nextPrereleaseNumber(current, target, laneTag) {
543
+ const currentPrerelease = parsePrerelease(current);
544
+ if (currentPrerelease == null)
545
+ return 0;
546
+ const [currentTag, currentN] = currentPrerelease;
547
+ // semver parses an all-digit prerelease identifier as a number, so the tag
548
+ // comparison must not be strict about the type.
549
+ if (stablePart(current) !== target || String(currentTag) !== laneTag || typeof currentN !== 'number')
550
+ return 0;
551
+ return currentN + 1;
552
+ }
553
+ function applyFixedGroupVersions({ participants, state, newVersions, cumulativeBump, fixedGroups, lanesByDir }) {
554
+ for (const group of fixedGroups) {
555
+ const bumpedMembers = group.filter((dir) => state.has(dir));
556
+ if (bumpedMembers.length === 0)
557
+ continue;
558
+ const groupBump = maxBumpType(bumpedMembers.map((dir) => cumulativeBump(dir, state.get(dir).bumpType)));
559
+ const highestCurrent = group
560
+ .map((dir) => participants.get(dir).currentVersion)
561
+ .sort(compare)
562
+ .at(-1);
563
+ const target = parsePrerelease(highestCurrent) == null
564
+ ? inc(highestCurrent, groupBump)
565
+ : escalateStableTarget(stablePart(highestCurrent), groupBump);
566
+ const laneTag = lanesByDir.get(group[0]);
567
+ let sharedVersion = target;
568
+ if (laneTag != null) {
569
+ const nextN = Math.max(...group.map((dir) => nextPrereleaseNumber(participants.get(dir).currentVersion, target, laneTag)));
570
+ sharedVersion = `${target}-${laneTag}.${nextN}`;
571
+ }
572
+ for (const dir of group) {
573
+ if (state.has(dir)) {
574
+ newVersions.set(dir, sharedVersion);
575
+ }
576
+ }
577
+ }
578
+ }
579
+ /**
580
+ * The band floor (`newMajor × 100`) an epic re-bases its members to, or null
581
+ * when no re-base is due. A re-base fires only when the lead releases to a
582
+ * new, higher *stable* major in this plan; a prerelease lead version (the lead
583
+ * on a lane) defers the re-base until its stable release.
584
+ */
585
+ function epicRebaseFloor(epic, participants, newVersions) {
586
+ const lead = participants.get(epic.leadDir);
587
+ const newLeadVersion = newVersions.get(epic.leadDir);
588
+ if (lead == null || newLeadVersion == null || parsePrerelease(newLeadVersion) != null)
589
+ return null;
590
+ const newMajor = Number(newLeadVersion.split('.')[0]);
591
+ const currentMajor = Number(lead.currentVersion.split('.')[0]);
592
+ return newMajor > currentMajor ? newMajor * 100 : null;
593
+ }
594
+ /**
595
+ * Overrides the computed version of every bumped epic member with the band
596
+ * floor when its lead crosses to a new stable major. A member on a lane
597
+ * re-bases to a prerelease of the floor; every other member to `floor.0.0`.
598
+ */
599
+ function applyEpicBandVersions({ participants, state, newVersions, epics, lanesByDir }) {
600
+ for (const epic of epics) {
601
+ const floor = epicRebaseFloor(epic, participants, newVersions);
602
+ if (floor == null)
603
+ continue;
604
+ const target = `${floor}.0.0`;
605
+ for (const memberDir of epic.memberDirs) {
606
+ if (!state.has(memberDir))
607
+ continue;
608
+ const laneTag = lanesByDir.get(memberDir);
609
+ newVersions.set(memberDir, laneTag == null
610
+ ? target
611
+ : `${target}-${laneTag}.${nextPrereleaseNumber(participants.get(memberDir).currentVersion, target, laneTag)}`);
612
+ }
613
+ }
614
+ }
615
+ /**
616
+ * The band of member majors an epic permits: `[leadMajor×100, leadMajor×100+99]`,
617
+ * where `leadMajor` is the major the plan establishes for the lead — its
618
+ * re-based major when the lead crosses to a new stable major, otherwise the
619
+ * lead's current major (a prerelease lead does not open the next band).
620
+ */
621
+ function epicBandMajor(epic, participants, newVersions) {
622
+ const floor = epicRebaseFloor(epic, participants, newVersions);
623
+ return floor != null ? floor / 100 : Number(participants.get(epic.leadDir).currentVersion.split('.')[0]);
624
+ }
625
+ /**
626
+ * Enforces that every released member's new major stays inside its epic's band.
627
+ * The re-base already keeps members in band when the lead moves; this guards
628
+ * the other direction — an ordinary `major` intent that would carry a member
629
+ * over the band ceiling (`1199.x` → `1200.0.0` while the lead is still on 11)
630
+ * is rejected rather than silently landing the member in the next band.
631
+ */
632
+ function enforceEpicBands(epics, participants, newVersions) {
633
+ for (const epic of epics) {
634
+ const bandMajor = epicBandMajor(epic, participants, newVersions);
635
+ const low = bandMajor * 100;
636
+ const high = low + 99;
637
+ for (const memberDir of epic.memberDirs) {
638
+ const memberVersion = newVersions.get(memberDir);
639
+ if (memberVersion == null)
640
+ continue;
641
+ const memberMajor = Number(memberVersion.split('.')[0]);
642
+ if (memberMajor < low || memberMajor > high) {
643
+ throw new PnpmError('VERSIONING_EPIC_OUT_OF_BAND', `The release plan takes ${participants.get(memberDir).name} to ${memberVersion}, whose major ${memberMajor} is outside the band ${low}-${high} of the epic led by "${epic.leadRef}" (major ${bandMajor}). ` +
644
+ (memberMajor > high
645
+ ? 'The band is exhausted - the lead must advance to a new major to open the next band.'
646
+ : 'Re-base the member into the band, or remove it from the epic.'));
647
+ }
648
+ }
649
+ }
650
+ }
651
+ /**
652
+ * The range that pnpm materializes for a workspace: spec at pack time, given
653
+ * the dependency's version at the dependent's previous release. Dependent
654
+ * propagation republishes the dependent whenever the dependency's new version
655
+ * falls outside this range.
656
+ */
657
+ export function materializeWorkspaceRange(spec, depCurrentVersion) {
658
+ const parsed = WorkspaceSpec.parse(spec);
659
+ if (parsed == null)
660
+ return null;
661
+ switch (parsed.version) {
662
+ case '^':
663
+ return `^${depCurrentVersion}`;
664
+ case '~':
665
+ return `~${depCurrentVersion}`;
666
+ case '*':
667
+ case '':
668
+ return depCurrentVersion;
669
+ default:
670
+ return parsed.version;
671
+ }
672
+ }
673
+ function enforceMaxBump(releases, versioning) {
674
+ const maxBump = versioning?.maxBump;
675
+ if (maxBump == null)
676
+ return;
677
+ for (const release of releases) {
678
+ const effectiveBump = effectiveBumpClass(release);
679
+ if (BUMP_ORDER[effectiveBump] <= BUMP_ORDER[maxBump])
680
+ continue;
681
+ const intentFiles = release.intents
682
+ .filter((intent) => Object.values(intent.releases).includes(effectiveBump))
683
+ .map((intent) => intent.filePath);
684
+ const raisedBy = intentFiles.length > 0 ? `intent file(s) ${intentFiles.join(', ')}` : `constraint chain: ${release.causes.join(', ')}`;
685
+ throw new PnpmError('VERSIONING_MAX_BUMP_EXCEEDED', `The release plan bumps ${release.name} by ${effectiveBump}, but versioning.maxBump caps releases from this branch at ${maxBump}. Raised by ${raisedBy}.`);
686
+ }
687
+ }
688
+ /**
689
+ * The bump class a release actually applies. Fixed-group version sharing and
690
+ * lane escalation can move a version further than the package's own
691
+ * declared or propagated bump, so the cap compares against the real distance
692
+ * between the current and the new version as well.
693
+ */
694
+ function effectiveBumpClass(release) {
695
+ const diffClass = diff(release.currentVersion, release.newVersion);
696
+ const normalized = diffClass?.replace(/^pre(?!release)/, '');
697
+ return maxBumpType([release.bumpType, normalized ?? undefined]) ?? release.bumpType;
698
+ }
699
+ //# sourceMappingURL=assembleReleasePlan.js.map