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`