fg-devkit 1.21.1 → 1.21.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.
package/README.md CHANGED
@@ -173,15 +173,18 @@ pnpm exec fgd audit-override-commit --no-commit # leave the changes in the work
173
173
  younger than `minimumReleaseAge` (re-run later), when the package is a direct dependency (bump it with
174
174
  `policy-upgrade-commit`), or when no patch exists (candidate for `auditConfig.ignoreGhsas`).
175
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
176
+ 5. Removes `minimumReleaseAgeExclude` entries that are no longer needed (see `prune-release-age-exclude` below).
177
+ This step also runs when `pnpm audit` is clean.
178
+ 6. If findings that are still reported have active (not expired) `<suppress>` entries in fg-devkit's shared
177
179
  `configs/dependency-check-suppressions.xml`, writes the repo's `dependency-check-suppressions.xml` with only
178
180
  those entries, so OWASP Dependency-Check accepts exactly the risks this repo has. Findings with no patch and
179
181
  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.
182
+ 7. Installs and runs the repo's `lint-check`, `lint:ts`, `test`, `test:unit` and `build` scripts when present.
181
183
  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.
184
+ 8. Commits the lockfile refresh (`vuln(package): refresh lockfile` / `vuln(): refresh N packages in lockfile`),
185
+ the override changes (`vuln(package): override version`, `vuln(): override N packages`), the release-age
186
+ excludes (`chore(repo): prune minimumReleaseAgeExclude`) and the suppressions file
187
+ (`chore(repo): sync dependency-check suppressions`) separately.
185
188
 
186
189
  Report, remove, or remove and auto-commit workspace overrides that no longer change `pnpm audit`:
187
190
 
@@ -191,6 +194,20 @@ pnpm exec fgd prune-overrides --fix
191
194
  pnpm exec fgd prune-overrides --commit
