fg-devkit 1.20.2 → 1.21.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.
package/README.md CHANGED
@@ -63,13 +63,17 @@ pnpm exec fgd fallow dead-code --json
63
63
 
64
64
  When you run `fgd sync-tooling-configs` (or `fgd sync-project-configs`), fg-devkit also writes a shared `.fallowrc.json` to the consuming repo.
65
65
 
66
- Copy the shared OWASP Dependency-Check suppressions file into a consuming repo and commit it:
66
+ Write the OWASP Dependency-Check suppressions that apply to a consuming repo and commit them:
67
67
 
68
68
  ```bash
69
- pnpm exec fgd sync-dependency-check-suppressions-commit
69
+ pnpm exec fgd sync-dependency-check-suppressions-commit # only entries matching pnpm audit findings
70
+ pnpm exec fgd sync-dependency-check-suppressions-commit --all # every entry in the shared file
70
71
  ```
71
72
 
72
- Jenkins `owaspDependencyCheck` reads `dependency-check-suppressions.xml` from the repo root when the file exists. Add another `<suppress>` element in the shared fg-devkit file for each additional accepted finding. The command overwrites the consumer copy.
73
+ The command runs `pnpm audit` and keeps only the active (not expired) `<suppress>` entries from fg-devkit's shared
74
+ `configs/dependency-check-suppressions.xml` whose GHSA id is reported in the repo, so each repo accepts exactly the
75
+ risks it has. OWASP Dependency-Check reads the file when it is configured as a suppression file. Add another `<suppress>` element in the shared fg-devkit file for each additional accepted finding.
76
+ The command overwrites the consumer copy; if nothing applies and the repo has no copy, no file is created.
73
77
 
74
78
  To sync standard consumer scripts to `fgd` commands:
75
79
 
@@ -153,13 +157,32 @@ Same upgrade, then prune unused workspace overrides in a second commit if any ca
153
157
  pnpm exec fgd policy-upgrade-commit --prune-overrides
154
158
  ```
155
159
 
156
- Fix one or more vulnerability overrides interactively, prune unused overrides, and auto-commit
157
- (`vuln(package): override version`, `vuln(package): prune override`, or `vuln(): override N packages` with a body listing each change):
160
+ Fix `pnpm audit` findings with the smallest change, verify, and auto-commit:
158
161
 
159
162
  ```bash
160
- pnpm exec fgd audit-override-commit
163
+ pnpm exec fgd audit-override-commit # refresh, override, prune, verify, commit
164
+ pnpm exec fgd audit-override-commit --no-verify # skip lint/typecheck/test/build (git hooks still run)
165
+ pnpm exec fgd audit-override-commit --no-commit # leave the changes in the working tree
161
166
  ```
162
167
 
168
+ 1. Runs `pnpm audit` and refreshes the vulnerable packages in the lockfile (`pnpm update <names>`).
169
+ Each moves to the newest version its parents' ranges already allow, so no override is needed.
170
+ 2. Audits again. When a patched version exists but a parent's range blocks it, adds a tight override to
171
+ `pnpm-workspace.yaml`: `'name@>=installed <patched': 'patched'` (the lowest patched version).
172
+ 3. Reports instead of overriding when the only patch is in a new major (upgrade the parent), when it is
173
+ younger than `minimumReleaseAge` (re-run later), when the package is a direct dependency (bump it with
174
+ `policy-upgrade-commit`), or when no patch exists (candidate for `auditConfig.ignoreGhsas`).
175
+ 4. Prunes overrides that no longer change the audit.
176
+ 5. If findings that are still reported have active (not expired) `<suppress>` entries in fg-devkit's shared
177
+ `configs/dependency-check-suppressions.xml`, writes the repo's `dependency-check-suppressions.xml` with only
178
+ those entries, so OWASP Dependency-Check accepts exactly the risks this repo has. Findings with no patch and
179
+ no suppression are listed so you can add one.
180
+ 6. Installs and runs the repo's `lint-check`, `lint:ts`, `test`, `test:unit` and `build` scripts when present.
181
+ Nothing is committed if a check fails.
182
+ 7. Commits the lockfile refresh (`vuln(package): refresh lockfile` / `vuln(): refresh N packages in lockfile`),
183
+ the override changes (`vuln(package): override version`, `vuln(): override N packages`) and the
184
+ suppressions file (`chore(repo): sync dependency-check suppressions`) separately.
185
+
163
186
  Report, remove, or remove and auto-commit workspace overrides that no longer change `pnpm audit`:
164
187
 
165
188
  ```bash
