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
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+108-1
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) {
@@ -1745,7 +1847,12 @@ export class Buddy {
17451847 try {
17461848 const { generateDockerfileUpdates } = await import('./utils/dockerfile-parser')
17471849 // Pass only the Dockerfile updates for this specific group
1748 const dockerUpdates = await generateDockerfileUpdates(dockerfileUpdates)
1850 // Passed through so a digest on a private image can be re-resolved
1851 // with the same credentials the scan used to find the tag.
1852 const dockerUpdates = await generateDockerfileUpdates(
1853 dockerfileUpdates,
1854 this.config.registries?.docker,
1855 )
17491856 fileUpdates.push(...dockerUpdates)
17501857 }
17511858 catch (error) {
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 */
src/utils/dockerfile-parser.tsmodified+64-11
Changes to src/utils/dockerfile-parser.ts
@@ -112,17 +112,34 @@ export async function parseDockerfile(filePath: string, content: string): Promis
112112/**
113113 * Parse a Docker image reference into name and version components
114114 */
115function parseImageReference(imageRef: string): { name: string, version: string } | null {
115function parseImageReference(imageRef: string): { name: string, version: string, digest?: string } | null {
116116 try {
117117 // Handle different image reference formats:
118118 // - image:tag
119119 // - registry/image:tag
120120 // - registry:port/image:tag
121121 // - registry/namespace/image:tag
122 // - image:tag@sha256:digest
122123
123124 let name: string
124125 let version: string
125126
127 // Split the digest off first. Everything below reasons about the *last*
128 // colon, and a digest contributes two of them — so `node:20@sha256:abc`
129 // parsed as the image `node:20@sha256` at version `abc`, which is not an
130 // image, is never found in any registry, and appears on the dependency
131 // dashboard as though it were real.
132 const digestIndex = imageRef.indexOf('@')
133 const digest = digestIndex === -1 ? undefined : imageRef.slice(digestIndex + 1)
134 if (digestIndex !== -1)
135 imageRef = imageRef.slice(0, digestIndex)
136
137 // Pinned by digest alone, with no tag. There is no version to move, and
138 // reporting it as `latest` would put a tag on the dashboard that the file
139 // does not contain.
140 if (digest && !imageRef.includes(':'))
141 return null
142
126143 if (imageRef.includes(':')) {
127144 const lastColonIndex = imageRef.lastIndexOf(':')
128145 const beforeColon = imageRef.substring(0, lastColonIndex)
@@ -146,12 +163,12 @@ function parseImageReference(imageRef: string): { name: string, version: string
146163 version = 'latest'
147164 }
148165
149 // Skip if version contains variables or is a digest
166 // Skip if version contains variables or is a bare digest
150167 if (version.includes('$') || version.startsWith('sha256:')) {
151168 return null
152169 }
153170
154 return { name, version }
171 return { name, version, ...(digest ? { digest } : {}) }
155172 }
156173 catch (error) {
157174 getDefaultLogger().warn(`Failed to parse image reference ${imageRef}:`, error)
@@ -230,7 +247,12 @@ export async function resolveDockerDigest(
230247/**
231248 * Update Dockerfile content with new image versions
232249 */
233export async function updateDockerfile(filePath: string, content: string, updates: PackageUpdate[]): Promise<string> {
250export async function updateDockerfile(
251 filePath: string,
252 content: string,
253 updates: PackageUpdate[],
254 registries?: DockerRegistryConfig,
255): Promise<string> {
234256 try {
235257 if (!isDockerfile(filePath)) {
236258 getDefaultLogger().info(`⚠️ updateDockerfile: ${filePath} is not a Dockerfile, returning original content`)
@@ -251,8 +273,15 @@ export async function updateDockerfile(filePath: string, content: string, update
251273 // Create regex to find FROM instructions with this image
252274 // Handle various formats: FROM image:tag, FROM image:tag as alias
253275 const escapedImageName = cleanImageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
276 // The tag is captured without its digest, so a pinned reference does not
277 // swallow `@sha256:…` into the version and then lose it on replacement.
278 //
279 // The trailing group is horizontal whitespace only. `\\s` matches a
280 // newline, so with the `m` flag it ran past the end of the line and took
281 // every following line with it — a multi-stage Dockerfile that builds
282 // from the same image twice had both `FROM` lines collapsed into one.
254283 const fromRegex = new RegExp(
255 `(FROM\\s+${escapedImageName})(:)([^\\s]+)(\\s.*)?$`,
284 `(FROM\\s+${escapedImageName})(:)([^\\s@]+)(@sha256:[a-f0-9]+)?([ \\t].*)?$`,
256285 'gim',
257286 )
258287
@@ -269,19 +298,40 @@ export async function updateDockerfile(filePath: string, content: string, update
269298 const fullMatch = match[0]
270299 const beforeColon = match[1] // "FROM image"
271300 const colon = match[2] // ":"
272 const currentVersion = match[3] // "tag"
273 const afterVersion = match[4] || '' // " as alias" or empty
301 const currentVersion = match[3] // "tag", without any digest
302 const currentDigest = match[4] || '' // "@sha256:…" or empty
303 const afterVersion = match[5] || '' // " as alias" or empty
274304
275305 if (shouldRespectVersion(currentVersion)) {
276306 getDefaultLogger().info(`⚠️ Skipping update for ${cleanImageName} - version "${currentVersion}" should be respected`)
277307 continue
278308 }
279309
310 // A pinned reference has to move both halves together. Writing the new
311 // tag beside the old digest would leave the old image running while
312 // the file claims otherwise, and dropping the digest silently unpins
313 // an image somebody pinned on purpose — both worse than not updating.
314 let digest = currentDigest
315 if (currentDigest) {
316 const resolved = await resolveDockerDigest(cleanImageName, update.newVersion, registries)
317
318 if (!resolved) {
319 getDefaultLogger().warn(
320 `⚠️ Leaving ${cleanImageName} pinned at ${currentVersion}: could not resolve the digest for ${update.newVersion}`,
321 )
322 continue
323 }
324
325 digest = `@${resolved}`
326 }
327
280328 // Replace with new version
281 const replacement = `${beforeColon}${colon}${update.newVersion}${afterVersion}`
329 const replacement = `${beforeColon}${colon}${update.newVersion}${digest}${afterVersion}`
282330 updatedContent = updatedContent.replace(fullMatch, replacement)
283331
284 getDefaultLogger().info(`📝 Updated ${cleanImageName}: ${currentVersion} → ${update.newVersion}`)
332 getDefaultLogger().info(
333 `📝 Updated ${cleanImageName}: ${currentVersion} → ${update.newVersion}${digest ? ' (digest re-resolved)' : ''}`,
334 )
285335 }
286336 }
287337
@@ -296,7 +346,10 @@ export async function updateDockerfile(filePath: string, content: string, update
296346/**
297347 * Generate file changes for Dockerfiles
298348 */
299export async function generateDockerfileUpdates(updates: PackageUpdate[]): Promise<Array<{ path: string, content: string, type: 'update' }>> {
349export async function generateDockerfileUpdates(
350 updates: PackageUpdate[],
351 registries?: DockerRegistryConfig,
352): Promise<Array<{ path: string, content: string, type: 'update' }>> {
300353 const fileUpdates: Array<{ path: string, content: string, type: 'update' }> = []
301354
302355 // Group updates by file
@@ -318,7 +371,7 @@ export async function generateDockerfileUpdates(updates: PackageUpdate[]): Promi
318371 const fs = await import('node:fs')
319372 if (fs.existsSync(filePath)) {
320373 const currentContent = fs.readFileSync(filePath, 'utf-8')
321 const updatedContent = await updateDockerfile(filePath, currentContent, dockerUpdates)
374 const updatedContent = await updateDockerfile(filePath, currentContent, dockerUpdates, registries)
322375
323376 // Only add file update if content actually changed
324377 if (updatedContent !== currentContent) {
test/dockerfile-digests.test.tsadded+175-0
Changes to test/dockerfile-digests.test.ts
@@ -0,0 +1,175 @@
1import type { PackageUpdate } from '../src/types'
2import { afterEach, describe, expect, it } from 'bun:test'
3import { parseDockerfile, updateDockerfile } from '../src/utils/dockerfile-parser'
4
5const realFetch = globalThis.fetch
6
7afterEach(() => {
8 globalThis.fetch = realFetch
9})
10
11const DIGEST = 'sha256:aaaa1111bbbb2222cccc3333dddd4444eeee5555ffff6666aaaa7777bbbb8888'
12const NEW_DIGEST = 'sha256:9999888877776666555544443333222211110000ffffeeeeddddccccbbbbaaaa'
13
14function update(overrides: Partial<PackageUpdate> = {}): PackageUpdate {
15 return {
16 name: 'node',
17 currentVersion: '20',
18 newVersion: '22',
19 updateType: 'major',
20 dependencyType: 'docker-image',
21 file: 'Dockerfile',
22 ...overrides,
23 } as PackageUpdate
24}
25
26/** Answer every registry request with a manifest carrying this digest. */
27function registryReturning(digest: string | null): void {
28 globalThis.fetch = (async (input: string) => {
29 if (digest === null)
30 return new Response('not found', { status: 404 })
31
32 if (String(input).includes('/token') || String(input).includes('auth'))
33 return new Response(JSON.stringify({ token: 't' }), { status: 200 })
34
35 return new Response(JSON.stringify({ schemaVersion: 2 }), {
36 status: 200,
37 headers: { 'docker-content-digest': digest, 'content-type': 'application/vnd.oci.image.manifest.v1+json' },
38 })
39 }) as unknown as typeof fetch
40}
41
42/**
43 * `resolveDockerDigest` was written — with a docstring explaining that moving
44 * a tag without moving its digest "would leave the old image running while the
45 * file claims otherwise" — and never called. Two things followed from that.
46 */
47describe('digest-pinned base images', () => {
48 describe('parsing', () => {
49 it('success case - a pinned reference names the image, not the digest', async () => {
50 // `node:20@sha256:abc` was split on the *last* colon, giving the image
51 // `node:20@sha256` at version `abc`. That is not an image, is never
52 // found in any registry, and appears on the dashboard as though real.
53 const parsed = await parseDockerfile('Dockerfile', `FROM node:20@${DIGEST}\n`)
54
55 expect(parsed?.dependencies).toEqual([
56 { name: 'node', currentVersion: '20', type: 'docker-image', file: 'Dockerfile' },
57 ])
58 })
59
60 it('success case - an unpinned reference is unchanged', async () => {
61 const parsed = await parseDockerfile('Dockerfile', 'FROM alpine:3.19\n')
62
63 expect(parsed?.dependencies[0]).toMatchObject({ name: 'alpine', currentVersion: '3.19' })
64 })
65
66 it('edge case - a registry with a port still parses', async () => {
67 const parsed = await parseDockerfile('Dockerfile', `FROM registry.io:5000/team/app:2.1@${DIGEST}\n`)
68
69 expect(parsed?.dependencies[0]).toMatchObject({
70 name: 'registry.io:5000/team/app',
71 currentVersion: '2.1',
72 })
73 })
74
75 it('edge case - a bare digest reference is still skipped', async () => {
76 const parsed = await parseDockerfile('Dockerfile', `FROM node@${DIGEST}\n`)
77
78 expect(parsed?.dependencies).toEqual([])
79 })
80 })
81
82 describe('updating', () => {
83 it('failure case - the pin is never silently dropped', async () => {
84 // The regression: `FROM node:20@sha256:…` became `FROM node:22`, which
85 // unpins an image somebody pinned on purpose.
86 registryReturning(NEW_DIGEST)
87
88 const result = await updateDockerfile('Dockerfile', `FROM node:20@${DIGEST}\n`, [update()])
89
90 expect(result).not.toBe(`FROM node:22\n`)
91 expect(result).toContain('@sha256:')
92 })
93
94 it('success case - the digest moves with the tag', async () => {
95 registryReturning(NEW_DIGEST)
96
97 const result = await updateDockerfile('Dockerfile', `FROM node:20@${DIGEST}\n`, [update()])
98
99 expect(result).toBe(`FROM node:22@${NEW_DIGEST}\n`)
100 })
101
102 it('failure case - an unresolvable digest leaves the line alone', async () => {
103 // Writing the new tag beside the old digest would leave the old image
104 // running while the file claims otherwise — worse than not updating.
105 registryReturning(null)
106
107 const result = await updateDockerfile('Dockerfile', `FROM node:20@${DIGEST}\n`, [update()])
108
109 expect(result).toBe(`FROM node:20@${DIGEST}\n`)
110 })
111
112 it('success case - an unpinned image needs no registry call', async () => {
113 let called = false
114 globalThis.fetch = (async () => {
115 called = true
116 return new Response('{}', { status: 200 })
117 }) as unknown as typeof fetch
118
119 const result = await updateDockerfile('Dockerfile', 'FROM node:20\n', [update()])
120
121 expect(result).toBe('FROM node:22\n')
122 expect(called).toBe(false)
123 })
124
125 it('success case - a stage alias survives the rewrite', async () => {
126 registryReturning(NEW_DIGEST)
127
128 const result = await updateDockerfile('Dockerfile', `FROM node:20@${DIGEST} as build\n`, [update()])
129
130 expect(result).toBe(`FROM node:22@${NEW_DIGEST} as build\n`)
131 })
132
133 it('edge case - a pinned and an unpinned line update independently', async () => {
134 registryReturning(NEW_DIGEST)
135
136 const result = await updateDockerfile(
137 'Dockerfile',
138 `FROM node:20@${DIGEST}\nFROM node:20 as tools\n`,
139 [update()],
140 )
141
142 expect(result).toContain(`FROM node:22@${NEW_DIGEST}`)
143 expect(result).toContain('FROM node:22 as tools')
144 })
145 })
146})
147
148/**
149 * Pre-existing, and found while fixing the digest handling: the trailing group
150 * was `(\s.*)?$`, and `\s` matches a newline. With the `m` flag the match ran
151 * past the end of its line and took every following line with it.
152 */
153describe('a FROM line stays on its line', () => {
154 it('failure case - a repeated base image does not collapse the file', async () => {
155 // A multi-stage build from the same image twice had both `FROM` lines
156 // replaced by a single one, silently deleting a build stage.
157 const content = 'FROM node:20 as build\nRUN bun install\nFROM node:20 as run\n'
158
159 const result = await updateDockerfile('Dockerfile', content, [update()])
160
161 expect(result.split('\n').filter(line => line.startsWith('FROM'))).toEqual([
162 'FROM node:22 as build',
163 'FROM node:22 as run',
164 ])
165 expect(result).toContain('RUN bun install')
166 })
167
168 it('success case - lines between two FROMs are untouched', async () => {
169 const content = 'FROM node:20\nCOPY . .\nFROM alpine:3.19\n'
170
171 const result = await updateDockerfile('Dockerfile', content, [update()])
172
173 expect(result).toBe('FROM node:22\nCOPY . .\nFROM alpine:3.19\n')
174 })
175})
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})