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
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) {