@@ -193,9 +216,9 @@ export default {
193
216
 
194
217
  ## Piwik PRO custom dimensions
195
218
 
196
- Compare the local slot map with a Piwik PRO site, then create missing dimensions or rename existing ones. Run it from a consuming repo (`bokningsverktyget-ui`, `webbportalen-ui`, `feelgood-app`, and the same layout in other apps) or from the Electron maintainer UI under **Piwik**.
219
+ Compare the local slot map with a Piwik PRO site, then create missing dimensions or rename existing ones. Run it from a consuming repo.
197
220
 
198
- The local map is `src/analytics/dimensions.ts` (`customDimensions` = event, `sessionDimensions` = session) or `src/util/piwikDimensions.ts` (`CustomDimensions` / `SessionCustomDimensions`). The site defaults to the first non-production entry in `src/config/env.ts`, such as preprod. Pass `--env production` to target production. In the Electron app, Piwik actions show one radio per distinct `piwikId`. Environments that share an id, such as localhost, development, and preprod, appear once as **preprod**.
221
+ The local map is `src/analytics/dimensions.ts` (`customDimensions` = event, `sessionDimensions` = session) or `src/util/piwikDimensions.ts` (`CustomDimensions` / `SessionCustomDimensions`). The site defaults to the first non-production entry in `src/config/env.ts`, such as preprod. Pass `--env production` to target production.
199
222
 
200
223
  ```bash
201
224
  pnpm exec fgd piwik list
@@ -208,7 +231,7 @@ pnpm exec fgd piwik compare --from preprod --to production
208
231
  pnpm exec fgd piwik migrate --from preprod --to production --dry-run
209
232
  ```
210
233
 
211
- `sync` copies the local map up to the selected Piwik site: it creates missing dimensions, then renames existing ones. It does not edit the repo. `pull` copies tracking ids from that site down into the local map for keys with the same name. In the Electron app, **Sync** has From and To radios. **Local map** is the repo. A site as To writes up to Piwik. **Local map** as To writes down into the repo. Two sites migrate between environments.
234
+ `sync` copies the local map up to the selected Piwik site: it creates missing dimensions, then renames existing ones. It does not edit the repo. `pull` copies tracking ids from that site down into the local map for keys with the same name.
212
235
 
213
236
  `compare` checks two sites from `src/config/env.ts` against each other. Event and session dimensions match on `tracking_id`, so a different slot on the other site is still in sync. `migrate` copies names, descriptions, and missing dimensions from `--from` onto `--to`, one create at a time. It stops if Piwik assigns a different `tracking_id`. Dimensions that exist only on the target stay there.
214
237
 
@@ -95,7 +95,7 @@
95
95
  "dotenv": "18.0.5",
96
96
  "qrcode.vue": "3.11.0",
97
97
  "tailwindcss": "4.3.3",
98
- "vite": "8.3.2",
98
+ "vite": "8.3.3",
99
99
  "vue": "3.5.43"
100
100
  }
101
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fg-devkit",
3
- "version": "1.20.2",
3
+ "version": "1.21.1",
4
4
  "description": "Shared configs, scripts, and DX tooling for Vue / TypeScript frontend repos",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -116,8 +116,8 @@
116
116
  "@inquirer/input": "5.1.7",
117
117
  "@inquirer/select": "5.2.6",
118
118
  "@intlify/eslint-plugin-vue-i18n": "4.5.1",
119
- "@typescript-eslint/eslint-plugin": "8.71.0",
120
- "@typescript-eslint/parser": "8.71.0",
119
+ "@typescript-eslint/eslint-plugin": "8.71.1",
120
+ "@typescript-eslint/parser": "8.71.1",
121
121
  "@vitest/eslint-plugin": "1.6.27",
