fg-devkit 1.28.0 → 1.28.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.
@@ -18,7 +18,7 @@
18
18
  "@lexical/rich-text": "0.53.0",
19
19
  "@piwikpro/vue-piwik-pro": "2.0.0",
20
20
  "@sentry/vue": "11.10.0",
21
- "@tanstack/vue-query": "5.105.0",
21
+ "@tanstack/vue-query": "5.105.1",
22
22
  "@unhead/vue": "3.4.3",
23
23
  "@vuelidate/core": "2.0.3",
24
24
  "@vuelidate/validators": "2.0.4",
@@ -44,7 +44,7 @@
44
44
  "vega-embed": "7.3.0",
45
45
  "vega-lite": "6.5.0",
46
46
  "vue-chartjs": "5.3.4",
47
- "vue-quilly": "1.1.6",
47
+ "vue-quilly": "1.2.0",
48
48
  "vue3-touch-keyboard": "1.3.9",
49
49
  "vuex": "4.1.0"
50
50
  },
@@ -57,20 +57,20 @@
57
57
  "@storybook/addon-vitest": "10.6.5",
58
58
  "@storybook/vue3": "10.6.5",
59
59
  "@storybook/vue3-vite": "10.6.5",
60
- "@tanstack/vue-query-devtools": "6.3.6",
60
+ "@tanstack/vue-query-devtools": "6.3.7",
61
61
  "@types/lodash-es": "4.17.12",
62
62
  "@types/node": "26.6.6",
63
63
  "@vitejs/plugin-vue": "6.0.10",
64
64
  "@vitest/browser": "5.0.5",
65
65
  "@vitest/browser-playwright": "5.0.5",
66
66
  "@vue/compiler-dom": "3.5.44",
67
- "@vue/language-core": "3.3.13",
67
+ "@vue/language-core": "3.3.14",
68
68
  "@vue/test-utils": "2.5.1",
69
- "fg-devkit": "1.27.2",
69
+ "fg-devkit": "1.28.1",
70
70
  "happy-dom": "21.0.4",
71
71
  "jsdom": "30.1.3",
72
72
  "randexp": "0.6.1",
73
- "sass": "1.107.0",
73
+ "sass": "1.107.1",
74
74
  "storybook": "10.6.5",
75
75
  "vega": "6.4.0",
76
76
  "vite": "8.3.5",
@@ -87,7 +87,7 @@
87
87
  "playwright": "1.64.1",
88
88
  "tailwindcss": "4.3.3",
89
89
  "vue": "3.5.44",
90
- "vue-i18n": "11.4.16",
90
+ "vue-i18n": "11.4.17",
91
91
  "vue-router": "5.4.0"
92
92
  },
93
93
  "overrides": {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fg-devkit",
3
- "version": "1.28.0",
3
+ "version": "1.28.2",
4
4
  "description": "Shared configs, scripts, and DX tooling for Vue / TypeScript frontend repos",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -56,11 +56,11 @@
56
56
  "@typescript-eslint/parser": "8.72.1",
57
57
  "@vitest/eslint-plugin": "1.6.28",
58
58
  "@vue/eslint-config-prettier": "10.2.0",
59
- "eslint": "10.13.0",
59
+ "eslint": "10.14.0",
60
60
  "eslint-plugin-prettier": "5.5.7",
61
61
  "eslint-plugin-vue": "10.12.0",
62
62
  "eslint-plugin-vuejs-accessibility": "2.6.3",
63
- "fallow": "3.37.0",
63
+ "fallow": "3.38.1",
64
64
  "git-cliff": "2.14.2",
65
65
  "lefthook": "2.2.2",
66
66
  "npm-check-updates": "23.1.1",
@@ -68,7 +68,7 @@
68
68
  "tsx": "4.23.15",
69
69
  "typescript": "5.9.3",
70
70
  "vue-eslint-parser": "10.4.1",
71
- "vue-tsc": "3.3.13"
71
+ "vue-tsc": "3.3.14"
72
72
  },
