ReviewOS

also looking at this

stacks/buddy

feat(engines): propose runtime bumps, which were resolved and never proposed

#1428
Merged glennmichael123 wants to merge feat/engine-updates into main
8 files +649 -14

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

docs/config.mdmodified+1-0
Changes to docs/config.md
@@ -159,6 +159,7 @@ matches `react` but not `@types/node`. Use `['**']` for every package, or
159159| `autoMerge` | Allows unattended merge — see the caveat below |
160160| `autoMigrate` | Attempts the migration for matching majors |
161161| `minimumReleaseAge` | Minutes a version must have been published |
162| `engines` | Propose bumps to the runtimes under `engines` (default off) |
162163| `prPriority` | Ordering within `maxPRsPerRun`; higher goes first |
163164
164165`prPriority` is applied **before** the per-run cap, so a high-priority group
src/buddy.tsmodified+102-0
Changes to src/buddy.ts
@@ -168,6 +168,15 @@ export class Buddy {
168168 // Get outdated Zig dependencies
169169 const zigStartTime = Date.now()
170170 const zigUpdates = await this.checkZigForUpdates(packageFiles)
171
172 // Runtimes declared under `engines`. Both halves of this — resolving a
173 // newer runtime and rewriting the constraint — existed and were tested,
174 // and no scan step called either, so an engine was listed on the
175 // dashboard and never proposed. Opt-in, because bumping one is a
176 // deployment decision rather than a dependency one.
177 const engineUpdates = this.config.packages?.engines === true
178 ? await this.checkEnginesForUpdates(packageFiles)
179 : []
171180 this.logger.info(`⏱️ Zig dependency checks took ${Date.now() - zigStartTime}ms (found ${zigUpdates.length} updates)`)
172181
173182 // pnpm catalogs are where the version lives in a workspace that uses
@@ -212,6 +221,7 @@ export class Buddy {
212221 ...githubActionsUpdates,
213222 ...dockerUpdates,
214223 ...zigUpdates,
224 ...engineUpdates,
215225 ...adapterScan.updates,
216226 ...catalogScan.updates,
217227 ]
@@ -1403,6 +1413,71 @@ export class Buddy {
14031413 return deduplicatedUpdates
14041414 }
14051415
1416 /**
1417 * Propose bumps to the runtimes declared under `engines`.
1418 *
1419 * Node and Deno publish releases on GitHub; the package managers publish to
1420 * npm. `resolveEngineVersion` knows which is which, and the lookups are the
1421 * clients this instance already has, so the npm side shares the registry
1422 * cache with everything else in the scan.
1423 *
1424 * @param packageFiles - Every scanned manifest
1425 * @returns One update per engine whose constraint no longer admits the newest release
1426 */
1427 private async checkEnginesForUpdates(packageFiles: PackageFile[]): Promise<PackageUpdate[]> {
1428 const { bumpEngineConstraint, engineUpdateType, resolveEngineVersion } = await import('./scanner/package-json-extras')
1429 const { fetchLatestActionVersion } = await import('./utils/github-actions-parser')
1430
1431 const engines = packageFiles.flatMap(file =>
1432 file.dependencies
1433 .filter(dep => dep.type === 'engines')
1434 .map(dep => ({ file, dep })),
1435 )
1436
1437 if (engines.length === 0)
1438 return []
1439
1440 this.logger.info(`⚡ Checking ${engines.length} engine constraint(s)...`)
1441
1442 const lookups = {
1443 npm: (name: string) => this.registryClient.getLatestVersion(name),
1444 // The action fetcher takes `owner/repo` and returns the latest release
1445 // tag, which is exactly the shape a runtime's GitHub releases have.
1446 github: (repo: string) => fetchLatestActionVersion(repo),
1447 }
1448
1449 const checks = engines.map(async ({ file, dep }): Promise<PackageUpdate | null> => {
1450 try {
1451 const latest = await resolveEngineVersion(dep.name, lookups)
1452 if (!latest)
1453 return null
1454
1455 const proposed = bumpEngineConstraint(dep.currentVersion, latest)
1456 if (!proposed) {
1457 this.logger.debug(`${dep.name} ${dep.currentVersion} already admits ${latest}`)
1458 return null
1459 }
1460
1461 this.logger.info(`Update available: ${dep.name} ${dep.currentVersion} → ${proposed}`)
1462
1463 return {
1464 name: dep.name,
1465 currentVersion: dep.currentVersion,
1466 newVersion: proposed,
1467 updateType: engineUpdateType(dep.currentVersion, proposed),
1468 dependencyType: 'engines',
1469 file: file.path,
1470 }
1471 }
1472 catch (error) {
1473 this.logger.warn(`Could not check engine ${dep.name}:`, error)
1474 return null
1475 }
1476 })
1477
1478 return (await Promise.all(checks)).filter((update): update is PackageUpdate => update !== null)
1479 }
1480
14061481 /**
14071482 * Check Dockerfiles for updates
14081483 */
@@ -1601,11 +1676,38 @@ export class Buddy {
16011676 // Handle package.json updates - only for actual package.json files, not dependency or GitHub Actions files
16021677 const packageJsonUpdates = updates.filter(update =>
16031678 update.file.endsWith('package.json')
1679 && update.dependencyType !== 'engines'
16041680 && !update.file.includes('.yaml')
16051681 && !update.file.includes('.yml')
16061682 && !update.file.includes('.github/workflows/'),
16071683 )
16081684
1685 // Engine constraints live under `engines`, which the dependency-section
1686 // loop below never looks in — an engine update sent through it logged
1687 // "not found" and wrote nothing.
1688 const engineUpdates = updates.filter(update => update.dependencyType === 'engines')
1689 if (engineUpdates.length > 0) {
1690 const { applyEngineUpdates } = await import('./scanner/package-json-extras')
1691 const byFile = new Map<string, PackageUpdate[]>()
1692 for (const update of engineUpdates)
1693 byFile.set(update.file, [...(byFile.get(update.file) ?? []), update])
1694
1695 for (const [path, fileEngines] of byFile) {
1696 try {
1697 const original = fs.readFileSync(path, 'utf-8')
1698 const content = applyEngineUpdates(original, fileEngines)
1699 if (content === original) {
1700 this.logger.warn(`⚠️ No engine changes written to ${path}`)
1701 continue
1702 }
1703 fileUpdates.push({ path, content, type: 'update' })
1704 }
1705 catch (error) {
1706 this.logger.warn(`Failed to update engines in ${path}:`, error)
1707 }
1708 }
1709 }
1710
16091711 // Group package.json updates by file
16101712 const updatesByPackageFile = new Map<string, PackageUpdate[]>()
16111713 for (const update of packageJsonUpdates) {
src/config-validation.tsmodified+1-1
Changes to src/config-validation.ts
@@ -290,7 +290,7 @@ function validatePackages(issues: ConfigIssue[], config: BuddyConfig): void {
290290 checkStringArray(issues, 'packages.minimumReleaseAgeExclude', packages.minimumReleaseAgeExclude)
291291 checkNumber(issues, 'packages.minimumReleaseAge', packages.minimumReleaseAge, { min: 0 })
292292
293 for (const key of ['includePrerelease', 'excludeMajor', 'respectLatest'] as const) {
293 for (const key of ['includePrerelease', 'excludeMajor', 'respectLatest', 'engines'] as const) {
294294 const value = packages[key]
295295 if (value !== undefined && typeof value !== 'boolean')
296296 issues.push({ path: `packages.${key}`, message: `expected a boolean, got ${quote(value)}` })
src/scanner/package-json-extras.tsmodified+67-1
Changes to src/scanner/package-json-extras.ts
@@ -1,4 +1,4 @@
1import type { Dependency } from '../types'
1import type { Dependency, PackageUpdate } from '../types'
22
33/** Runtimes buddy knows how to resolve a version for. */
44export const KNOWN_ENGINES: Record<string, { registry: 'npm' | 'github', source: string }> = {
@@ -217,3 +217,69 @@ export function bumpEngineConstraint(constraint: string, latest: string): string
217217
218218 return `${operator}${truncated.join('.')}`
219219}
220
221/**
222 * Classify an engine bump by the first component that changed.
223 *
224 * Constraints carry operators and are often imprecise (`>=20`), so semver
225 * ordering cannot be applied to them directly. Comparing the numeric parts
226 * positionally is what the constraint means anyway.
227 *
228 * @param current - The declared constraint
229 * @param next - The proposed constraint
230 * @returns The semver impact of the change
231 * @example
232 * ```ts
233 * engineUpdateType('>=20', '>=22') // 'major'
234 * engineUpdateType('>=20.5', '>=20.9') // 'minor'
235 * ```
236 */
237export function engineUpdateType(current: string, next: string): PackageUpdate['updateType'] {
238 const parts = (value: string): number[] => (/(\d+(?:\.\d+)*)/.exec(value)?.[1] ?? '0').split('.').map(Number)
239 const before = parts(current)
240 const after = parts(next)
241
242 if ((after[0] ?? 0) !== (before[0] ?? 0))
243 return 'major'
244 if ((after[1] ?? 0) !== (before[1] ?? 0))
245 return 'minor'
246 return 'patch'
247}
248
249/**
250 * Rewrite engine constraints in a package.json's text.
251 *
252 * String replacement rather than parse-and-serialise, for the same reason the
253 * dependency writer does it: the file's indentation, key order and trailing
254 * newline are somebody's choices, and re-serialising would silently discard
255 * them in a pull request that claims to touch one line.
256 *
257 * Scoped to the `engines` object, so an engine whose name is also a dependency
258 * — `npm`, `yarn` — is not rewritten in `devDependencies` by mistake.
259 *
260 * @param content - Current package.json text
261 * @param updates - Engine updates to apply
262 * @returns The rewritten text, unchanged when nothing matched
263 */
264export function applyEngineUpdates(content: string, updates: PackageUpdate[]): string {
265 const block = /("engines"\s*:\s*\{)([\s\S]*?)(\})/.exec(content)
266 if (!block)
267 return content
268
269 const [whole, open, body, close] = block
270 let next = body
271
272 for (const update of updates) {
273 if (update.dependencyType !== 'engines')
274 continue
275
276 const name = update.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
277 const current = update.currentVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
278 next = next.replace(
279 new RegExp(`("${name}"\\s*:\\s*")${current}(")`),
280 `$1${update.newVersion}$2`,
281 )
282 }
283
284 return next === body ? content : content.replace(whole, `${open}${next}${close}`)
285}
src/types.tsmodified+9-0
Changes to src/types.ts
@@ -135,6 +135,15 @@ export interface BuddyConfig {
135135 excludeMajor?: boolean
136136 /** Respect "latest" and "*" version indicators (default: true) */
137137 respectLatest?: boolean
138 /**
139 * Propose bumps to the runtimes declared under `engines` (default: false).
140 *
141 * Off by default because an engine constraint is a deployment decision
142 * rather than a dependency one: raising `node: '>=20'` to `'>=22'` is a
143 * statement about every environment the project runs in, not just the
144 * lockfile. The constraint's shape is preserved — a floor stays a floor.
145 */
146 engines?: boolean
138147 /** Minimum age in minutes that a package version must have before installation (default: 0) */
139148 minimumReleaseAge?: number
140149 /** Package names to exclude from minimum release age requirement */
test/engines-updates.test.tsadded+224-0
Changes to test/engines-updates.test.ts
@@ -0,0 +1,224 @@
1import type { PackageFile, PackageUpdate } from '../src/types'
2import { afterEach, describe, expect, it } from 'bun:test'
3import { promises as fs } from 'node:fs'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { Buddy } from '../src/buddy'
7import { validateConfig } from '../src/config-validation'
8import { applyEngineUpdates, engineUpdateType } from '../src/scanner/package-json-extras'
9
10const realFetch = globalThis.fetch
11
12afterEach(() => {
13 globalThis.fetch = realFetch
14})
15
16const MANIFEST = `{
17 "name": "app",
18 "engines": {
19 "node": ">=20",
20 "pnpm": "^9.0.0"
21 },
22 "devDependencies": {
23 "pnpm": "^9.0.0"
24 }
25}
26`
27
28function engine(name: string, current: string, next: string): PackageUpdate {
29 return {
30 name,
31 currentVersion: current,
32 newVersion: next,
33 updateType: engineUpdateType(current, next),
34 dependencyType: 'engines',
35 file: 'package.json',
36 } as PackageUpdate
37}
38
39/**
40 * `resolveEngineVersion` and `bumpEngineConstraint` were written, tested and
41 * exported, and no scan step called either — so an engine was listed on the
42 * dashboard and never proposed. Had one been, the package.json writer only
43 * looks in the four dependency sections, so it would have logged "not found"
44 * and written nothing.
45 */
46describe('engine update type', () => {
47 it('success case - classifies by the first component that moved', () => {
48 expect(engineUpdateType('>=20', '>=22')).toBe('major')
49 expect(engineUpdateType('>=20.5', '>=20.9')).toBe('minor')
50 expect(engineUpdateType('^1.2.3', '^1.2.4')).toBe('patch')
51 })
52})
53
54describe('rewriting engine constraints', () => {
55 it('success case - rewrites the constraint and nothing else', () => {
56 const result = applyEngineUpdates(MANIFEST, [engine('node', '>=20', '>=22')])
57
58 expect(result).toContain('"node": ">=22"')
59 // Indentation, key order and the trailing newline are the author's.
60 expect(result.split('\n').length).toBe(MANIFEST.split('\n').length)
61 expect(result.endsWith('\n')).toBe(true)
62 })
63
64 it('failure case - a name shared with a dependency is not touched there', () => {
65 // `pnpm` is both an engine and a devDependency here. Only the engine
66 // moves; rewriting the devDependency would be a different update.
67 const result = applyEngineUpdates(MANIFEST, [engine('pnpm', '^9.0.0', '^10.0.0')])
68
69 expect(result).toContain('"engines": {\n "node": ">=20",\n "pnpm": "^10.0.0"')
70 expect(result).toContain('"devDependencies": {\n "pnpm": "^9.0.0"')
71 })
72
73 it('success case - applies several engines in one pass', () => {
74 const result = applyEngineUpdates(MANIFEST, [
75 engine('node', '>=20', '>=22'),
76 engine('pnpm', '^9.0.0', '^10.0.0'),
77 ])
78
79 expect(result).toContain('"node": ">=22"')
80 expect(result).toContain('"pnpm": "^10.0.0"')
81 })
82
83 it('edge case - an unmatched constraint leaves the file identical', () => {
84 expect(applyEngineUpdates(MANIFEST, [engine('node', '>=18', '>=22')])).toBe(MANIFEST)
85 })
86
87 it('edge case - a manifest with no engines block is untouched', () => {
88 const bare = '{\n "name": "app"\n}\n'
89 expect(applyEngineUpdates(bare, [engine('node', '>=20', '>=22')])).toBe(bare)
90 })
91})
92
93describe('checking engines for updates', () => {
94 function manifestWith(engines: Record<string, string>): PackageFile {
95 return {
96 path: 'package.json',
97 type: 'package.json',
98 content: '',
99 dependencies: Object.entries(engines).map(([name, currentVersion]) => ({
100 name,
101 currentVersion,
102 type: 'engines' as const,
103 file: 'package.json',
104 })),
105 }
106 }
107
108 function buddyWithRegistry(latest: Record<string, string | null>): Buddy {
109 const buddy = new Buddy({ packages: { strategy: 'all', engines: true } }, process.cwd())
110 ;(buddy as never as { registryClient: { getLatestVersion: (n: string) => Promise<string | null> } })
111 .registryClient.getLatestVersion = async name => latest[name] ?? null
112 return buddy
113 }
114
115 async function check(buddy: Buddy, files: PackageFile[]): Promise<PackageUpdate[]> {
116 return (buddy as never as { checkEnginesForUpdates: (f: PackageFile[]) => Promise<PackageUpdate[]> })
117 .checkEnginesForUpdates(files)
118 }
119
120 it('success case - an npm-published runtime is resolved through the registry', async () => {
121 const updates = await check(buddyWithRegistry({ pnpm: '10.4.1' }), [manifestWith({ pnpm: '^9.0.0' })])
122
123 expect(updates).toEqual([
124 expect.objectContaining({
125 name: 'pnpm',
126 currentVersion: '^9.0.0',
127 newVersion: '^10.4.1',
128 updateType: 'major',
129 dependencyType: 'engines',
130 }),
131 ])
132 })
133
134 it('success case - node is resolved through its GitHub releases', async () => {
135 globalThis.fetch = (async (input: string) => {
136 expect(String(input)).toContain('/repos/nodejs/node/releases/latest')
137 return new Response(JSON.stringify({ tag_name: 'v22.3.0' }), { status: 200 })
138 }) as unknown as typeof fetch
139
140 const updates = await check(buddyWithRegistry({}), [manifestWith({ node: '>=20' })])
141
142 expect(updates[0]).toMatchObject({ name: 'node', newVersion: '>=22' })
143 })
144
145 it('success case - the constraint keeps its shape', async () => {
146 // A floor stays a floor at the author's precision: `>=20` becomes `>=22`,
147 // never `22.3.0`, which would break every contributor on another patch.
148 globalThis.fetch = (async () =>
149 new Response(JSON.stringify({ tag_name: 'v22.3.0' }), { status: 200 })) as unknown as typeof fetch
150
151 const updates = await check(buddyWithRegistry({}), [manifestWith({ node: '>=20.5' })])
152
153 expect(updates[0]?.newVersion).toBe('>=22.3')
154 })
155
156 it('edge case - a constraint already at the latest yields nothing', async () => {
157 // Compared at the author's precision: a floor of `>=9` is not moved by a
158 // 9.x release, only by 10.
159 const updates = await check(buddyWithRegistry({ pnpm: '9.1.0' }), [manifestWith({ pnpm: '>=9' })])
160
161 expect(updates).toEqual([])
162 })
163
164 it('success case - a precise caret constraint follows the latest within its major', async () => {
165 // `^9.0.0` admits 9.1.0 already, but the floor is what the author wrote,
166 // and keeping it current is the point — the same way `^9.0.0` on a
167 // dependency is bumped to `^9.1.0`.
168 const updates = await check(buddyWithRegistry({ pnpm: '9.1.0' }), [manifestWith({ pnpm: '^9.0.0' })])
169
170 expect(updates[0]).toMatchObject({ newVersion: '^9.1.0', updateType: 'minor' })
171 })
172
173 it('edge case - an unresolvable runtime yields nothing rather than an error', async () => {
174 const updates = await check(buddyWithRegistry({ pnpm: null }), [manifestWith({ pnpm: '^9.0.0' })])
175
176 expect(updates).toEqual([])
177 })
178
179 it('edge case - a manifest with no engines makes no lookups', async () => {
180 let looked = false
181 const buddy = buddyWithRegistry({})
182 ;(buddy as never as { registryClient: { getLatestVersion: () => Promise<null> } })
183 .registryClient.getLatestVersion = async () => {
184 looked = true
185 return null
186 }
187
188 expect(await check(buddy, [manifestWith({})])).toEqual([])
189 expect(looked).toBe(false)
190 })
191})
192
193describe('the opt-in', () => {
194 it('success case - engines is a boolean in config', () => {
195 expect(validateConfig({ packages: { strategy: 'all', engines: true } })).toEqual([])
196 })
197
198 it('failure case - a non-boolean is rejected', () => {
199 const issues = validateConfig({ packages: { strategy: 'all', engines: 'yes' as never } })
200
201 expect(issues.some(issue => issue.path === 'packages.engines')).toBe(true)
202 })
203})
204
205describe('writing engines through the file generator', () => {
206 it('success case - an engines update reaches the manifest on disk', async () => {
207 const dir = await fs.mkdtemp(join(tmpdir(), 'buddy-engines-'))
208 const path = join(dir, 'package.json')
209 await fs.writeFile(path, MANIFEST)
210
211 try {
212 const buddy = new Buddy({ packages: { strategy: 'all' } }, dir)
213 const changes = await buddy.generateAllFileUpdates([{ ...engine('node', '>=20', '>=22'), file: path }])
214
215 // Before this, the update went through the dependency-section loop,
216 // logged "Package node not found", and produced no change at all.
217 expect(changes).toHaveLength(1)
218 expect(changes[0].content).toContain('"node": ">=22"')
219 }
220 finally {
221 await fs.rm(dir, { recursive: true, force: true })
222 }
223 })
224})