fg-devkit 1.21.0 → 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 +27 -11
- package/configs/eslint.config.base.d.ts +16 -0
- package/package.json +5 -2
- package/scaffold/vue/package.json +1 -1
- package/scripts/audit-fix-plan.mjs +134 -0
- package/scripts/audit-fix-plan.test.mjs +107 -0
- package/scripts/audit-override-commit.mjs +101 -38
- package/scripts/bump-node-toolchain.mjs +2 -5
- package/scripts/fg-devkit.mjs +15 -1
- package/scripts/fleet-project-repos.mjs +25 -5
- package/scripts/node-toolchain-sync.mjs +2 -2
- package/scripts/piwik-dimensions.test.mjs +0 -17
- package/scripts/prune-release-age-exclude.mjs +161 -0
- package/scripts/sync-dependency-check-suppressions-commit.mjs +1 -1
package/README.md
CHANGED
|
@@ -72,8 +72,7 @@ pnpm exec fgd sync-dependency-check-suppressions-commit --all # every entry in
|
|
|
72
72
|
|
|
73
73
|
The command runs `pnpm audit` and keeps only the active (not expired) `<suppress>` entries from fg-devkit's shared
|
|
74
74
|
`configs/dependency-check-suppressions.xml` whose GHSA id is reported in the repo, so each repo accepts exactly the
|
|
75
|
-
risks it has.
|
|
76
|
-
file exists. Add another `<suppress>` element in the shared fg-devkit file for each additional accepted finding.
|
|
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.
|
|
77
76
|
The command overwrites the consumer copy; if nothing applies and the repo has no copy, no file is created.
|
|
78
77
|
|
|
79
78
|
To sync standard consumer scripts to `fgd` commands:
|
|
@@ -174,15 +173,18 @@ pnpm exec fgd audit-override-commit --no-commit # leave the changes in the work
|
|
|
174
173
|
younger than `minimumReleaseAge` (re-run later), when the package is a direct dependency (bump it with
|
|
175
174
|
`policy-upgrade-commit`), or when no patch exists (candidate for `auditConfig.ignoreGhsas`).
|
|
176
175
|
4. Prunes overrides that no longer change the audit.
|
|
177
|
-
5.
|
|
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
|
|
178
179
|
`configs/dependency-check-suppressions.xml`, writes the repo's `dependency-check-suppressions.xml` with only
|
|
179
|
-
those entries, so
|
|
180
|
+
those entries, so OWASP Dependency-Check accepts exactly the risks this repo has. Findings with no patch and
|
|
180
181
|
no suppression are listed so you can add one.
|
|
181
|
-
|
|
182
|
+
7. Installs and runs the repo's `lint-check`, `lint:ts`, `test`, `test:unit` and `build` scripts when present.
|
|
182
183
|
Nothing is committed if a check fails.
|
|
183
|
-
|
|
184
|
-
the override changes (`vuln(package): override version`, `vuln(): override N packages`)
|
|
185
|
-
|
|
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.
|
|
186
188
|
|
|
187
189
|
Report, remove, or remove and auto-commit workspace overrides that no longer change `pnpm audit`:
|
|
188
190
|
|
|
@@ -192,6 +194,20 @@ pnpm exec fgd prune-overrides --fix
|
|
|
192
194
|
pnpm exec fgd prune-overrides --commit
|
|
193
195
|
```
|
|
194
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
|
+
|
|
195
211
|
## tsconfig in consuming repos
|
|
196
212
|
|
|
197
213
|
Use `fgd sync-project-configs` (or `fgd sync-tooling-configs`) to write the local `tsconfig.json` template from `fg-devkit`.
|
|
@@ -217,9 +233,9 @@ export default {
|
|
|
217
233
|
|
|
218
234
|
## Piwik PRO custom dimensions
|
|
219
235
|
|
|
220
|
-
Compare the local slot map with a Piwik PRO site, then create missing dimensions or rename existing ones. Run it from a consuming repo
|
|
236
|
+
Compare the local slot map with a Piwik PRO site, then create missing dimensions or rename existing ones. Run it from a consuming repo.
|
|
221
237
|
|
|
222
|
-
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.
|
|
238
|
+
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.
|
|
223
239
|
|
|
224
240
|
```bash
|
|
225
241
|
pnpm exec fgd piwik list
|
|
@@ -232,7 +248,7 @@ pnpm exec fgd piwik compare --from preprod --to production
|
|
|
232
248
|
pnpm exec fgd piwik migrate --from preprod --to production --dry-run
|
|
233
249
|
```
|
|
234
250
|
|
|
235
|
-
`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.
|
|
251
|
+
`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.
|
|
236
252
|
|
|
237
253
|
`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.
|
|
238
254
|
|
|
@@ -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.
|
|
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":
|
|
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",
|
|
@@ -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.
|
|
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
|
-
* entries, so
|
|
16
|
-
*
|
|
17
|
-
*
|
|
17
|
+
* entries, so OWASP Dependency-Check accepts exactly the risks this repo has.
|
|
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
|
|
365
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
//
|
|
370
|
-
|
|
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
|
-
|
|
408
|
-
|
|
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(
|
|
466
|
+
if (stageContents(afterOverrides) > 0) {
|
|
413
467
|
commit(
|
|
414
468
|
overrideChanges.length > 0
|
|
415
469
|
? buildOverrideCommitMessage(overrideChanges)
|
|
416
|
-
: buildRefreshCommitMessage(
|
|
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) {
|
|
@@ -33,10 +33,7 @@ import fs from 'node:fs'
|
|
|
33
33
|
import path from 'node:path'
|
|
34
34
|
import { fileURLToPath } from 'node:url'
|
|
35
35
|
|
|
36
|
-
import {
|
|
37
|
-
DEFAULT_NODE_TOOLCHAIN_EXCLUDE,
|
|
38
|
-
collectFleetProjectRepos,
|
|
39
|
-
} from './fleet-project-repos.mjs'
|
|
36
|
+
import { collectFleetProjectRepos, toolchainExclude } from './fleet-project-repos.mjs'
|
|
40
37
|
import { parseFleetSelectionFlags, resolveFleetRepos } from './fleet-repo-picker.mjs'
|
|
41
38
|
import {
|
|
42
39
|
formatNodeNvmrc,
|
|
@@ -213,7 +210,7 @@ async function main() {
|
|
|
213
210
|
process.exit(1)
|
|
214
211
|
}
|
|
215
212
|
|
|
216
|
-
const allRepos = collectFleetProjectRepos(projectsRoot, { exclude:
|
|
213
|
+
const allRepos = collectFleetProjectRepos(projectsRoot, { exclude: toolchainExclude(projectsRoot) })
|
|
217
214
|
if (allRepos.length === 0) {
|
|
218
215
|
console.log(`[bump-node-toolchain] No project repositories under ${projectsRoot}`)
|
|
219
216
|
process.exit(0)
|
package/scripts/fg-devkit.mjs
CHANGED
|
@@ -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
|
-
{
|
|
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
|
|
|
@@ -80,11 +80,31 @@ export function collectFleetGitRepos(projectsRoot) {
|
|
|
80
80
|
return repos.sort((a, b) => a.localeCompare(b))
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
/**
|
|
84
|
-
export const
|
|
83
|
+
/** Local config file in the projects root (not part of fg-devkit). */
|
|
84
|
+
export const FLEET_CONFIG_FILE = '.fg-devkit-fleet.json'
|
|
85
85
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Repos excluded from fleet-wide Node and pnpm toolchain bumps (for example repos that
|
|
88
|
+
* maintain several Node versions or do not use pnpm). Read from `toolchainExclude` in
|
|
89
|
+
* `<projectsRoot>/.fg-devkit-fleet.json` and the comma-separated `FGD_TOOLCHAIN_EXCLUDE`
|
|
90
|
+
* environment variable.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} projectsRoot
|
|
93
|
+
* @returns {Set<string>}
|
|
94
|
+
*/
|
|
95
|
+
export function toolchainExclude(projectsRoot) {
|
|
96
|
+
const names = new Set()
|
|
97
|
+
try {
|
|
98
|
+
const config = JSON.parse(fs.readFileSync(path.join(projectsRoot, FLEET_CONFIG_FILE), 'utf8'))
|
|
99
|
+
for (const name of config?.toolchainExclude ?? []) names.add(String(name))
|
|
100
|
+
} catch {
|
|
101
|
+
// No local config: nothing excluded.
|
|
102
|
+
}
|
|
103
|
+
for (const name of (process.env.FGD_TOOLCHAIN_EXCLUDE ?? '').split(',')) {
|
|
104
|
+
if (name.trim()) names.add(name.trim())
|
|
105
|
+
}
|
|
106
|
+
return names
|
|
107
|
+
}
|
|
88
108
|
|
|
89
109
|
/**
|
|
90
110
|
* @param {string} dir
|
|
@@ -108,7 +128,7 @@ export function isPnpmPackageJsonDir(dir, pkg) {
|
|
|
108
128
|
export function collectFleetPnpmRepos(projectsRoot, options = {}) {
|
|
109
129
|
const exclude = new Set([
|
|
110
130
|
...DEFAULT_FLEET_REPO_EXCLUDE,
|
|
111
|
-
...
|
|
131
|
+
...toolchainExclude(projectsRoot),
|
|
112
132
|
...(options.exclude ?? []),
|
|
113
133
|
])
|
|
114
134
|
const { skipScaffoldVue = true } = options
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from './dependency-policy-toolchain.mjs'
|
|
14
14
|
|
|
15
15
|
const DOCKER_FROM_NODE_PATTERN = /FROM node:(v)?(\d+(?:\.\d+)?)(-[a-z0-9-]+)?/gi
|
|
16
|
-
const JENKINS_NODE_IMAGE_PATTERN = /(
|
|
16
|
+
const JENKINS_NODE_IMAGE_PATTERN = /([\w.-]+\/jenkins-build:)node\d+/g
|
|
17
17
|
const GITHUB_NODE_VERSION_PATTERN = /node-version:\s*['"]?\d+(?:\.\d+)?['"]?/g
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -323,7 +323,7 @@ export function collectNodeToolchainViolations(repoRoot, policy, fgDevkitRoot) {
|
|
|
323
323
|
const expectedJenkins = jenkinsImage ?? deriveJenkinsImageTag(nvmrc)
|
|
324
324
|
if (fs.existsSync(jenkinsfilePath)) {
|
|
325
325
|
const jenkinsfile = fs.readFileSync(jenkinsfilePath, 'utf8')
|
|
326
|
-
const matches = [...jenkinsfile.matchAll(/
|
|
326
|
+
const matches = [...jenkinsfile.matchAll(/[\w.-]+\/jenkins-build:(node\d+)/g)]
|
|
327
327
|
for (const match of matches) {
|
|
328
328
|
if (match[1] !== expectedJenkins) {
|
|
329
329
|
violations.push(`Jenkinsfile: expected jenkins-build:${expectedJenkins}, got jenkins-build:${match[1]}`)
|
|
@@ -381,20 +381,3 @@ test('pull plans tracking id updates from Piwik into the local map', () => {
|
|
|
381
381
|
'export const customDimensions = { active: 46, step: 30 } as const\n',
|
|
382
382
|
)
|
|
383
383
|
})
|
|
384
|
-
|
|
385
|
-
test('parses the real consumer dimension files when they are checked out beside fg-devkit', () => {
|
|
386
|
-
const files = [
|
|
387
|
-
['../../bokningsverktyget-ui/src/analytics/dimensions.ts', 'event', 'rowsBooked', 7],
|
|
388
|
-
['../../webbportalen-ui/src/analytics/dimensions.ts', 'session', 'organizationName', 26],
|
|
389
|
-
['../../feelgood-app/src/util/piwikDimensions.ts', 'event', 'eventTimestamp', 78],
|
|
390
|
-
]
|
|
391
|
-
for (const [relative, scope, key, slot] of files) {
|
|
392
|
-
const file = path.join(scriptDir, relative)
|
|
393
|
-
if (!fs.existsSync(file)) continue
|
|
394
|
-
const dimensions = parseDimensionSource(fs.readFileSync(file, 'utf8'), relative)
|
|
395
|
-
const found = dimensions.find((item) => item.key === key)
|
|
396
|
-
assert.ok(found, `${key} missing in ${relative}`)
|
|
397
|
-
assert.equal(found.scope, scope)
|
|
398
|
-
assert.equal(found.slot, slot)
|
|
399
|
-
}
|
|
400
|
-
})
|
|
@@ -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
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Write dependency-check-suppressions.xml into the consumer repo and auto-commit.
|
|
4
4
|
*
|
|
5
5
|
* Only the active <suppress> entries from fg-devkit's shared file that match a finding
|
|
6
|
-
* `pnpm audit` still reports in this repo are written, so
|
|
6
|
+
* `pnpm audit` still reports in this repo are written, so OWASP Dependency-Check accepts
|
|
7
7
|
* exactly the risks this repo has. Pass --all to copy the whole shared file instead.
|
|
8
8
|
*
|
|
9
9
|
* Usage:
|