73
73
  "scripts": {
74
74
  "fgd-ui": "pnpm --dir electron/maintainer-ui start",
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@tailwindcss/vite": "4.3.3",
19
- "@tanstack/vue-query": "5.105.0",
19
+ "@tanstack/vue-query": "5.105.1",
20
20
  "axios": "1.21.0",
21
21
  "content-disposition-header": "0.6.0",
22
22
  "date-fns": "4.4.0",
@@ -25,14 +25,14 @@
25
25
  "pinia": "4.1.0",
26
26
  "qrcode.vue": "3.11.0",
27
27
  "vue": "3.5.44",
28
- "vue-i18n": "11.4.16",
28
+ "vue-i18n": "11.4.17",
29
29
  "vue-router": "5.4.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@intlify/unplugin-vue-i18n": "11.2.8",
33
- "@tanstack/vue-query-devtools": "6.3.6",
33
+ "@tanstack/vue-query-devtools": "6.3.7",
34
34
  "@types/node": "26.6.6",
35
- "fg-devkit": "1.28.0",
35
+ "fg-devkit": "1.28.2",
36
36
  "@vitejs/plugin-vue": "6.0.10",
37
37
  "tailwindcss": "4.3.3",
38
38
  "tsx": "4.23.15",
@@ -44,6 +44,7 @@ import {
44
44
  isReleaseAgeExcluded,
45
45
  isSameReleaseLine,
46
46
  normalizeAdvisories,
47
+ overrideKey,
47
48
  parseAuditJson,
48
49
  parseReleaseAgeExcludeEntry,
49
50
  planOverrides,
@@ -294,7 +295,7 @@ async function chooseForcedFixes(plan) {
294
295
  for (const item of plan.crossMajor) {
295
296
  const message = `${item.name} ${item.from} → ${item.to} (new major, ${ghsaList(item)}). Override?`
296
297
  if (await decide(message, 'allow-major')) {
297
- overrides.push({ ...item, key: `${item.name}@>=${item.from} <${item.to}`, value: item.to, forced: true })
298
+ overrides.push({ ...item, key: overrideKey(item.name, item.from, item.to), value: item.to, forced: true })
298
299
  }
299
300
  }
300
301
 
@@ -449,7 +450,7 @@ function verify() {
449
450
  }
450
451
  }
451
452
 
452
- function printSummary({ fixedByRefresh, plan, forced, heldBack, applied, remaining, suppressions, excludeChanges }) {
453
+ function printSummary({ fixedByRefresh, plan, forced, heldBack, applied, pruned, remaining, suppressions, excludeChanges }) {
453
454
  const suppressed = new Set(suppressions.covered.map((advisory) => advisory.ghsa))
454
455
  const unique = (items) => [...new Set(items)]
455
456
  const lines = []
@@ -471,6 +472,10 @@ function printSummary({ fixedByRefresh, plan, forced, heldBack, applied, remaini
471
472
  .filter((item) => item.forced)
472
473
  .map((item) => `${item.key} -> ${item.value} (${item.ghsas.join(', ')}${youngNote(item)})`),
473
474
  )
475
+ section(
476
+ 'Override had no effect and was pruned (no parent range matched it, check pnpm-workspace.yaml by hand):',
477
+ pruned.map((item) => `${item.key} -> ${item.value} (${item.ghsas.join(', ')})`),
478
+ )
474
479
  section(
475
480
  'Bumped in package.json:',
476
481
  forced.bumps.map(
@@ -542,6 +547,7 @@ async function main() {
542
547
  let forced = { overrides: [], bumps: [] }
543
548
  let heldBack = []
544
549
  let allowed = []
550
+ let pruned = []
545
551
  let remaining = []
546
552
  let afterBumps
547
553
 
@@ -581,6 +587,9 @@ async function main() {
581
587
 
582
588
  // 4. Prune overrides.
583
589
  pruneOverrides()
590
+ const overridesAfterPrune = collectOverrides(cwd)
591
+ pruned = allowed.filter((item) => !(item.key in overridesAfterPrune))
592
+ allowed = allowed.filter((item) => item.key in overridesAfterPrune)
584
593
  remaining = allowed.length > 0 || forced.bumps.length > 0 ? audit() : afterRefresh
585
594
  }
586
595
  const afterOverrides = snapshot()
@@ -588,7 +597,7 @@ async function main() {
588
597
  // 5. Release-age excludes, 6. suppressions, 7. verify.
589
598
  const excludeChanges = pruneReleaseAgeExclude()
590
599
  const suppressions = syncSuppressions(remaining)
591
- printSummary({ fixedByRefresh, plan, forced, heldBack, applied: allowed, remaining, suppressions, excludeChanges })
600
+ printSummary({ fixedByRefresh, plan, forced, heldBack, applied: allowed, pruned, remaining, suppressions, excludeChanges })
592
601
 
593
602
  const final = snapshot()
594
603
  const nothingChanged = !suppressionsWritten && TRACKED_FILES.every((file) => final[file] === original[file])
@@ -71,6 +71,38 @@ export function isSameReleaseLine(from, to) {
71
71
  return a.patch === b.patch
72
72
  }
73
73
 
74
+ /** First version of the release line `version` belongs to: `1.0.0` for 1.4.2, `0.12.0` for 0.12.4. */
75
+ export function releaseLineFloor(version) {
76
+ const parsed = parseVersion(version)
77
+ if (!parsed) return null
78
+ if (parsed.major > 0) return `${parsed.major}.0.0`
79
+ if (parsed.minor > 0) return `0.${parsed.minor}.0`
80
+ return `0.0.${parsed.patch}`
81
+ }
82
+
83
+ /** First version after the release line `version` belongs to: `2.0.0` for 1.4.2, `0.13.0` for 0.12.4. */
84
+ export function releaseLineCeiling(version) {
85
+ const parsed = parseVersion(version)
86
+ if (!parsed) return null
87
+ if (parsed.major > 0) return `${parsed.major + 1}.0.0`
88
+ if (parsed.minor > 0) return `0.${parsed.minor + 1}.0`
89
+ return `0.0.${parsed.patch + 1}`
90
+ }
91
+
92
+ /**
93
+ * Override key for moving `from` up to `to`. pnpm applies a key when its range overlaps the
94
+ * range a parent requests, not the installed version, so the lower bound is the release line
95
+ * floor of `from`: a version lifted by an earlier override can sit outside the requested range.
96
+ * A prerelease keeps its own version as the lower bound, since `>=1.0.0` does not match
97
+ * `1.0.0-beta.1`. Across a major the key stops at the end of `from`'s release line so it
98
+ * never catches parents that already ask for the new major.
99
+ */
100
+ export function overrideKey(name, from, to) {
101
+ const low = parseVersion(from)?.prerelease.length ? from : releaseLineFloor(from)
102
+ const high = isSameReleaseLine(from, to) ? to : releaseLineCeiling(from)
103
+ return `${name}@>=${low} <${high}`
104
+ }
105
+
74
106
  /**
75
107
  * Lowest patched version strictly above `installed`, read from an advisory's
76
108
  * `patched_versions` (e.g. `>=1.2.2`, `>=1.1.21 <2.0.0 || >=2.1.7`).
@@ -140,8 +172,8 @@ export function normalizeAdvisories(report) {
140
172
  * Decide what to do with advisories that survived the lockfile refresh.
141
173
  *
142
174
  * For every installed vulnerable version the target is the lowest version that fixes all
143
- * advisories affecting it. Targets in the same release line become one override per range,
144
- * keyed tightly (`name@>=installed <target`) so nothing outside the vulnerable span moves.
175
+ * advisories affecting it. Installed versions in the same release line become one override,
176
+ * keyed from the release line floor (`name@>=1.0.0 <target`) so it matches the parent's range.
145
177
  *
146
178
  * @param {ReturnType<typeof normalizeAdvisories>} advisories
147
179
  * @param {{ directDependencies?: Set<string> }} [options]
@@ -205,24 +237,25 @@ export function planOverrides(advisories, { directDependencies = new Set() } = {
205
237
  .map(([installed, { target, ghsas: spanGhsas }]) => ({ low: installed, high: target, ghsas: spanGhsas }))
206
238
  .sort((a, b) => compareVersions(a.low, b.low))
207
239
 
208
- const merged = []
240
+ const merged = new Map()
209
241
  for (const span of spans) {
210
- const last = merged.at(-1)
211
- if (last && compareVersions(span.low, last.high) < 0) {
242
+ const floor = releaseLineFloor(span.low)
243
+ const last = merged.get(floor)
244
+ if (last) {
212
245
  if (compareVersions(span.high, last.high) > 0) last.high = span.high
213
246
  for (const ghsa of span.ghsas) last.ghsas.add(ghsa)
214
247
  } else {
215
- merged.push({ ...span, ghsas: new Set(span.ghsas) })
248
+ merged.set(floor, { ...span, ghsas: new Set(span.ghsas) })
216
249
  }
217
250
  }
218
251
 
219
- for (const span of merged) {
252
+ for (const span of merged.values()) {
220
253
  const entry = { name, from: span.low, to: span.high, ghsas: [...span.ghsas] }
221
254
  if (!isSameReleaseLine(span.low, span.high)) {
222
255
  plan.crossMajor.push(entry)
223
256
  continue
224
257
  }
225
- plan.overrides.push({ ...entry, key: `${name}@>=${span.low} <${span.high}`, value: span.high })
258
+ plan.overrides.push({ ...entry, key: overrideKey(name, span.low, span.high), value: span.high })
226
259
  }
227
260
  }
228
261
 
@@ -288,9 +321,73 @@ function unquoteYaml(value) {
288
321
  return text
289
322
  }
290
323
 
324
+ /**
325
+ * `{ name, low, lowInclusive, high, highInclusive }` for keys like `name@>=1.0.0 <1.6.11`,
326
+ * `name@<=8.5.17`, `name@^1.2.0`, `name@~1.2.0` or `name@1.2.3`, else null. A null bound is open.
327
+ */
328
+ function overrideKeyRange(key) {
329
+ const at = key.lastIndexOf('@')
330
+ if (at <= 0) return null
331
+ const range = { name: key.slice(0, at), low: null, lowInclusive: true, high: null, highInclusive: false }
332
+ const parts = key.slice(at + 1).trim().split(/\s+/)
333
+ const shorthand = parts.length === 1 && parts[0].match(/^([~^])(.+)$/)
334
+ if (shorthand) {
335
+ const version = parseVersion(shorthand[2])
336
+ if (!version) return null
337
+ range.low = shorthand[2]
338
+ range.high =
339
+ shorthand[1] === '^' ? releaseLineCeiling(shorthand[2]) : `${version.major}.${version.minor + 1}.0`
340
+ return range
341
+ }
342
+ for (const part of parts) {
343
+ const match = part.match(/^(>=|>|<=|<|=)?(.+)$/)
344
+ if (!match || !parseVersion(match[2])) return null
345
+ const operator = match[1] ?? '='
346
+ if (operator.startsWith('>') || operator === '=') {
347
+ range.low = match[2]
348
+ range.lowInclusive = operator !== '>'
349
+ }
350
+ if (operator.startsWith('<') || operator === '=') {
351
+ range.high = match[2]
352
+ range.highInclusive = operator !== '<'
353
+ }
354
+ }
355
+ return range
356
+ }
357
+
358
+ function startsBeforeEnd(start, end) {
359
+ if (start.low === null || end.high === null) return true
360
+ const order = compareVersions(start.low, end.high)
361
+ return order < 0 || (order === 0 && start.lowInclusive && end.highInclusive)
362
+ }
363
+
364
+ function rangesOverlap(a, b) {
365
+ return startsBeforeEnd(a, b) && startsBeforeEnd(b, a)
366
+ }
367
+
368
+ function rangeContains(outer, inner) {
369
+ const lowOrder = outer.low === null ? -1 : inner.low === null ? 1 : compareVersions(outer.low, inner.low)
370
+ const highOrder = outer.high === null ? -1 : inner.high === null ? 1 : compareVersions(inner.high, outer.high)
371
+ const lowOk = lowOrder < 0 || (lowOrder === 0 && (outer.lowInclusive || !inner.lowInclusive))
372
+ const highOk = highOrder < 0 || (highOrder === 0 && (outer.highInclusive || !inner.highInclusive))
373
+ return lowOk && highOk
374
+ }
375
+
376
+ /** How an existing key relates to `entry`: 'replaced', 'overlaps' (same package) or null. */
377
+ function relationTo(key, value, entry) {
378
+ const existing = overrideKeyRange(key)
379
+ const next = overrideKeyRange(entry.key)
380
+ if (!existing || !next || existing.name !== next.name || key === entry.key) return null
381
+ if (!rangesOverlap(existing, next)) return null
382
+ const lowerTarget = parseVersion(value) && compareVersions(value, entry.value) <= 0
383
+ return lowerTarget && rangeContains(next, existing) ? 'replaced' : 'overlaps'
384
+ }
385
+
291
386
  /**
292
387
  * Add or replace entries in the top-level `overrides:` map of pnpm-workspace.yaml,
293
- * keeping every other line untouched. Creates the section when missing.
388
+ * keeping every other line untouched. Creates the section when missing. pnpm uses the first
389
+ * matching key, so an older key for the same package that lies inside a new entry's range is
390
+ * removed, and a new entry that overlaps a key it cannot replace goes in before that key.
294
391
  *
295
392
  * @param {string} text
296
393
  * @param {{ key: string, value: string }[]} entries
@@ -314,17 +411,40 @@ export function upsertWorkspaceOverrides(text, entries) {
314
411
  while (lastContent > start && lines[lastContent] === '') lastContent -= 1
315
412
 
316
413
  const pending = new Map(entries.map((entry) => [entry.key, entry]))
414
+ const removed = new Set()
415
+ const insertBefore = new Map()
317
416
  for (let i = start + 1; i <= lastContent; i += 1) {
318
- const match = lines[i].match(/^(\s+)((?:'(?:[^']|'')*'|"[^"]*"|[^:#]+)):/)
417
+ const match = lines[i].match(/^(\s+)((?:'(?:[^']|'')*'|"[^"]*"|[^:#]+)):\s*(.*?)\s*(?:#.*)?$/)
319
418
  if (!match) continue
320
419
  const key = unquoteYaml(match[2])
321
420
  if (pending.has(key)) {
322
421
  lines[i] = rendered(pending.get(key))
323
422
  pending.delete(key)
423
+ continue
424
+ }
425
+ const value = unquoteYaml(match[3])
426
+ if (entries.some((entry) => relationTo(key, value, entry) === 'replaced')) {
427
+ removed.add(i)
428
+ continue
429
+ }
430
+ for (const entry of entries) {
431
+ if (pending.has(entry.key) && relationTo(key, value, entry) === 'overlaps' && !insertBefore.has(entry.key)) {
432
+ insertBefore.set(entry.key, i)
433
+ }
324
434
  }
325
435
  }
326
- lines.splice(lastContent + 1, 0, ...[...pending.values()].map(rendered))
327
- return lines.join('\n')
436
+ const result = []
437
+ for (let i = 0; i < lines.length; i += 1) {
438
+ for (const [key, index] of insertBefore) {
439
+ if (index === i && pending.has(key)) {
440
+ result.push(rendered(pending.get(key)))
441
+ pending.delete(key)
442
+ }
443
+ }
444
+ if (!removed.has(i)) result.push(lines[i])
445
+ if (i === lastContent) result.push(...[...pending.values()].map(rendered))
446
+ }
447
+ return result.join('\n')
328
448
  }
329
449
 
330
450
  // ---------------------------------------------------------------------------
@@ -13,8 +13,10 @@ import {
13
13
  isSameReleaseLine,
14
14
  lowestPatchedVersion,
15
15
  normalizeAdvisories,
16
+ overrideKey,
16
17
  planOverrides,
17
18
  planReleaseAgeExcludePrune,
19
+ releaseLineFloor,
18
20
  readWorkspaceList,
19
21
  rewriteWorkspaceList,
20
22
  upsertWorkspaceOverrides,
@@ -108,23 +110,44 @@ test('planOverrides picks the lowest version that fixes every advisory, per rele
108
110
  assert.deepEqual(
109
111
  plan.overrides.map(({ key, value }) => ({ key, value })),
110
112
  [
111
- { key: 'brace-expansion@>=1.1.18 <1.1.21', value: '1.1.21' },
112
- { key: 'brace-expansion@>=2.1.4 <2.1.7', value: '2.1.7' },
113
+ { key: 'brace-expansion@>=1.0.0 <1.1.21', value: '1.1.21' },
114
+ { key: 'brace-expansion@>=2.0.0 <2.1.7', value: '2.1.7' },
113
115
  ],
114
116
  )
115
117
  assert.deepEqual(plan.overrides[0].ghsas.sort(), ['GHSA-1', 'GHSA-2'])
116
118
  })
117
119
 
118
- test('planOverrides merges overlapping spans of the same package', () => {
120
+ test('planOverrides merges installed versions of the same release line', () => {
119
121
  const plan = planOverrides([
120
122
  advisory({ name: 'x', ghsa: 'GHSA-1', vulnerable: '<1.4.0', patched: '>=1.4.0', versions: ['1.2.0', '1.3.0'] }),
121
123
  ])
122
124
  assert.deepEqual(
123
125
  plan.overrides.map(({ key, value }) => ({ key, value })),
124
- [{ key: 'x@>=1.2.0 <1.4.0', value: '1.4.0' }],
126
+ [{ key: 'x@>=1.0.0 <1.4.0', value: '1.4.0' }],
125
127
  )
126
128
  })
127
129
 
130
+ test('planOverrides keys from the release line floor so a version lifted by an earlier override still matches', () => {
131
+ const plan = planOverrides([
132
+ advisory({ name: 'seroval', ghsa: 'GHSA-1', vulnerable: '<1.6.11', patched: '>=1.6.11', versions: ['1.6.3'] }),
133
+ advisory({ name: 'old', ghsa: 'GHSA-2', vulnerable: '<0.12.5', patched: '>=0.12.5', versions: ['0.12.4'] }),
134
+ ])
135
+ assert.deepEqual(
136
+ plan.overrides.map(({ key, value }) => ({ key, value })),
137
+ [
138
+ { key: 'seroval@>=1.0.0 <1.6.11', value: '1.6.11' },
139
+ { key: 'old@>=0.12.0 <0.12.5', value: '0.12.5' },
140
+ ],
141
+ )
142
+ })
143
+
144
+ test('releaseLineFloor follows caret semantics', () => {
145
+ assert.equal(releaseLineFloor('1.6.3'), '1.0.0')
146
+ assert.equal(releaseLineFloor('0.12.4'), '0.12.0')
147
+ assert.equal(releaseLineFloor('0.0.3'), '0.0.3')
148
+ assert.equal(releaseLineFloor('nope'), null)
149
+ })
150
+
128
151
  test('planOverrides reports instead of overriding when it would be unsafe or useless', () => {
129
152
  const plan = planOverrides(
130
153
  [
@@ -245,6 +268,88 @@ test('upsertWorkspaceOverrides replaces matching keys and appends new ones in pl
245
268
  )
246
269
  })
247
270
 
271
+ test('upsertWorkspaceOverrides removes older overlapping keys of the same package', () => {
272
+ const text = [
273
+ 'overrides:',
274
+ " 'seroval@>=1.5.6 <1.6.3': '1.6.3'",
275
+ " 'seroval@>=2.0.0 <2.0.4': '2.0.4'",
276
+ " 'uuid@>=8.3.2 <11.1.1': '11.1.1'",
277
+ ' lodash: 4.18.1',
278
+ '',
279
+ ].join('\n')
280
+ const result = upsertWorkspaceOverrides(text, [{ key: 'seroval@>=1.0.0 <1.6.11', value: '1.6.11' }])
281
+ assert.equal(
282
+ result,
283
+ [
284
+ 'overrides:',
285
+ " 'seroval@>=2.0.0 <2.0.4': '2.0.4'",
286
+ " 'uuid@>=8.3.2 <11.1.1': '11.1.1'",
287
+ ' lodash: 4.18.1',
288
+ " 'seroval@>=1.0.0 <1.6.11': '1.6.11'",
289
+ '',
290
+ ].join('\n'),
291
+ )
292
+ })
293
+
294
+ test('upsertWorkspaceOverrides puts a new key before an overlapping key it cannot replace', () => {
295
+ const text = "overrides:\n lodash: 4.18.1\n 'seroval@<1.6.3': '1.6.3'\n 'uuid@<11.1.1': '11.1.1'\n"
296
+ const result = upsertWorkspaceOverrides(text, [{ key: 'seroval@>=1.0.0 <1.6.11', value: '1.6.11' }])
297
+ assert.equal(
298
+ result,
299
+ "overrides:\n lodash: 4.18.1\n 'seroval@>=1.0.0 <1.6.11': '1.6.11'\n 'seroval@<1.6.3': '1.6.3'\n 'uuid@<11.1.1': '11.1.1'\n",
300
+ )
301
+ })
302
+
303
+ test('upsertWorkspaceOverrides keeps a forced cross-major key when a newer major gets its own key', () => {
304
+ const text = "overrides:\n 'x@>=1.0.0 <2.0.0': '2.0.3'\n"
305
+ const result = upsertWorkspaceOverrides(text, [{ key: 'x@>=2.0.0 <2.1.7', value: '2.1.7' }])
306
+ assert.equal(result, "overrides:\n 'x@>=1.0.0 <2.0.0': '2.0.3'\n 'x@>=2.0.0 <2.1.7': '2.1.7'\n")
307
+ })
308
+
309
+ test('upsertWorkspaceOverrides reads <=, >, caret, tilde and exact keys', () => {
310
+ const text = [
311
+ 'overrides:',
312
+ " 'x@<=0.9.0': '0.9.1'",
313
+ " 'x@>1.0.0 <=1.6.2': '1.6.3'",
314
+ " 'x@~1.5.4': '1.5.9'",
315
+ " 'x@1.5.6': '1.6.3'",
316
+ " 'x@^1.5.0': '1.6.3'",
317
+ " 'x@>=1.6.11': '1.7.0'",
318
+ ' postcss@<=8.5.17: ^8.5.18',
319
+ '',
320
+ ].join('\n')
321
+ const result = upsertWorkspaceOverrides(text, [
322
+ { key: 'x@>=1.0.0 <1.6.11', value: '1.6.11' },
323
+ { key: 'postcss@>=8.0.0 <8.5.20', value: '8.5.20' },
324
+ ])
325
+ assert.equal(
326
+ result,
327
+ [
328
+ 'overrides:',
329
+ " 'x@<=0.9.0': '0.9.1'",
330
+ " 'x@>=1.0.0 <1.6.11': '1.6.11'",
331
+ " 'x@^1.5.0': '1.6.3'",
332
+ " 'x@>=1.6.11': '1.7.0'",
333
+ " 'postcss@>=8.0.0 <8.5.20': '8.5.20'",
334
+ ' postcss@<=8.5.17: ^8.5.18',
335
+ '',
336
+ ].join('\n'),
337
+ )
338
+ })
339
+
340
+ test('upsertWorkspaceOverrides treats touching inclusive bounds as overlapping', () => {
341
+ const text = "overrides:\n 'x@<=1.6.11': '1.6.11'\n 'x@<1.0.0': '0.9.9'\n"
342
+ const result = upsertWorkspaceOverrides(text, [{ key: 'x@>=1.6.11 <1.7.2', value: '1.7.2' }])
343
+ assert.equal(result, "overrides:\n 'x@>=1.6.11 <1.7.2': '1.7.2'\n 'x@<=1.6.11': '1.6.11'\n 'x@<1.0.0': '0.9.9'\n")
344
+ })
345
+
346
+ test('overrideKey keeps prereleases and stops forced keys at the end of the release line', () => {
347
+ assert.equal(overrideKey('x', '1.6.3', '1.6.11'), 'x@>=1.0.0 <1.6.11')
348
+ assert.equal(overrideKey('x', '0.4.0-alpha.5', '0.4.1'), 'x@>=0.4.0-alpha.5 <0.4.1')
349
+ assert.equal(overrideKey('x', '1.1.18', '2.0.3'), 'x@>=1.0.0 <2.0.0')
350
+ assert.equal(overrideKey('@scope/x', '0.12.4', '1.0.0'), '@scope/x@>=0.12.0 <0.13.0')
351
+ })
352
+
248
353
  test('diffLockfileVersions reports changed resolutions across lockfile documents', () => {
249
354
  const before = [
250
355
  '---',
@@ -80,7 +80,23 @@ export function collectOverrideChanges(before, after) {
80
80
  changed.push({ key, name: getOverridePackageName(key), from, to })
81
81
  }
82
82
  }
83
- return changed
83
+ return mergeReplacedKeys(changed)
84
+ }
85
+
86
+ /** A key swapped for a new one of the same package (old range removed, new range added) is one change. */
87
+ function mergeReplacedKeys(changed) {
88
+ const merged = []
89
+ for (const item of changed) {
90
+ const sameName = changed.filter((other) => other.name === item.name)
91
+ const added = sameName.filter((other) => other.to !== undefined)
92
+ const removed = sameName.filter((other) => other.to === undefined)
93
+ if (added.length !== 1 || removed.length === 0) {
94
+ merged.push(item)
95
+ } else if (item === added[0]) {
96
+ merged.push({ ...item, from: item.from ?? removed[0].from })
97
+ }
98
+ }
99
+ return merged
84
100
  }
85
101
 
86
102
  export function formatOverrideValue(value) {
@@ -0,0 +1,27 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { buildOverrideCommitMessage, collectOverrideChanges } from './override-changes.mjs'
5
+
6
+ test('a replaced key of the same package is one change', () => {
7
+ const changed = collectOverrideChanges(
8
+ { 'seroval@>=1.5.6 <1.6.3': '1.6.3', 'uuid@<11.1.1': '11.1.1' },
9
+ { 'seroval@>=1.0.0 <1.6.11': '1.6.11', 'uuid@<11.1.1': '11.1.1' },
10
+ )
11
+ assert.deepEqual(changed, [{ key: 'seroval@>=1.0.0 <1.6.11', name: 'seroval', from: '1.6.3', to: '1.6.11' }])
12
+ assert.deepEqual(buildOverrideCommitMessage(changed), { subject: 'vuln(seroval): override 1.6.11', body: '' })
13
+ })
14
+
15
+ test('separate packages and pruned keys stay separate changes', () => {
16
+ const changed = collectOverrideChanges(
17
+ { 'a@<1.0.1': '1.0.1', 'b@<2.0.1': '2.0.1' },
18
+ { 'b@<2.0.1': '2.0.1', 'c@<3.0.1': '3.0.1' },
19
+ )
20
+ assert.deepEqual(
21
+ changed.map(({ name, from, to }) => ({ name, from, to })),
22
+ [
23
+ { name: 'a', from: '1.0.1', to: undefined },
24
+ { name: 'c', from: undefined, to: '3.0.1' },
25
+ ],
26
+ )
27
+ })