@pnpm/releasing.versioning 1100.2.0 → 1100.2.2

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