192
195
  ```
193
196
 
197
+ Report, remove, or remove and auto-commit `minimumReleaseAgeExclude` entries that are no longer needed:
198
+
199
+ ```bash
200
+ pnpm exec fgd prune-release-age-exclude
201
+ pnpm exec fgd prune-release-age-exclude --fix
202
+ pnpm exec fgd prune-release-age-exclude --commit
203
+ ```
204
+
205
+ A pinned version (`name@1.2.3` or `name@1.2.3 || 1.2.4`) is only needed while it is in the lockfile and younger than
206
+ `minimumReleaseAge` (1440 minutes unless configured). Versions that are old enough, or no longer installed, are
207
+ removed, and an entry with no versions left is removed entirely. Name rules such as `my-package` or `@scope/*` allow
208
+ every future release on purpose and are always kept. With `--fix` the lockfile is re-verified against the release-age
209
+ policy, and `pnpm-workspace.yaml` is restored if pnpm rejects it.
210
+
194
211
  ## tsconfig in consuming repos
195
212
 
196
213
  Use `fgd sync-project-configs` (or `fgd sync-tooling-configs`) to write the local `tsconfig.json` template from `fg-devkit`.
@@ -0,0 +1,16 @@
1
+ import type { Linter } from 'eslint'
2
+
3
+ export interface CreateBaseConfigOptions {
4
+ tsconfigRootDir?: string
5
+ tsconfigFiles?: string[]
6
+ additionalIgnores?: string[]
7
+ extraNonTypeAwareFiles?: string[]
8
+ enablePrettier?: boolean
9
+ enableI18n?: boolean
10
+ i18nLocaleDir?: string
11
+ additionalRules?: Linter.RulesRecord
12
+ additionalPlugins?: NonNullable<Linter.Config['plugins']>
13
+ additionalSettings?: NonNullable<Linter.Config['settings']>
14
+ }
15
+
16
+ export default function createBaseConfig(options?: CreateBaseConfigOptions): Linter.Config[]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fg-devkit",
3
- "version": "1.21.1",
3
+ "version": "1.21.2",
4
4
  "description": "Shared configs, scripts, and DX tooling for Vue / TypeScript frontend repos",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -13,7 +13,10 @@
13
13
  "exports": {
14
14
  "./tsconfig.base.json": "./configs/tsconfig.base.json",
15
15
  "./tsconfig.app.sync.json": "./configs/tsconfig.app.sync.json",
16
- "./eslint.config.base.js": "./configs/eslint.config.base.js",
16
+ "./eslint.config.base.js": {
17
+ "types": "./configs/eslint.config.base.d.ts",
18
+ "default": "./configs/eslint.config.base.js"
19
+ },
17
20
  "./prettier.config.js": "./configs/prettier.config.js",
18
21
  "./lefthook.yml": "./configs/lefthook.yml",
19
22
  "./vscode-settings.json": "./configs/vscode-settings.json",
@@ -32,7 +32,7 @@
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.21.1",
35
+ "fg-devkit": "1.21.2",
36
36
  "@vitejs/plugin-vue": "6.0.9",
37
37
  "tailwindcss": "4.3.3",
38
38
  "tsx": "4.23.15",
@@ -434,3 +434,137 @@ export function filterSuppressions(xmlText, ghsas, now = Date.now()) {
434
434
  }
435
435
  return result
436
436
  }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // minimumReleaseAgeExclude pruning
440
+ // ---------------------------------------------------------------------------
441
+
442
+ /**
443
+ * Split a minimumReleaseAgeExclude entry into its package name and exact versions.
444
+ * `versions` is null for name rules (`fg-devkit`, `@scope/*`), which allow every
445
+ * future release of that package on purpose and are never pruned.
446
+ */
447
+ export function parseReleaseAgeExcludeEntry(entry) {
448
+ const text = String(entry).trim()
449
+ const at = text.lastIndexOf('@')
450
+ if (at <= 0) return { name: text, versions: null }
451
+ return {
452
+ name: text.slice(0, at),
453
+ versions: text
454
+ .slice(at + 1)
455
+ .split('||')
456
+ .map((version) => version.trim())
457
+ .filter(Boolean),
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Decide which version-pinned minimumReleaseAgeExclude entries are still needed.
463
+ *
464
+ * A version is still needed only while it is resolved in the lockfile *and* younger than
465
+ * minimumReleaseAge. Anything else (old enough now, or no longer installed) is dropped.
466
+ * Name rules, patterns, non-exact versions and versions without a known publish time are
467
+ * kept unchanged, so nothing is removed on a guess.
468
+ *
469
+ * @param {string[]} entries
470
+ * @param {{
471
+ * lockfileVersions: Map<string, Set<string>>,
472
+ * publishTime: (name: string, version: string) => number | undefined,
473
+ * minimumReleaseAgeMinutes: number,
474
+ * now?: number,
475
+ * }} context
476
+ * @returns {{ entry: string, next: string | null, dropped: { version: string, reason: 'old-enough' | 'not-installed' }[] }[]}
477
+ * `next` is the entry to keep (`null` = remove the entry, same string = unchanged).
478
+ */
479
+ export function planReleaseAgeExcludePrune(entries, context) {
480
+ const now = context.now ?? Date.now()
481
+ const maxAgeMs = context.minimumReleaseAgeMinutes * 60000
482
+ return entries.map((entry) => {
483
+ const { name, versions } = parseReleaseAgeExcludeEntry(entry)
484
+ if (versions === null || name.includes('*') || versions.length === 0) {
485
+ return { entry, next: entry, dropped: [] }
486
+ }
487
+ if (versions.some((version) => !parseVersion(version))) return { entry, next: entry, dropped: [] }
488
+
489
+ const kept = []
490
+ const dropped = []
491
+ for (const version of versions) {
492
+ if (!context.lockfileVersions.get(name)?.has(version)) {
493
+ dropped.push({ version, reason: 'not-installed' })
494
+ continue
495
+ }
496
+ const published = context.publishTime(name, version)
497
+ if (published === undefined || !Number.isFinite(published)) {
498
+ kept.push(version)
499
+ } else if (now - published >= maxAgeMs) {
500
+ dropped.push({ version, reason: 'old-enough' })
501
+ } else {
502
+ kept.push(version)
503
+ }
504
+ }
505
+ if (dropped.length === 0) return { entry, next: entry, dropped }
506
+ return { entry, next: kept.length > 0 ? `${name}@${kept.join(' || ')}` : null, dropped }
507
+ })
508
+ }
509
+
510
+ /** Items of a top-level YAML block list (`key:` followed by ` - item` lines). */
511
+ export function readWorkspaceList(text, key) {
512
+ const lines = String(text ?? '').split('\n')
513
+ const start = lines.findIndex((line) => line === `${key}:` || line.startsWith(`${key}: #`))
514
+ if (start === -1) return []
515
+ const items = []
516
+ for (let i = start + 1; i < lines.length; i += 1) {
517
+ const line = lines[i]
518
+ if (line.trim() === '' || line.trim().startsWith('#')) continue
519
+ const item = line.match(/^\s+-\s+(.*?)\s*$/)
520
+ if (!item) break
521
+ items.push(unquoteYaml(item[1]))
522
+ }
523
+ return items
524
+ }
525
+
526
+ function renderYamlListItem(value) {
527
+ return /^[\w@./-][\w@./ |<>=*^~-]*$/.test(value) && !/[:#]/.test(value) && !value.startsWith('@')
528
+ ? value
529
+ : `'${value.replace(/'/g, "''")}'`
530
+ }
531
+
532
+ /**
533
+ * Apply `{ entry -> next }` replacements to a top-level YAML block list, keeping
534
+ * indentation, quoting style and comments. `next === null` removes the item; the key is
535
+ * removed too when its list becomes empty.
536
+ */
537
+ export function rewriteWorkspaceList(text, key, replacements) {
538
+ const lines = String(text ?? '').split('\n')
539
+ const start = lines.findIndex((line) => line === `${key}:` || line.startsWith(`${key}: #`))
540
+ if (start === -1) return text
541
+ let remaining = 0
542
+ let end = start + 1
543
+ const output = lines.slice(0, start + 1)
544
+ for (; end < lines.length; end += 1) {
545
+ const line = lines[end]
546
+ if (line.trim() === '' || line.trim().startsWith('#')) {
547
+ output.push(line)
548
+ continue
549
+ }
550
+ const item = line.match(/^(\s+-\s+)(.*?)\s*$/)
551
+ if (!item) break
552
+ const value = unquoteYaml(item[2])
553
+ if (!replacements.has(value)) {
554
+ output.push(line)
555
+ remaining += 1
556
+ continue
557
+ }
558
+ const next = replacements.get(value)
559
+ if (next === null) continue
560
+ const quoted = /^['"]/.test(item[2]) ? `'${next.replace(/'/g, "''")}'` : renderYamlListItem(next)
561
+ output.push(`${item[1]}${quoted}`)
562
+ remaining += 1
563
+ }
564
+ if (remaining === 0) {
565
+ output.splice(start, output.length - start)
566
+ // Drop blank lines left where the section was.
567
+ while (output.length > 0 && output.at(-1) === '' && lines[end] === '') output.pop()
568
+ }
569
+ return [...output, ...lines.slice(end)].join('\n')
570
+ }
@@ -12,6 +12,9 @@ import {
12
12
  lowestPatchedVersion,
13
13
  normalizeAdvisories,
14
14
  planOverrides,
15
+ planReleaseAgeExcludePrune,
16
+ readWorkspaceList,
17
+ rewriteWorkspaceList,
15
18
  upsertWorkspaceOverrides,
16
19
  } from './audit-fix-plan.mjs'
17
20
 
@@ -285,3 +288,107 @@ test('filterSuppressions keeps only active entries that match the repo findings'
285
288
  // Expired entries are dropped even when they match.
286
289
  assert.doesNotMatch(filterSuppressions(xml, ['GHSA-vfj7-8cjw-p6xm'], Date.parse('2026-12-02T00:00:00Z')), /GHSA-vfj7/)
287
290
  })
291
+
292
+ test('planReleaseAgeExcludePrune drops versions that are old enough or no longer installed', () => {
293
+ const now = Date.parse('2026-10-06T12:00:00Z')
294
+ const hoursAgo = (hours) => now - hours * 3600000
295
+ const times = {
296
+ 'lefthook@2.1.15': hoursAgo(24 * 30),
297
+ 'lefthook@2.1.17': hoursAgo(2),
298
+ 'tsx@4.23.9': hoursAgo(24 * 10),
299
+ 'fallow@3.31.0': hoursAgo(5),
300
+ }
301
+ const plan = planReleaseAgeExcludePrune(
302
+ [
303
+ 'fg-devkit',
304
+ '@fallow-cli/*',
305
+ 'lefthook@2.1.15 || 2.1.17',
306
+ 'tsx@4.23.9',
307
+ 'fallow@3.31.0',
308
+ 'gone@1.0.0',
309
+ 'x@^1.0.0',
310
+ ],
311
+ {
312
+ lockfileVersions: new Map([
313
+ ['lefthook', new Set(['2.1.15', '2.1.17'])],
314
+ ['tsx', new Set(['4.23.9'])],
315
+ ['fallow', new Set(['3.31.0'])],
316
+ ['x', new Set(['1.2.0'])],
317
+ ]),
318
+ publishTime: (name, version) => times[`${name}@${version}`],
319
+ minimumReleaseAgeMinutes: 1440,
320
+ now,
321
+ },
322
+ )
323
+ assert.deepEqual(
324
+ plan.map(({ entry, next }) => [entry, next]),
325
+ [
326
+ ['fg-devkit', 'fg-devkit'],
327
+ ['@fallow-cli/*', '@fallow-cli/*'],
328
+ ['lefthook@2.1.15 || 2.1.17', 'lefthook@2.1.17'],
329
+ ['tsx@4.23.9', null],
330
+ ['fallow@3.31.0', 'fallow@3.31.0'],
331
+ ['gone@1.0.0', null],
332
+ ['x@^1.0.0', 'x@^1.0.0'],
333
+ ],
334
+ )
335
+ assert.deepEqual(plan[5].dropped, [{ version: '1.0.0', reason: 'not-installed' }])
336
+ })
337
+
338
+ test('planReleaseAgeExcludePrune keeps versions with unknown publish time', () => {
339
+ const plan = planReleaseAgeExcludePrune(['private@1.0.0'], {
340
+ lockfileVersions: new Map([['private', new Set(['1.0.0'])]]),
341
+ publishTime: () => undefined,
342
+ minimumReleaseAgeMinutes: 1440,
343
+ })
344
+ assert.equal(plan[0].next, 'private@1.0.0')
345
+ })
346
+
347
+ test('readWorkspaceList and rewriteWorkspaceList edit only the given list', () => {
348
+ const text = [
349
+ 'allowBuilds:',
350
+ ' esbuild: true',
351
+ 'minimumReleaseAgeExclude:',
352
+ ' - fg-devkit',
353
+ ' - tsx@4.23.9',
354
+ ' - lefthook@2.1.15 || 2.1.17',
355
+ " - '@fallow-cli/darwin-arm64@3.31.0'",
356
+ 'overrides:',
357
+ " 'a@<1.0.1': '1.0.1'",
358
+ '',
359
+ ].join('\n')
360
+ assert.deepEqual(readWorkspaceList(text, 'minimumReleaseAgeExclude'), [
361
+ 'fg-devkit',
362
+ 'tsx@4.23.9',
363
+ 'lefthook@2.1.15 || 2.1.17',
364
+ '@fallow-cli/darwin-arm64@3.31.0',
365
+ ])
366
+ const result = rewriteWorkspaceList(
367
+ text,
368
+ 'minimumReleaseAgeExclude',
369
+ new Map([
370
+ ['tsx@4.23.9', null],
371
+ ['lefthook@2.1.15 || 2.1.17', 'lefthook@2.1.17'],
372
+ ['@fallow-cli/darwin-arm64@3.31.0', null],
373
+ ]),
374
+ )
375
+ assert.equal(
376
+ result,
377
+ [
378
+ 'allowBuilds:',
379
+ ' esbuild: true',
380
+ 'minimumReleaseAgeExclude:',
381
+ ' - fg-devkit',
382
+ ' - lefthook@2.1.17',
383
+ 'overrides:',
384
+ " 'a@<1.0.1': '1.0.1'",
385
+ '',
386
+ ].join('\n'),
387
+ )
388
+ const emptied = rewriteWorkspaceList(
389
+ 'minimumReleaseAgeExclude:\n - tsx@4.23.9\nallowBuilds:\n esbuild: true\n',
390
+ 'minimumReleaseAgeExclude',
391
+ new Map([['tsx@4.23.9', null]]),
392
+ )
393
+ assert.equal(emptied, 'allowBuilds:\n esbuild: true\n')
394
+ })
@@ -10,11 +10,14 @@
10
10
  * pnpm-workspace.yaml. Findings with no patch, a patch in another major, a patch
11
11
  * younger than minimumReleaseAge, or on a direct dependency are reported, not overridden.
12
12
  * 4. Prune overrides that no longer change the audit (prune-overrides --fix).
13
- * 5. If findings that are still reported have active <suppress> entries in fg-devkit's
13
+ * 5. Prune minimumReleaseAgeExclude entries that are no longer needed
14
+ * (prune-release-age-exclude --fix). Runs even when the audit is clean.
15
+ * 6. If findings that are still reported have active <suppress> entries in fg-devkit's
14
16
  * shared dependency-check-suppressions.xml, write the repo's copy with only those
15
17
  * entries, so OWASP Dependency-Check accepts exactly the risks this repo has.
16
- * 6. Install and run the repo's checks (lint-check, lint:ts, test, test:unit, build).
17
- * 7. Commit the refresh, the override changes and the suppressions file separately.
18
+ * 7. Install and run the repo's checks (lint-check, lint:ts, test, test:unit, build).
19
+ * 8. Commit the refresh, the overrides, the release-age excludes and the suppressions
20
+ * file separately.
18
21
  *
19
22
  * Usage:
20
23
  * pnpm exec fg-devkit audit-override-commit [--no-verify] [--no-commit]
@@ -34,7 +37,9 @@ import {
34
37
  isReleaseAgeExcluded,
35
38
  normalizeAdvisories,
36
39
  parseAuditJson,
40
+ parseReleaseAgeExcludeEntry,
37
41
  planOverrides,
42
+ readWorkspaceList,
38
43
  upsertWorkspaceOverrides,
39
44
  } from './audit-fix-plan.mjs'
40
45
  import {
@@ -246,6 +251,44 @@ function writeOverrides(overrides) {
246
251
  runOrFail('pnpm', ['install', '--lockfile-only', '--ignore-scripts', '--no-frozen-lockfile'])
247
252
  }
248
253
 
254
+ /**
255
+ * Remove minimumReleaseAgeExclude entries that are no longer needed. Returns the
256
+ * entries removed and the narrowed entries added, for the commit message.
257
+ */
258
+ function pruneReleaseAgeExclude() {
259
+ const before = readWorkspaceList(readFile('pnpm-workspace.yaml') ?? '', 'minimumReleaseAgeExclude')
260
+ if (before.length === 0) return { removed: [], added: [] }
261
+ console.log(`${TAG} Checking minimumReleaseAgeExclude…`)
262
+ const result = run(process.execPath, [path.join(scriptDir, 'prune-release-age-exclude.mjs'), '--fix'])
263
+ if (result.status !== 0) {
264
+ console.warn(`${TAG} Could not prune minimumReleaseAgeExclude; left unchanged.`)
265
+ return { removed: [], added: [] }
266
+ }
267
+ const after = readWorkspaceList(readFile('pnpm-workspace.yaml') ?? '', 'minimumReleaseAgeExclude')
268
+ return {
269
+ removed: before.filter((entry) => !after.includes(entry)),
270
+ added: after.filter((entry) => !before.includes(entry)),
271
+ }
272
+ }
273
+
274
+ /** `removed entry` or `old entry -> narrowed entry` lines. */
275
+ function describeExcludeChanges({ removed, added }) {
276
+ const nameOf = (entry) => parseReleaseAgeExcludeEntry(entry).name
277
+ return removed.map((entry) => {
278
+ const narrowed = added.find((item) => nameOf(item) === nameOf(entry))
279
+ return narrowed ? `${entry} -> ${narrowed}` : `${entry}`
280
+ })
281
+ }
282
+
283
+ function buildReleaseAgeExcludeCommitMessage(changes) {
284
+ return {
285
+ subject: 'chore(repo): prune minimumReleaseAgeExclude',
286
+ body: describeExcludeChanges(changes)
287
+ .map((line) => `- ${line}`)
288
+ .join('\n'),
289
+ }
290
+ }
291
+
249
292
  function pruneOverrides() {
250
293
  if (!/^overrides:/m.test(readFile('pnpm-workspace.yaml') ?? '')) return
251
294
  console.log(`${TAG} Pruning overrides that no longer affect pnpm audit…`)
@@ -294,7 +337,7 @@ function verify() {
294
337
  }
295
338
  }
296
339
 
297
- function printSummary({ fixedByRefresh, plan, heldBack, applied, remaining, suppressions }) {
340
+ function printSummary({ fixedByRefresh, plan, heldBack, applied, remaining, suppressions, excludeChanges }) {
298
341
  const suppressed = new Set(suppressions.covered.map((advisory) => advisory.ghsa))
299
342
  const unique = (items) => [...new Set(items)]
300
343
  const lines = []
@@ -337,6 +380,7 @@ function printSummary({ fixedByRefresh, plan, heldBack, applied, remaining, supp
337
380
  'Could not read the patched range, check manually:',
338
381
  plan.unparseable.map((item) => `${item.name}: ${item.patched} (${item.ghsas.join(', ')})`),
339
382
  )
383
+ section('Removed from minimumReleaseAgeExclude (no longer needed):', describeExcludeChanges(excludeChanges))
340
384
  if (remaining.length > 0) {
341
385
  lines.push(`Still reported by pnpm audit: ${remaining.length} advisory range(s).`)
342
386
  } else {
@@ -360,60 +404,79 @@ function main() {
360
404
 
361
405
  console.log(`${TAG} Running pnpm audit…`)
362
406
  const initial = audit()
407
+ let refreshed = original
408
+ let refreshChanges = []
409
+ let fixedByRefresh = []
410
+ let plan = { overrides: [], noPatch: [], crossMajor: [], direct: [], unparseable: [] }
411
+ let heldBack = []
412
+ let allowed = []
413
+ let remaining = []
414
+
363
415
  if (initial.length === 0) {
364
- console.log(`${TAG} pnpm audit is clean. Nothing to do.`)
365
- return 0
416
+ console.log(`${TAG} pnpm audit is clean.`)
417
+ } else {
418
+ console.log(`${TAG} ${initial.length} advisory range(s) in ${new Set(initial.map((a) => a.name)).size} package(s).`)
419
+
420
+ // 2. Refresh within ranges.
421
+ refreshLockfile(initial)
422
+ refreshed = snapshot()
423
+ refreshChanges = diffLockfileVersions(original['pnpm-lock.yaml'], refreshed['pnpm-lock.yaml'])
424
+
425
+ // 3. Plan overrides for what is left.
426
+ const afterRefresh = audit()
427
+ const remainingIds = new Set(afterRefresh.map((advisory) => advisory.id))
428
+ fixedByRefresh = [
429
+ ...new Set(initial.filter((advisory) => !remainingIds.has(advisory.id)).map((a) => `${a.name} (${a.ghsa})`)),
430
+ ]
431
+ plan = planOverrides(afterRefresh, { directDependencies: directDependencies() })
432
+ ;({ allowed, heldBack } = applyReleaseAgeGate(plan.overrides))
433
+ if (allowed.length > 0) writeOverrides(allowed)
434
+
435
+ // 4. Prune overrides.
436
+ pruneOverrides()
437
+ remaining = allowed.length > 0 ? audit() : afterRefresh
366
438
  }
367
- console.log(`${TAG} ${initial.length} advisory range(s) in ${new Set(initial.map((a) => a.name)).size} package(s).`)
368
-
369
- // 2. Refresh within ranges.
370
- refreshLockfile(initial)
371
- const refreshed = snapshot()
372
- const refreshChanges = diffLockfileVersions(original['pnpm-lock.yaml'], refreshed['pnpm-lock.yaml'])
373
-
374
- // 3. Plan overrides for what is left.
375
- const afterRefresh = audit()
376
- const remainingIds = new Set(afterRefresh.map((advisory) => advisory.id))
377
- const fixedByRefresh = [
378
- ...new Set(initial.filter((advisory) => !remainingIds.has(advisory.id)).map((a) => `${a.name} (${a.ghsa})`)),
379
- ]
380
- const plan = planOverrides(afterRefresh, { directDependencies: directDependencies() })
381
- const { allowed, heldBack } = applyReleaseAgeGate(plan.overrides)
382
- if (allowed.length > 0) writeOverrides(allowed)
383
-
384
- // 4. Prune, 5. sync suppressions, 6. verify.
385
- pruneOverrides()
386
- const remaining = allowed.length > 0 ? audit() : afterRefresh
439
+ const afterOverrides = snapshot()
440
+
441
+ // 5. Release-age excludes, 6. suppressions, 7. verify.
442
+ const excludeChanges = pruneReleaseAgeExclude()
387
443
  const suppressions = syncSuppressions(remaining)
388
- printSummary({ fixedByRefresh, plan, heldBack, applied: allowed, remaining, suppressions })
389
- verify()
444
+ printSummary({ fixedByRefresh, plan, heldBack, applied: allowed, remaining, suppressions, excludeChanges })
390
445
 
391
- // 7. Commit.
392
446
  const final = snapshot()
393
- const overrideChanges = collectOverrideChanges(overridesBefore, collectOverrides(cwd))
394
447
  const nothingChanged = !suppressionsWritten && TRACKED_FILES.every((file) => final[file] === original[file])
395
448
  if (nothingChanged) {
396
449
  console.log(`${TAG} No file changes.`)
397
450
  return 0
398
451
  }
452
+ verify()
453
+
454
+ // 8. Commit.
399
455
  if (!shouldCommit) {
400
456
  console.log(`${TAG} --no-commit: changes left in the working tree.`)
401
457
  return 0
402
458
  }
403
-
459
+ const overrideChanges = collectOverrideChanges(overridesBefore, collectOverrides(cwd))
404
460
  if (refreshChanges.length > 0 && stageContents(refreshed) > 0) {
405
461
  commit(buildRefreshCommitMessage(refreshChanges))
406
462
  }
407
- if (overrideChanges.length > 0) {
408
- for (const item of overrideChanges) {
409
- console.log(` - ${item.name}: ${item.from ?? '(missing)'} -> ${formatOverrideValue(item.to)}`)
410
- }
463
+ for (const item of overrideChanges) {
464
+ console.log(` - ${item.name}: ${item.from ?? '(missing)'} -> ${formatOverrideValue(item.to)}`)
411
465
  }
412
- if (stageContents(final) > 0) {
466
+ if (stageContents(afterOverrides) > 0) {
413
467
  commit(
414
468
  overrideChanges.length > 0
415
469
  ? buildOverrideCommitMessage(overrideChanges)
416
- : buildRefreshCommitMessage(diffLockfileVersions(refreshed['pnpm-lock.yaml'], final['pnpm-lock.yaml'])),
470
+ : buildRefreshCommitMessage(
471
+ diffLockfileVersions(refreshed['pnpm-lock.yaml'], afterOverrides['pnpm-lock.yaml']),
472
+ ),
473
+ )
474
+ }
475
+ if (stageContents(snapshot()) > 0) {
476
+ commit(
477
+ excludeChanges.removed.length > 0 || excludeChanges.added.length > 0
478
+ ? buildReleaseAgeExcludeCommitMessage(excludeChanges)
479
+ : buildRefreshCommitMessage(diffLockfileVersions(afterOverrides['pnpm-lock.yaml'], final['pnpm-lock.yaml'])),
417
480
  )
418
481
  }
419
482
  if (suppressionsWritten) {
@@ -18,6 +18,7 @@
18
18
  * pnpm exec fg-devkit policy-upgrade-commit [--prune-overrides] [ncu-args...]
19
19
  * pnpm exec fg-devkit audit-override-commit [--no-verify] [--no-commit]
20
20
  * pnpm exec fg-devkit prune-overrides [--fix] [--commit]
21
+ * pnpm exec fg-devkit prune-release-age-exclude [--fix] [--commit]
21
22
  * pnpm exec fg-devkit sync-project-configs (VS Code + tooling sync; overwrites config files)
22
23
  * pnpm exec fg-devkit sync-tooling-configs (tooling sync only; overwrites config files)
23
24
  * pnpm exec fg-devkit sync-dependency-check-suppressions-commit [--all]
@@ -479,6 +480,7 @@ const DIRECT_COMMANDS = {
479
480
  'policy-upgrade-commit': 'policy-upgrade-commit.mjs',
480
481
  'audit-override-commit': 'audit-override-commit.mjs',
481
482
  'prune-overrides': 'prune-overrides.mjs',
483
+ 'prune-release-age-exclude': 'prune-release-age-exclude.mjs',
482
484
  'sync-project-configs': 'sync-project-configs.mjs',
483
485
  'sync-tooling-configs': 'sync-tooling-configs.mjs',
484
486
  'sync-dependency-check-suppressions-commit': 'sync-dependency-check-suppressions-commit.mjs',
@@ -546,6 +548,8 @@ Commands:
546
548
  Fix pnpm audit findings: refresh lockfile first, override only when a range blocks the fix, verify, auto-commit
547
549
  prune-overrides [--fix] [--commit]
548
550
  Report unused workspace overrides; --fix removes them; --commit also auto-commits
551
+ prune-release-age-exclude [--fix] [--commit]
552
+ Report minimumReleaseAgeExclude entries that are no longer needed; --fix removes them; --commit also auto-commits
549
553
  sync-project-configs VS Code + tooling sync (overwrites config files)
550
554
  sync-tooling-configs Tooling sync only (overwrites config files)
551
555
  sync-dependency-check-suppressions-commit [--all]
@@ -975,8 +979,15 @@ async function runInteractiveMenu() {
975
979
  { name: 'Sync and auto-commit', value: 'sync-commit' },
976
980
  { name: 'Upgrade package.json packages and auto-commit', value: 'upgrade-commit' },
977
981
  { name: 'Upgrade package.json packages, prune overrides, and auto-commit', value: 'upgrade-commit-prune' },
978
- { name: 'Fix vulnerability overrides and auto-commit', value: 'audit-override-commit' },
982
+ {
983
+ name: 'Fix vulnerabilities (refresh, override, prune) and auto-commit',
984
+ value: 'audit-override-commit',
985
+ },
979
986
  { name: 'Prune unused vulnerability overrides and auto-commit', value: 'prune-overrides' },
987
+ {
988
+ name: 'Prune unneeded minimumReleaseAgeExclude entries and auto-commit',
989
+ value: 'prune-release-age-exclude',
990
+ },
980
991
  { name: 'Cancel', value: 'cancel' },
981
992
  ],
982
993
  default: 'check',
@@ -999,6 +1010,9 @@ async function runInteractiveMenu() {
999
1010
  if (choice === 'prune-overrides') {
1000
1011
  return runSelectedAction(() => runNode('prune-overrides.mjs', ['--commit']))
1001
1012
  }
1013
+ if (choice === 'prune-release-age-exclude') {
1014
+ return runSelectedAction(() => runNode('prune-release-age-exclude.mjs', ['--commit']))
1015
+ }
1002
1016
  return 1
1003
1017
  }
1004
1018
 
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Find minimumReleaseAgeExclude entries in pnpm-workspace.yaml that are no longer needed.
4
+ *
5
+ * A pinned version (`name@1.2.3 || 1.2.4`) is only needed while it is resolved in the
6
+ * lockfile and still younger than minimumReleaseAge. Versions that are old enough now, or
7
+ * no longer installed, are removed; an entry with no versions left is removed entirely.
8
+ * Name rules (`fg-devkit`, `@scope/*`) allow every future release on purpose and are kept.
9
+ *
10
+ * Usage:
11
+ * pnpm exec fg-devkit prune-release-age-exclude # report only
12
+ * pnpm exec fg-devkit prune-release-age-exclude --fix # remove what is not needed
13
+ * pnpm exec fg-devkit prune-release-age-exclude --commit # remove and auto-commit
14
+ */
15
+
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+ import { spawnSync } from 'node:child_process'
19
+ import { parseArgs } from 'node:util'
20
+ import {
21
+ DEFAULT_MINIMUM_RELEASE_AGE_MINUTES,
22
+ lockfileVersions,
23
+ parseAuditJson,
24
+ planReleaseAgeExcludePrune,
25
+ readWorkspaceList,
26
+ rewriteWorkspaceList,
27
+ } from './audit-fix-plan.mjs'
28
+
29
+ const TAG = '[prune-release-age-exclude]'
30
+ const KEY = 'minimumReleaseAgeExclude'
31
+ const WORKSPACE_FILE = 'pnpm-workspace.yaml'
32
+ const COMMIT_SUBJECT = 'chore(repo): prune minimumReleaseAgeExclude'
33
+ const cwd = process.cwd()
34
+
35
+ const { values: args } = parseArgs({
36
+ options: {
37
+ fix: { type: 'boolean', default: false },
38
+ commit: { type: 'boolean', default: false },
39
+ help: { type: 'boolean', short: 'h', default: false },
40
+ },
41
+ strict: true,
42
+ })
43
+
44
+ if (args.help) {
45
+ console.log(`Usage: pnpm exec fg-devkit prune-release-age-exclude [--fix] [--commit]
46
+
47
+ Reports ${KEY} entries in ${WORKSPACE_FILE} that are no longer needed: pinned
48
+ versions that are older than minimumReleaseAge, or no longer in the lockfile.
49
+ Name rules such as "fg-devkit" or "@scope/*" are always kept.
50
+
51
+ --fix Remove them and re-verify the lockfile
52
+ --commit Remove them and auto-commit`)
53
+ process.exit(0)
54
+ }
55
+
56
+ function run(command, commandArgs, { capture = true } = {}) {
57
+ const result = spawnSync(command, commandArgs, {
58
+ cwd,
59
+ env: process.env,
60
+ encoding: 'utf8',
61
+ stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
62
+ maxBuffer: 50 * 1024 * 1024,
63
+ })
64
+ if (result.error) throw result.error
65
+ return result
66
+ }
67
+
68
+ function readJsonOutput(command, commandArgs) {
69
+ const result = run(command, commandArgs)
70
+ if (result.status !== 0) return undefined
71
+ try {
72
+ return parseAuditJson(result.stdout)
73
+ } catch {
74
+ return undefined
75
+ }
76
+ }
77
+
78
+ function describe(item) {
79
+ const reasons = item.dropped.map(
80
+ ({ version, reason }) =>
81
+ `${version} (${reason === 'old-enough' ? 'older than minimumReleaseAge' : 'not in lockfile'})`,
82
+ )
83
+ return item.next === null
84
+ ? `${item.entry}: remove, ${reasons.join(', ')}`
85
+ : `${item.entry} -> ${item.next}: drop ${reasons.join(', ')}`
86
+ }
87
+
88
+ function main() {
89
+ const workspacePath = path.join(cwd, WORKSPACE_FILE)
90
+ const lockfilePath = path.join(cwd, 'pnpm-lock.yaml')
91
+ if (!fs.existsSync(workspacePath) || !fs.existsSync(lockfilePath)) {
92
+ console.log(`${TAG} No ${WORKSPACE_FILE} or pnpm-lock.yaml. Nothing to check.`)
93
+ return 0
94
+ }
95
+
96
+ const original = fs.readFileSync(workspacePath, 'utf8')
97
+ const entries = readWorkspaceList(original, KEY)
98
+ if (entries.length === 0) {
99
+ console.log(`${TAG} No ${KEY} entries.`)
100
+ return 0
101
+ }
102
+
103
+ const config = readJsonOutput('pnpm', ['config', 'list', '--json']) ?? {}
104
+ const minutes = Number(config.minimumReleaseAge ?? DEFAULT_MINIMUM_RELEASE_AGE_MINUTES)
105
+ const timesByName = new Map()
106
+ const publishTime = (name, version) => {
107
+ if (!timesByName.has(name)) timesByName.set(name, readJsonOutput('pnpm', ['view', name, 'time', '--json']) ?? {})
108
+ const time = Date.parse(timesByName.get(name)[version] ?? '')
109
+ return Number.isFinite(time) ? time : undefined
110
+ }
111
+
112
+ console.log(
113
+ `${TAG} Checking ${entries.length} ${KEY} entr${entries.length === 1 ? 'y' : 'ies'} (minimumReleaseAge ${minutes} min)…`,
114
+ )
115
+ const plan = planReleaseAgeExcludePrune(entries, {
116
+ lockfileVersions: lockfileVersions(fs.readFileSync(lockfilePath, 'utf8')),
117
+ publishTime,
118
+ minimumReleaseAgeMinutes: minutes,
119
+ })
120
+ const changes = plan.filter((item) => item.next !== item.entry)
121
+ if (changes.length === 0) {
122
+ console.log(`${TAG} Every entry is still needed.`)
123
+ return 0
124
+ }
125
+
126
+ console.log(`${TAG} Not needed (${changes.length}):`)
127
+ for (const item of changes) console.log(` - ${describe(item)}`)
128
+
129
+ if (!args.fix && !args.commit) {
130
+ console.log(`${TAG} Re-run with --fix to remove them, or --commit to remove and auto-commit.`)
131
+ return 0
132
+ }
133
+
134
+ fs.writeFileSync(
135
+ workspacePath,
136
+ rewriteWorkspaceList(original, KEY, new Map(changes.map((item) => [item.entry, item.next]))),
137
+ )
138
+ // pnpm verifies the lockfile against the release-age policy here; restore on failure.
139
+ const verify = run('pnpm', ['install', '--lockfile-only', '--ignore-scripts', '--no-frozen-lockfile'])
140
+ if (verify.status !== 0) {
141
+ fs.writeFileSync(workspacePath, original)
142
+ console.error(`${TAG} pnpm rejected the lockfile without these entries, restored ${WORKSPACE_FILE}.`)
143
+ console.error((verify.stderr || verify.stdout || '').trim())
144
+ return 1
145
+ }
146
+ console.log(`${TAG} Updated ${WORKSPACE_FILE}.`)
147
+
148
+ if (!args.commit) return 0
149
+ const files = [WORKSPACE_FILE, 'pnpm-lock.yaml'].filter((file) => fs.existsSync(path.join(cwd, file)))
150
+ if (run('git', ['add', '--', ...files], { capture: false }).status !== 0) return 1
151
+ const body = changes.map((item) => `- ${describe(item)}`).join('\n')
152
+ console.log(`${TAG} Auto-committing: ${COMMIT_SUBJECT}`)
153
+ return run('git', ['commit', '-m', COMMIT_SUBJECT, '-m', body], { capture: false }).status ?? 1
154
+ }
155
+
156
+ try {
157
+ process.exit(main())
158
+ } catch (error) {
159
+ console.error(`${TAG} ${error instanceof Error ? error.message : error}`)
160
+ process.exit(2)
161
+ }