122
122
  "@vue/eslint-config-prettier": "10.2.0",
123
123
  "@vue/eslint-config-typescript": "14.9.0",
@@ -32,11 +32,11 @@
32
32
  "@intlify/unplugin-vue-i18n": "11.2.5",
33
33
  "@tanstack/vue-query-devtools": "6.3.1",
34
34
  "@types/node": "22.16.5",
35
- "fg-devkit": "1.20.2",
35
+ "fg-devkit": "1.21.1",
36
36
  "@vitejs/plugin-vue": "6.0.9",
37
37
  "tailwindcss": "4.3.3",
38
38
  "tsx": "4.23.15",
39
- "vite": "8.3.2",
39
+ "vite": "8.3.3",
40
40
  "vite-plugin-vue-inspector": "7.0.0"
41
41
  },
42
42
  "packageManager": "pnpm@12.9.1",
@@ -0,0 +1,436 @@
1
+ /**
2
+ * Pure helpers for audit-override-commit: normalize `pnpm audit --json` output, plan
3
+ * minimal overrides for what a lockfile refresh could not fix, edit the `overrides:`
4
+ * section of pnpm-workspace.yaml, and diff resolved versions between two lockfiles.
5
+ *
6
+ * No I/O here so the logic can be unit tested (audit-fix-plan.test.mjs).
7
+ */
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Versions
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** Parse `1.2.3` / `1.2.3-beta.1` (build metadata ignored). Returns null when not semver. */
14
+ export function parseVersion(value) {
15
+ const match = String(value ?? '')
16
+ .trim()
17
+ .match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/)
18
+ if (!match) return null
19
+ return {
20
+ major: Number(match[1]),
21
+ minor: Number(match[2]),
22
+ patch: Number(match[3]),
23
+ prerelease: match[4] ? match[4].split('.') : [],
24
+ }
25
+ }
26
+
27
+ function comparePrerelease(a, b) {
28
+ if (a.length === 0 && b.length === 0) return 0
29
+ if (a.length === 0) return 1
30
+ if (b.length === 0) return -1
31
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
32
+ if (a[i] === undefined) return -1
33
+ if (b[i] === undefined) return 1
34
+ const aNum = /^\d+$/.test(a[i])
35
+ const bNum = /^\d+$/.test(b[i])
36
+ if (aNum && bNum) {
37
+ const diff = Number(a[i]) - Number(b[i])
38
+ if (diff !== 0) return Math.sign(diff)
39
+ } else if (aNum !== bNum) {
40
+ return aNum ? -1 : 1
41
+ } else if (a[i] !== b[i]) {
42
+ return a[i] < b[i] ? -1 : 1
43
+ }
44
+ }
45
+ return 0
46
+ }
47
+
48
+ /** Semver precedence compare. Throws on non-semver input. */
49
+ export function compareVersions(a, b) {
50
+ const left = typeof a === 'string' ? parseVersion(a) : a
51
+ const right = typeof b === 'string' ? parseVersion(b) : b
52
+ if (!left || !right) throw new Error(`cannot compare versions ${a} and ${b}`)
53
+ for (const part of ['major', 'minor', 'patch']) {
54
+ if (left[part] !== right[part]) return Math.sign(left[part] - right[part])
55
+ }
56
+ return comparePrerelease(left.prerelease, right.prerelease)
57
+ }
58
+
59
+ /**
60
+ * True when `to` is a semver-compatible upgrade of `from` (what a `^from` range accepts):
61
+ * same major for >=1.0.0, same minor for 0.x, same patch for 0.0.x.
62
+ */
63
+ export function isSameReleaseLine(from, to) {
64
+ const a = parseVersion(from)
65
+ const b = parseVersion(to)
66
+ if (!a || !b) return false
67
+ if (a.major !== b.major) return false
68
+ if (a.major > 0) return true
69
+ if (a.minor !== b.minor) return false
70
+ if (a.minor > 0) return true
71
+ return a.patch === b.patch
72
+ }
73
+
74
+ /**
75
+ * Lowest patched version strictly above `installed`, read from an advisory's
76
+ * `patched_versions` (e.g. `>=1.2.2`, `>=1.1.21 <2.0.0 || >=2.1.7`).
77
+ * Returns `{ status: 'none' }` when no patch exists, `{ status: 'unparseable' }` when the
78
+ * range is not made of `>=x.y.z` lower bounds, else `{ status: 'ok', version }`.
79
+ */
80
+ export function lowestPatchedVersion(patchedVersions, installed) {
81
+ const text = String(patchedVersions ?? '').trim()
82
+ if (!text || text === 'None' || text === '<0.0.0') return { status: 'none' }
83
+ const bounds = []
84
+ for (const alternative of text.split('||')) {
85
+ const lower = alternative.trim().match(/^>=\s*v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?:\s|$)/)
86
+ if (!lower) return { status: 'unparseable' }
87
+ bounds.push(lower[1])
88
+ }
89
+ const candidates = bounds.filter((version) => compareVersions(version, installed) > 0).sort(compareVersions)
90
+ if (candidates.length === 0) return { status: 'unparseable' }
91
+ return { status: 'ok', version: candidates[0] }
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // pnpm audit --json
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /** Parse `pnpm audit --json` stdout (tolerates log noise around the JSON). */
99
+ export function parseAuditJson(stdout) {
100
+ const text = String(stdout ?? '').trim()
101
+ try {
102
+ return JSON.parse(text)
103
+ } catch {
104
+ const start = text.indexOf('{')
105
+ const end = text.lastIndexOf('}')
106
+ if (start >= 0 && end > start) return JSON.parse(text.slice(start, end + 1))
107
+ throw new Error(`pnpm audit --json did not return JSON\n${text.slice(0, 500)}`)
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Flatten an audit report into advisories:
113
+ * `{ id, ghsa, name, severity, title, vulnerable, patched, versions: string[] }`.
114
+ * `id` is unique per advisory *and* vulnerable range (one GHSA can cover several majors).
115
+ */
116
+ export function normalizeAdvisories(report) {
117
+ const advisories = []
118
+ for (const [key, advisory] of Object.entries(report?.advisories ?? {})) {
119
+ const ghsa = advisory.github_advisory_id || String(advisory.id ?? key)
120
+ const versions = [...new Set((advisory.findings ?? []).map((finding) => finding.version).filter(Boolean))]
121
+ advisories.push({
122
+ id: `${ghsa}|${advisory.module_name}|${advisory.vulnerable_versions}`,
123
+ ghsa,
124
+ name: advisory.module_name,
125
+ severity: advisory.severity,
126
+ title: advisory.title,
127
+ vulnerable: advisory.vulnerable_versions,
128
+ patched: advisory.patched_versions,
129
+ versions,
130
+ })
131
+ }
132
+ return advisories
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Override planning
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /**
140
+ * Decide what to do with advisories that survived the lockfile refresh.
141
+ *
142
+ * 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.
145
+ *
146
+ * @param {ReturnType<typeof normalizeAdvisories>} advisories
147
+ * @param {{ directDependencies?: Set<string> }} [options]
148
+ */
149
+ export function planOverrides(advisories, { directDependencies = new Set() } = {}) {
150
+ const plan = { overrides: [], noPatch: [], crossMajor: [], direct: [], unparseable: [] }
151
+ const byPackage = new Map()
152
+ for (const advisory of advisories) {
153
+ if (!byPackage.has(advisory.name)) byPackage.set(advisory.name, [])
154
+ byPackage.get(advisory.name).push(advisory)
155
+ }
156
+
157
+ for (const [name, packageAdvisories] of byPackage) {
158
+ const ghsas = [...new Set(packageAdvisories.map((advisory) => advisory.ghsa))]
159
+ if (directDependencies.has(name)) {
160
+ plan.direct.push({ name, ghsas, versions: collectVersions(packageAdvisories) })
161
+ continue
162
+ }
163
+
164
+ // installed version -> highest "lowest patched version" across its advisories
165
+ const targets = new Map()
166
+ const blocked = { none: new Set(), unparseable: new Set() }
167
+ for (const advisory of packageAdvisories) {
168
+ for (const installed of advisory.versions) {
169
+ if (!parseVersion(installed)) {
170
+ blocked.unparseable.add(advisory.ghsa)
171
+ continue
172
+ }
173
+ const patched = lowestPatchedVersion(advisory.patched, installed)
174
+ if (patched.status !== 'ok') {
175
+ blocked[patched.status].add(advisory.ghsa)
176
+ continue
177
+ }
178
+ const current = targets.get(installed)
179
+ if (!current) {
180
+ targets.set(installed, { target: patched.version, ghsas: new Set([advisory.ghsa]) })
181
+ continue
182
+ }
183
+ if (compareVersions(patched.version, current.target) > 0) current.target = patched.version
184
+ current.ghsas.add(advisory.ghsa)
185
+ }
186
+ }
187
+
188
+ if (blocked.none.size > 0) {
189
+ plan.noPatch.push({ name, ghsas: [...blocked.none], versions: collectVersions(packageAdvisories) })
190
+ }
191
+ if (blocked.unparseable.size > 0) {
192
+ plan.unparseable.push({ name, ghsas: [...blocked.unparseable], patched: packageAdvisories[0].patched })
193
+ }
194
+ // A package with an unfixable advisory gets no override: it would not clear the finding.
195
+ if (blocked.none.size > 0 || blocked.unparseable.size > 0) continue
196
+
197
+ const spans = [...targets.entries()]
198
+ .map(([installed, { target, ghsas: spanGhsas }]) => ({ low: installed, high: target, ghsas: spanGhsas }))
199
+ .sort((a, b) => compareVersions(a.low, b.low))
200
+
201
+ const merged = []
202
+ for (const span of spans) {
203
+ const last = merged.at(-1)
204
+ if (last && compareVersions(span.low, last.high) < 0) {
205
+ if (compareVersions(span.high, last.high) > 0) last.high = span.high
206
+ for (const ghsa of span.ghsas) last.ghsas.add(ghsa)
207
+ } else {
208
+ merged.push({ ...span, ghsas: new Set(span.ghsas) })
209
+ }
210
+ }
211
+
212
+ for (const span of merged) {
213
+ const entry = { name, from: span.low, to: span.high, ghsas: [...span.ghsas] }
214
+ if (!isSameReleaseLine(span.low, span.high)) {
215
+ plan.crossMajor.push(entry)
216
+ continue
217
+ }
218
+ plan.overrides.push({ ...entry, key: `${name}@>=${span.low} <${span.high}`, value: span.high })
219
+ }
220
+ }
221
+
222
+ return plan
223
+ }
224
+
225
+ function collectVersions(advisories) {
226
+ return [...new Set(advisories.flatMap((advisory) => advisory.versions))].sort((a, b) => {
227
+ try {
228
+ return compareVersions(a, b)
229
+ } catch {
230
+ return a < b ? -1 : 1
231
+ }
232
+ })
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // minimumReleaseAge
237
+ // ---------------------------------------------------------------------------
238
+
239
+ /** pnpm 11+ defaults minimumReleaseAge to 1440 minutes when it is not configured. */
240
+ export const DEFAULT_MINIMUM_RELEASE_AGE_MINUTES = 1440
241
+
242
+ function globToRegExp(pattern) {
243
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
244
+ return new RegExp(`^${escaped}$`)
245
+ }
246
+
247
+ /**
248
+ * Whether `name@version` is listed in minimumReleaseAgeExclude. Entries can be a name,
249
+ * a name pattern (`@scope/*`), or `name@1.2.3 || 1.2.4`.
250
+ */
251
+ export function isReleaseAgeExcluded(excludeList, name, version) {
252
+ for (const raw of excludeList ?? []) {
253
+ const entry = String(raw).trim()
254
+ const at = entry.lastIndexOf('@')
255
+ if (at <= 0) {
256
+ if (globToRegExp(entry).test(name)) return true
257
+ continue
258
+ }
259
+ if (entry.slice(0, at) !== name) continue
260
+ const versions = entry
261
+ .slice(at + 1)
262
+ .split('||')
263
+ .map((item) => item.trim())
264
+ if (versions.includes(version)) return true
265
+ }
266
+ return false
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // pnpm-workspace.yaml overrides
271
+ // ---------------------------------------------------------------------------
272
+
273
+ function quoteYamlKey(key) {
274
+ return `'${key.replace(/'/g, "''")}'`
275
+ }
276
+
277
+ function unquoteYaml(value) {
278
+ const text = value.trim()
279
+ if (text.startsWith("'") && text.endsWith("'")) return text.slice(1, -1).replace(/''/g, "'")
280
+ if (text.startsWith('"') && text.endsWith('"')) return text.slice(1, -1)
281
+ return text
282
+ }
283
+
284
+ /**
285
+ * Add or replace entries in the top-level `overrides:` map of pnpm-workspace.yaml,
286
+ * keeping every other line untouched. Creates the section when missing.
287
+ *
288
+ * @param {string} text
289
+ * @param {{ key: string, value: string }[]} entries
290
+ */
291
+ export function upsertWorkspaceOverrides(text, entries) {
292
+ if (entries.length === 0) return text
293
+ const lines = text.replace(/\r\n/g, '\n').split('\n')
294
+ const start = lines.findIndex((line) => /^overrides:\s*(?:#.*)?$/.test(line))
295
+ const rendered = (entry) => ` ${quoteYamlKey(entry.key)}: '${entry.value}'`
296
+
297
+ if (start === -1) {
298
+ while (lines.length > 0 && lines.at(-1) === '') lines.pop()
299
+ lines.push('overrides:', ...entries.map(rendered), '')
300
+ return lines.join('\n')
301
+ }
302
+
303
+ let end = start + 1
304
+ while (end < lines.length && (lines[end] === '' || /^\s/.test(lines[end]))) end += 1
305
+ // Keep trailing blank lines outside the section.
306
+ let lastContent = end - 1
307
+ while (lastContent > start && lines[lastContent] === '') lastContent -= 1
308
+
309
+ const pending = new Map(entries.map((entry) => [entry.key, entry]))
310
+ for (let i = start + 1; i <= lastContent; i += 1) {
311
+ const match = lines[i].match(/^(\s+)((?:'(?:[^']|'')*'|"[^"]*"|[^:#]+)):/)
312
+ if (!match) continue
313
+ const key = unquoteYaml(match[2])
314
+ if (pending.has(key)) {
315
+ lines[i] = rendered(pending.get(key))
316
+ pending.delete(key)
317
+ }
318
+ }
319
+ lines.splice(lastContent + 1, 0, ...[...pending.values()].map(rendered))
320
+ return lines.join('\n')
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Lockfile diff
325
+ // ---------------------------------------------------------------------------
326
+
327
+ /** Map of package name -> Set of resolved versions in a pnpm-lock.yaml (all YAML documents). */
328
+ export function lockfileVersions(text) {
329
+ const versions = new Map()
330
+ const pattern = /^ {2}'?((?:@[^/\s']+\/)?[^@\s'()]+)@(\d+\.\d+\.\d+[^:'\s(]*)/gm
331
+ for (const match of String(text ?? '').matchAll(pattern)) {
332
+ if (!versions.has(match[1])) versions.set(match[1], new Set())
333
+ versions.get(match[1]).add(match[2])
334
+ }
335
+ return versions
336
+ }
337
+
338
+ /** `[{ name, from: string[], to: string[] }]` for packages whose resolved versions changed. */
339
+ export function diffLockfileVersions(beforeText, afterText) {
340
+ const before = lockfileVersions(beforeText)
341
+ const after = lockfileVersions(afterText)
342
+ const changes = []
343
+ for (const name of [...new Set([...before.keys(), ...after.keys()])].sort()) {
344
+ const from = [...(before.get(name) ?? [])].filter((version) => !after.get(name)?.has(version))
345
+ const to = [...(after.get(name) ?? [])].filter((version) => !before.get(name)?.has(version))
346
+ if (from.length === 0 && to.length === 0) continue
347
+ changes.push({ name, from: from.sort(compareVersionsLoose), to: to.sort(compareVersionsLoose) })
348
+ }
349
+ return changes
350
+ }
351
+
352
+ function compareVersionsLoose(a, b) {
353
+ try {
354
+ return compareVersions(a, b)
355
+ } catch {
356
+ return a < b ? -1 : 1
357
+ }
358
+ }
359
+
360
+ /** Commit message for the lockfile-refresh commit, in the repo's `vuln()` convention. */
361
+ export function buildRefreshCommitMessage(changes) {
362
+ const describe = (change) =>
363
+ `- ${change.name}: ${change.from.join(', ') || '(new)'} -> ${change.to.join(', ') || '(removed)'}`
364
+ if (changes.length === 0) {
365
+ return { subject: 'vuln(): refresh lockfile', body: '' }
366
+ }
367
+ if (changes.length === 1) {
368
+ return { subject: `vuln(${changes[0].name}): refresh lockfile`, body: describe(changes[0]) }
369
+ }
370
+ return { subject: `vuln(): refresh ${changes.length} packages in lockfile`, body: changes.map(describe).join('\n') }
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // OWASP Dependency-Check suppressions
375
+ // ---------------------------------------------------------------------------
376
+
377
+ function parseSuppressUntil(value) {
378
+ if (!value) return undefined
379
+ // Dependency-Check uses xs:date, e.g. `2026-12-01Z` or `2026-12-01+01:00`.
380
+ const match = value.trim().match(/^(\d{4}-\d{2}-\d{2})(Z|[+-]\d{2}:\d{2})?$/)
381
+ const time = match ? Date.parse(`${match[1]}T00:00:00${match[2] ?? 'Z'}`) : Date.parse(value)
382
+ return Number.isFinite(time) ? time : undefined
383
+ }
384
+
385
+ /**
386
+ * Parse the `<suppress>` entries of a dependency-check-suppressions.xml.
387
+ * GHSA ids are read from the matching elements only (not `<notes>`). An entry is
388
+ * inactive once its `until` date has passed, because Dependency-Check ignores it then.
389
+ *
390
+ * @returns {{ text: string, ghsas: string[], until?: string, active: boolean }[]}
391
+ * `text` is the entry exactly as written, including indentation and line break.
392
+ */
393
+ export function parseSuppressions(xmlText, now = Date.now()) {
394
+ const entries = []
395
+ const pattern = /^[ \t]*<suppress\b([^>]*)>([\s\S]*?)<\/suppress>[ \t]*(?:\r?\n)?/gm
396
+ for (const [text, attributes, body] of String(xmlText ?? '').matchAll(pattern)) {
397
+ const until = attributes.match(/\buntil="([^"]*)"/)?.[1]
398
+ const untilTime = parseSuppressUntil(until)
399
+ const active = !until || (untilTime !== undefined && untilTime > now)
400
+ const matchers = body.replace(/<notes>[\s\S]*?<\/notes>/g, '')
401
+ const ghsas = [
402
+ ...new Set(
403
+ [...matchers.matchAll(/GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}/gi)].map(
404
+ ([ghsa]) => `GHSA${ghsa.slice(4).toLowerCase()}`,
405
+ ),
406
+ ),
407
+ ]
408
+ entries.push({ text, ghsas, until, active })
409
+ }
410
+ return entries
411
+ }
412
+
413
+ /** GHSA id -> `until` for every active `<suppress>` entry. */
414
+ export function activeSuppressedGhsas(xmlText, now = Date.now()) {
415
+ const covered = new Map()
416
+ for (const entry of parseSuppressions(xmlText, now)) {
417
+ if (!entry.active) continue
418
+ for (const ghsa of entry.ghsas) covered.set(ghsa, entry.until)
419
+ }
420
+ return covered
421
+ }
422
+
423
+ /**
424
+ * The shared suppressions file reduced to the active entries that match one of `ghsas`,
425
+ * so a repo only accepts the risks it actually has. Header, namespace and the kept
426
+ * entries are copied verbatim.
427
+ */
428
+ export function filterSuppressions(xmlText, ghsas, now = Date.now()) {
429
+ const wanted = new Set(ghsas)
430
+ let result = String(xmlText ?? '')
431
+ for (const entry of parseSuppressions(result, now)) {
432
+ if (entry.active && entry.ghsas.some((ghsa) => wanted.has(ghsa))) continue
433
+ result = result.replace(entry.text, '')
434
+ }
435
+ return result
436
+ }