ReviewOS

also looking at this

stacks/buddy

fix: let the satisfied-PR closer consult the manifest, and reject phantom paths from PR bodies

#1447
Merged glennmichael123 wants to merge fix/satisfied-pr-verification into main
2 files +134 -11
src/buddy.tsmodified+69-11
Changes to src/buddy.ts
@@ -1610,6 +1610,33 @@ export class Buddy {
16101610 * 'behind' — current is behind the target
16111611 * 'failed' — could not compare (non-semver, malformed, etc.)
16121612 */
1613 /**
1614 * Read the version package.json declares for every dependency, keyed by
1615 * package name. Range prefixes stay in place — compareVersionsSafe strips
1616 * them. Returns an empty map when the manifest is missing or unreadable.
1617 */
1618 private readDeclaredNpmVersions(): Map<string, string> {
1619 const declared = new Map<string, string>()
1620 try {
1621 const fs = require('node:fs')
1622 const path = require('node:path')
1623 const manifestPath = path.join(this.projectPath, 'package.json')
1624 if (!fs.existsSync(manifestPath))
1625 return declared
1626 const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
1627 for (const section of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
1628 for (const [name, version] of Object.entries(manifest[section] ?? {})) {
1629 if (typeof version === 'string' && !declared.has(name))
1630 declared.set(name, version)
1631 }
1632 }
1633 }
1634 catch (error) {
1635 this.logger.debug('Could not read declared versions from package.json:', error)
1636 }
1637 return declared
1638 }
1639
16131640 private compareVersionsSafe(current: string, target: string): 'at-or-beyond' | 'behind' | 'failed' {
16141641 try {
16151642 const cleanTarget = target.replace(/^[v^~>=<@]+/, '')
@@ -2736,6 +2763,17 @@ export class Buddy {
27362763 * Reads the embedded manifest when present, falling back to scraping the
27372764 * rendered tables for PRs opened before manifests existed.
27382765 */
2766 /**
2767 * A regex capture from a PR body counts as a repository path only when it
2768 * looks like one — markdown links, badges and URLs (renovate-era bodies are
2769 * full of them) must not reach the removed-file check as phantom paths.
2770 */
2771 private isPlausibleRepoPath(candidate: string): boolean {
2772 if (!candidate || /[\s()[\]!`]|:\/\//.test(candidate))
2773 return false
2774 return /^[\w@.-]+(?:\/[\w@.-]+)*$/.test(candidate)
2775 }
2776
27392777 private extractFilePathsFromPRBody(prBody: string): string[] {
27402778 const manifest = parseManifest(prBody)
27412779 if (manifest)
@@ -2749,7 +2787,7 @@ export class Buddy {
27492787 let match
27502788 while ((match = tableRowRegex.exec(prBody)) !== null) {
27512789 const filePath = match[1].trim()
2752 if (filePath && !filePaths.includes(filePath)) {
2790 if (this.isPlausibleRepoPath(filePath) && !filePaths.includes(filePath)) {
27532791 filePaths.push(filePath)
27542792 }
27552793 }
@@ -2758,7 +2796,7 @@ export class Buddy {
27582796 const boldFileRegex = /\*\*([^*]+\.(?:json|yaml|yml|lock))\*\*/g
27592797 while ((match = boldFileRegex.exec(prBody)) !== null) {
27602798 const filePath = match[1].trim()
2761 if (filePath && !filePaths.includes(filePath)) {
2799 if (this.isPlausibleRepoPath(filePath) && !filePaths.includes(filePath)) {
27622800 filePaths.push(filePath)
27632801 }
27642802 }
@@ -2769,7 +2807,7 @@ export class Buddy {
27692807 while ((match = simpleTableRowRegex.exec(prBody)) !== null) {
27702808 const filePath = match[1].trim()
27712809 // Only consider paths that look like file paths (contain / or end with common extensions)
2772 if (filePath && (filePath.includes('/') || /\.(?:json|yaml|yml|lock)$/.test(filePath)) && !filePaths.includes(filePath)) {
2810 if (this.isPlausibleRepoPath(filePath) && (filePath.includes('/') || /\.(?:json|yaml|yml|lock)$/.test(filePath)) && !filePaths.includes(filePath)) {
27732811 filePaths.push(filePath)
27742812 }
27752813 }
@@ -2778,7 +2816,7 @@ export class Buddy {
27782816 const filePathRegex = /(?:^|\s)([\w-]+(?:\/[\w.-]+)*\/[\w.-]+\.(?:json|yaml|yml|lock))(?:\s|$)/gm
27792817 while ((match = filePathRegex.exec(prBody)) !== null) {
27802818 const filePath = match[1].trim()
2781 if (filePath && !filePaths.includes(filePath)) {
2819 if (this.isPlausibleRepoPath(filePath) && !filePaths.includes(filePath)) {
27822820 filePaths.push(filePath)
27832821 }
27842822 }
@@ -3008,13 +3046,19 @@ export class Buddy {
30083046 strategy: 'all',
30093047 },
30103048 }
3011 const scanBuddy = new Buddy(scanConfig)
3049 const scanBuddy = new Buddy(scanConfig, this.projectPath)
30123050 const currentScanResult = await scanBuddy.scanForUpdates()
30133051 const currentUpdatesMap = new Map<string, PackageUpdate>()
30143052 for (const update of currentScanResult.updates) {
30153053 currentUpdatesMap.set(update.name, update)
30163054 }
30173055
3056 // Positive evidence for packages the scan no longer lists: what the
3057 // manifest itself declares. A fully satisfied PR consists entirely of
3058 // packages absent from an outdated-scan, so without this the closer
3059 // could never close one.
3060 const declaredVersions = this.readDeclaredNpmVersions()
3061
30183062 // Safety: if the scan returned 0 updates, something may be wrong
30193063 // (bun outdated failure, missing node_modules, rate limiting, etc.).
30203064 // Never close PRs based on an empty scan — that would wipe them all out.
@@ -3059,10 +3103,23 @@ export class Buddy {
30593103 // Package not in current scan. This could mean:
30603104 // a) Already at the target version (genuinely satisfied)
30613105 // b) Scan missed it (strategy filtering, API failure, etc.)
3062 // We can't distinguish these cases, so we count it as unverifiable.
3063 // The PR will only be closed if we have ZERO unverifiable packages
3064 // (i.e., every package was positively confirmed as satisfied).
3065 this.logger.debug(`PR #${pr.number}: ${prUpdate.name} not in current scan (unverifiable)`)
3106 // The manifest distinguishes them: a declared version at or
3107 // beyond the target is positive confirmation of (a).
3108 const declaredVersion = declaredVersions.get(prUpdate.name)
3109 if (declaredVersion) {
3110 const declaredComparison = this.compareVersionsSafe(declaredVersion, prUpdate.newVersion)
3111 if (declaredComparison === 'at-or-beyond') {
3112 this.logger.debug(`PR #${pr.number}: ${prUpdate.name} declared at ${declaredVersion}, at or beyond ${prUpdate.newVersion}`)
3113 verifiedSatisfied++
3114 continue
3115 }
3116 if (declaredComparison === 'behind') {
3117 this.logger.debug(`PR #${pr.number}: ${prUpdate.name} declared at ${declaredVersion}, still behind ${prUpdate.newVersion} — the scan missed it`)
3118 verifiedStillNeeded++
3119 continue
3120 }
3121 }
3122 this.logger.debug(`PR #${pr.number}: ${prUpdate.name} not in current scan and not declared (unverifiable)`)
30663123 unverifiable++
30673124 continue
30683125 }
@@ -3114,9 +3171,10 @@ export class Buddy {
31143171 // All packages were verified as satisfied (no unverifiable ones)
31153172 const packagesAlreadyUpdated = prUpdates.filter((u) => {
31163173 const current = currentUpdatesMap.get(u.name)
3117 if (!current)
3174 const version = current?.currentVersion ?? declaredVersions.get(u.name)
3175 if (!version)
31183176 return false
3119 return this.compareVersionsSafe(current.currentVersion, u.newVersion) === 'at-or-beyond'
3177 return this.compareVersionsSafe(version, u.newVersion) === 'at-or-beyond'
31203178 })
31213179
31223180 let closeComment = `🤖 **Auto-closing satisfied PR**\n\n`
test/buddy-orchestration.test.tsmodified+65-0
Changes to test/buddy-orchestration.test.ts
@@ -364,6 +364,46 @@ describe('buddy orchestration', () => {
364364 expect(provider.deleteBranch).toHaveBeenCalledWith('buddy/update-lodash')
365365 })
366366
367 it('success case - closes a fully satisfied PR by consulting the manifest when its packages left the scan', async () => {
368 // The scan lists only what is still outdated, so a fully satisfied PR
369 // has no packages in it at all — the manifest is the positive evidence.
370 const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'buddy-close-'))
371 fs.writeFileSync(path.join(projectDir, 'package.json'), JSON.stringify({
372 devDependencies: { typescript: '^7.0.2' },
373 }))
374 restorers.push(() => fs.rmSync(projectDir, { recursive: true, force: true }))
375
376 stubScan([makeUpdate({ name: 'react', currentVersion: '18.0.0', newVersion: '18.2.0' })])
377 const provider = makeCloseProvider([makePR({
378 number: 125,
379 head: 'buddy/update-typescript',
380 body: `updates${serializeManifest([makeUpdate({ name: 'typescript', currentVersion: '6.0.3', newVersion: '7.0.2' })])}`,
381 })])
382 const buddy = new Buddy(baseConfig(), projectDir)
383
384 await buddy.checkAndCloseSatisfiedPRs(provider, false)
385
386 expect(provider.closePullRequest).toHaveBeenCalledWith(125)
387 const [, comment] = provider.createComment.mock.calls[0]
388 expect(comment).toContain('typescript')
389 })
390
391 it('failure case - keeps the PR when the manifest declares a version still behind the target', async () => {
392 const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'buddy-close-'))
393 fs.writeFileSync(path.join(projectDir, 'package.json'), JSON.stringify({
394 dependencies: { lodash: '^4.17.20' },
395 }))
396 restorers.push(() => fs.rmSync(projectDir, { recursive: true, force: true }))
397
398 stubScan([makeUpdate({ name: 'react', currentVersion: '18.0.0', newVersion: '18.2.0' })])
399 const provider = makeCloseProvider([lodashPR()])
400 const buddy = new Buddy(baseConfig(), projectDir)
401
402 await buddy.checkAndCloseSatisfiedPRs(provider, false)
403
404 expect(provider.closePullRequest).not.toHaveBeenCalled()
405 })
406
367407 it('failure case - never closes on an empty scan (mass-closure guard)', async () => {
368408 // A scan returning nothing may mean bun outdated failed or the registry
369409 // rate-limited — closing every PR on that evidence would wipe them out.
@@ -430,6 +470,31 @@ describe('buddy orchestration', () => {
430470 })
431471 })
432472
473 describe('extractFilePathsFromPRBody', () => {
474 it('failure case - renovate-era badge tables yield no phantom paths', () => {
475 const buddy = new Buddy(baseConfig()) as any
476 const body = [
477 '| Package | Change | Age | Adoption | Passing | Confidence |',
478 '|---|---|---|---|---|---|',
479 '| [typescript](https://github.com/microsoft/TypeScript/tree/master) | [`6.0.3` -> `7.0.2`](https://renovatebot.com/diffs/npm/typescript/6.0.3/7.0.2) | [![age](https://developer.mend.io/api/mc/badges/age/npm/typescript/7.0.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/typescript/7.0.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | ok | ok |',
480 ].join('\n')
481
482 expect(buddy.extractFilePathsFromPRBody(body)).toEqual([])
483 })
484
485 it('success case - real file cells and bold paths still extract', () => {
486 const buddy = new Buddy(baseConfig()) as any
487 const body = [
488 '| [lodash](https://npmjs.com/lodash) | ^4.17.20 → ^4.17.21 | **package.json** | ✅ |',
489 'Also touches **.github/workflows/ci.yml** along the way.',
490 ].join('\n')
491
492 const paths = buddy.extractFilePathsFromPRBody(body)
493 expect(paths).toContain('package.json')
494 expect(paths).toContain('.github/workflows/ci.yml')
495 })
496 })
497
433498 describe('compareVersionsSafe', () => {
434499 it('distinguishes at-or-beyond, behind, and failed', () => {
435500 const buddy = new Buddy(baseConfig()) as any