ReviewOS

also looking at this

stacks/buddy

fix(ecosystems): regenerate the lockfiles the adapters were written to update

#1426
Merged glennmichael123 wants to merge fix/adapter-lockfiles into main
2 files +194 -1

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.

src/buddy.tsmodified+69-1
Changes to src/buddy.ts
@@ -1826,12 +1826,15 @@ export class Buddy {
18261826 }
18271827
18281828 const changes: Array<{ path: string, content: string, type: 'update' }> = []
1829 const ecosystems = new Set<string>()
18291830
18301831 for (const [file, fileUpdates] of byFile) {
18311832 const adapter = adapterNamed(fileUpdates[0].dependencyType)
18321833 if (!adapter)
18331834 continue
18341835
1836 ecosystems.add(adapter.name)
1837
18351838 try {
18361839 const path = join(this.projectPath, file)
18371840 const original = await Bun.file(path).text()
@@ -1860,7 +1863,72 @@ export class Buddy {
18601863 }
18611864 }
18621865
1863 return changes
1866 return [...changes, ...await this.regenerateAdapterLockfiles(changes, [...ecosystems])]
1867 }
1868
1869 /**
1870 * Bring the adapter ecosystems' lockfiles back in step with their manifests.
1871 *
1872 * `regenerateLockfiles` and every adapter's `postWrite` were written, tested
1873 * and exported, and nothing called them — so a Python, Rust, Go or Ruby
1874 * manifest was updated and its lockfile left describing the old version.
1875 * That is precisely the `lockfile-drift` failure `fix-ci` exists to
1876 * diagnose, arriving on a pull request buddy opened itself.
1877 *
1878 * The manifests are written to disk first because `postWrite` runs the
1879 * ecosystem's own tool against the working tree — it reads the file, not the
1880 * change about to be committed. The tree is a CI checkout that is discarded
1881 * after the run, which is why writing to it is acceptable here and would not
1882 * be somewhere longer-lived.
1883 *
1884 * @param changes - Manifest changes already produced
1885 * @param ecosystems - Ecosystem names whose manifests changed
1886 * @returns File changes for the regenerated lockfiles
1887 */
1888 private async regenerateAdapterLockfiles(
1889 changes: Array<{ path: string, content: string, type: 'update' }>,
1890 ecosystems: string[],
1891 ): Promise<Array<{ path: string, content: string, type: 'update' }>> {
1892 if (changes.length === 0 || ecosystems.length === 0)
1893 return []
1894
1895 const { regenerateLockfiles } = await import('./ecosystems')
1896
1897 try {
1898 for (const change of changes)
1899 await Bun.write(join(this.projectPath, change.path), change.content)
1900 }
1901 catch (error) {
1902 // Without the manifests on disk the tools would lock the old versions,
1903 // which is worse than leaving the lockfile alone.
1904 this.logger.warn(`⚠️ Could not stage manifests for lockfile regeneration: ${error}`)
1905 return []
1906 }
1907
1908 const { regenerated, notes } = await regenerateLockfiles(this.projectPath, ecosystems)
1909
1910 // A missing tool is reported rather than swallowed: the pull request will
1911 // carry a manifest its lockfile does not match, and that is worth saying.
1912 for (const note of notes)
1913 this.logger.info(`ℹ️ ${note}`)
1914
1915 const locked: Array<{ path: string, content: string, type: 'update' }> = []
1916
1917 for (const lockfile of regenerated) {
1918 try {
1919 locked.push({
1920 path: lockfile,
1921 content: await Bun.file(join(this.projectPath, lockfile)).text(),
1922 type: 'update',
1923 })
1924 this.logger.info(`🔒 Regenerated ${lockfile}`)
1925 }
1926 catch (error) {
1927 this.logger.warn(`⚠️ Could not read the regenerated ${lockfile}: ${error}`)
1928 }
1929 }
1930
1931 return locked
18641932 }
18651933
18661934 /**
test/adapter-lockfiles.test.tsadded+125-0
Changes to test/adapter-lockfiles.test.ts
@@ -0,0 +1,125 @@
1import type { PackageUpdate } from '../src/types'
2import { afterEach, beforeEach, 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 { regenerateLockfiles } from '../src/ecosystems/scan'
8
9/** An adapter that records what it was asked to do. */
10function fakeAdapter(name: string, options: { lockfile?: string, note?: string } = {}) {
11 const seen: string[] = []
12
13 return {
14 seen,
15 adapter: {
16 name,
17 applyUpdate: (content: string) => content,
18 async postWrite(dir: string) {
19 seen.push(dir)
20 return {
21 regenerated: options.lockfile ? [options.lockfile] : [],
22 ...(options.note ? { note: options.note } : {}),
23 }
24 },
25 } as never,
26 }
27}
28
29/**
30 * `regenerateLockfiles` and every adapter's `postWrite` were written, tested
31 * and exported, and nothing called them — so a Python, Rust, Go or Ruby
32 * manifest was updated and its lockfile left describing the old version. That
33 * is the `lockfile-drift` failure `fix-ci` exists to diagnose, arriving on a
34 * pull request buddy opened itself.
35 */
36describe('regenerateLockfiles', () => {
37 it('success case - runs the adapters whose manifests changed', async () => {
38 const python = fakeAdapter('python', { lockfile: 'uv.lock' })
39 const rust = fakeAdapter('rust', { lockfile: 'Cargo.lock' })
40
41 const result = await regenerateLockfiles('/repo', ['python'], [python.adapter, rust.adapter])
42
43 expect(result.regenerated).toEqual(['uv.lock'])
44 expect(python.seen).toEqual(['/repo'])
45 expect(rust.seen).toEqual([])
46 })
47
48 it('success case - collects the notes an adapter reports', async () => {
49 // A missing tool has to surface: the pull request will carry a manifest
50 // its lockfile does not match.
51 const python = fakeAdapter('python', { note: 'uv.lock (needs `uv lock`)' })
52
53 const result = await regenerateLockfiles('/repo', ['python'], [python.adapter])
54
55 expect(result.notes).toEqual(['uv.lock (needs `uv lock`)'])
56 expect(result.regenerated).toEqual([])
57 })
58
59 it('failure case - an adapter that throws does not stop the others', async () => {
60 const broken = {
61 name: 'python',
62 applyUpdate: (c: string) => c,
63 postWrite: async () => { throw new Error('uv exploded') },
64 } as never
65 const ruby = fakeAdapter('ruby', { lockfile: 'Gemfile.lock' })
66
67 const result = await regenerateLockfiles('/repo', ['python', 'ruby'], [broken, ruby.adapter])
68
69 expect(result.regenerated).toEqual(['Gemfile.lock'])
70 })
71})
72
73describe('adapter updates carry their lockfile', () => {
74 let testDir: string
75
76 // Deliberately no `process.chdir`: `Buddy` takes an explicit project path,
77 // and another suite in this run leaves the working directory pointing at a
78 // temporary directory it has already removed.
79 beforeEach(async () => {
80 testDir = await fs.mkdtemp(join(tmpdir(), 'buddy-locks-'))
81 })
82
83 afterEach(async () => {
84 await fs.rm(testDir, { recursive: true, force: true })
85 })
86
87 it('success case - the updated manifest is on disk before the tool runs', async () => {
88 // `postWrite` runs the ecosystem's own tool against the working tree: it
89 // reads the manifest, not the change about to be committed. A tool run
90 // before the write would lock the version being replaced.
91 await fs.writeFile(join(testDir, 'Cargo.toml'), '[dependencies]\nserde = "1.0.100"\n')
92
93 const buddy = new Buddy({ packages: { strategy: 'all' } }, testDir)
94 const update = {
95 name: 'serde',
96 currentVersion: '1.0.100',
97 newVersion: '1.0.200',
98 updateType: 'patch',
99 dependencyType: 'rust',
100 file: 'Cargo.toml',
101 } as PackageUpdate
102
103 const changes = await (buddy as never as {
104 generateAdapterFileUpdates: (u: PackageUpdate[]) => Promise<Array<{ path: string, content: string }>>
105 }).generateAdapterFileUpdates([update])
106
107 const manifest = changes.find(change => change.path === 'Cargo.toml')
108 expect(manifest?.content).toContain('1.0.200')
109
110 // The staged write is what makes the regeneration meaningful.
111 expect(await fs.readFile(join(testDir, 'Cargo.toml'), 'utf-8')).toContain('1.0.200')
112 })
113
114 it('edge case - no adapter updates means nothing is written', async () => {
115 const buddy = new Buddy({ packages: { strategy: 'all' } }, testDir)
116
117 const changes = await (buddy as never as {
118 generateAdapterFileUpdates: (u: PackageUpdate[]) => Promise<unknown[]>
119 }).generateAdapterFileUpdates([
120 { name: 'react', currentVersion: '1', newVersion: '2', updateType: 'major', dependencyType: 'dependencies', file: 'package.json' } as PackageUpdate,
121 ])
122
123 expect(changes).toEqual([])
124 })
125})