also looking at this
fix: prevent generated dependency cache PRs
#1366
16 files
+356
-72
| @@ -1,15 +1,16 @@ | ||
| 1 | 1 | # Changelog |
| 2 | ||
| 2 | 3 | [Compare changes](https://github.com/stacksjs/buddy-bot/compare/v0.9.21...v0.9.22) |
| 3 | 4 | |
| 4 | ### ⚡ Performance Improvements | |
| 5 | ## ⚡ Performance Improvements | |
| 5 | 6 | |
| 6 | 7 | - **build**: drop external sourcemaps from published package ([3fb2223](https://github.com/stacksjs/buddy-bot/commit/3fb2223)) _(by Chris <chrisbreuer93@gmail.com>)_ |
| 7 | 8 | |
| 8 | ### 🧹 Chores | |
| 9 | ## 🧹 Chores | |
| 9 | 10 | |
| 10 | 11 | - release v0.9.22 ([2c99ecc](https://github.com/stacksjs/buddy-bot/commit/2c99ecc)) _(by Chris <chrisbreuer93@gmail.com>)_ |
| 11 | 12 | |
| 12 | ### Contributors | |
| 13 | ## Contributors | |
| 13 | 14 | |
| 14 | 15 | - _Chris <chrisbreuer93@gmail.com>_ |
| 15 | 16 | |
| @@ -128,4 +128,4 @@ You can also try to convince your employer to sponsor Stacks as a business. This | ||
| 128 | 128 | |
| 129 | 129 | #### Thanks to Vue.js for the inspiration of this sponsorship page |
| 130 | 130 | |
| 131 | *You can find their sponsorship page [here](https://vuejs.org/sponsor/).* | |
| 131 | *You can find the [Vue.js sponsorship page](https://vuejs.org/sponsor/).* | |
| @@ -3,6 +3,7 @@ import type { FileChange, GitProvider, Issue, IssueOptions, PullRequest, PullReq | ||
| 3 | 3 | import { Buffer } from 'node:buffer' |
| 4 | 4 | import { spawn } from 'node:child_process' |
| 5 | 5 | import process from 'node:process' |
| 6 | import { assertUpdateTargetsExist, FileChangeValidationError, normalizeRepositoryPath } from '../utils/file-changes' | |
| 6 | 7 | import { detectRequiredPackageManagers, getAllLockFilePaths, regenerateLockFile } from '../utils/lock-file' |
| 7 | 8 | |
| 8 | 9 | // Match GitHub token formats (ghp_*, gho_*, ghs_*, ghu_*, ghr_*, github_pat_*) |
| @@ -141,7 +142,7 @@ export class GitHubProvider implements GitProvider { | ||
| 141 | 142 | // Lockfile regeneration failures must NOT fall through to the API path — |
| 142 | 143 | // the API path can't regenerate lockfiles either, so it would just produce |
| 143 | 144 | // a PR with an updated manifest and stale lockfile. Bubble the error up. |
| 144 | if (gitError instanceof LockfileRegenerationError) | |
| 145 | if (gitError instanceof LockfileRegenerationError || gitError instanceof FileChangeValidationError) | |
| 145 | 146 | throw gitError |
| 146 | 147 | |
| 147 | 148 | console.warn(`⚠️ Git CLI commit failed, falling back to GitHub API: ${gitError}`) |
| @@ -240,9 +241,19 @@ export class GitHubProvider implements GitProvider { | ||
| 240 | 241 | await this.runCommand('git', ['clean', '-fd']) |
| 241 | 242 | console.log(`✅ Reset ${branchName} to origin/${baseBranch}`) |
| 242 | 243 | |
| 244 | await assertUpdateTargetsExist(files, async (cleanPath) => { | |
| 245 | try { | |
| 246 | await this.runCommand('git', ['cat-file', '-e', `origin/${baseBranch}:${cleanPath}`]) | |
| 247 | return true | |
| 248 | } | |
| 249 | catch { | |
| 250 | return false | |
| 251 | } | |
| 252 | }) | |
| 253 | ||
| 243 | 254 | // Apply file changes |
| 244 | 255 | for (const file of files) { |
| 245 | const cleanPath = file.path.replace(/^\.\//, '').replace(/^\/+/, '') | |
| 256 | const cleanPath = normalizeRepositoryPath(file.path) | |
| 246 | 257 | |
| 247 | 258 | // Safety check: prevent writing to sensitive files during tests |
| 248 | 259 | if (process.env.NODE_ENV === 'test' || process.env.BUN_ENV === 'test') { |
| @@ -395,11 +406,23 @@ export class GitHubProvider implements GitProvider { | ||
| 395 | 406 | const baseCommit = await this.apiRequest(`GET /repos/${this.owner}/${this.repo}/git/commits/${baseSha}`) |
| 396 | 407 | const baseTreeSha = baseCommit.tree.sha |
| 397 | 408 | |
| 409 | await assertUpdateTargetsExist(files, async (cleanPath) => { | |
| 410 | const encodedPath = cleanPath.split('/').map(segment => encodeURIComponent(segment)).join('/') | |
| 411 | try { | |
| 412 | await this.apiRequest(`GET /repos/${this.owner}/${this.repo}/contents/${encodedPath}?ref=${encodeURIComponent(baseBranch)}`) | |
| 413 | return true | |
| 414 | } | |
| 415 | catch (error: any) { | |
| 416 | if (error.message?.includes('404')) | |
| 417 | return false | |
| 418 | throw error | |
| 419 | } | |
| 420 | }) | |
| 421 | ||
| 398 | 422 | // Create new tree with file changes |
| 399 | 423 | const tree = [] |
| 400 | 424 | for (const file of files) { |
| 401 | // Ensure path doesn't start with ./ or have leading slashes (GitHub API requires clean relative paths) | |
| 402 | const cleanPath = file.path.replace(/^\.\//, '').replace(/^\/+/, '') | |
| 425 | const cleanPath = normalizeRepositoryPath(file.path) | |
| 403 | 426 | |
| 404 | 427 | if (file.type === 'delete') { |
| 405 | 428 | tree.push({ |
| @@ -383,8 +383,10 @@ export class PullRequestGenerator { | ||
| 383 | 383 | } |
| 384 | 384 | |
| 385 | 385 | // Generate version change with diff link (Renovate style) |
| 386 | const diffUrl = `https://renovatebot.com/diffs/npm/${encodeURIComponent(cleanPackageName)}/${update.currentVersion}/${update.newVersion}` | |
| 387 | const change = `[\`${update.currentVersion}\` -> \`${update.newVersion}\`](${diffUrl})` | |
| 386 | const diffUrl = `https://renovatebot.com/diffs/npm/${encodeURIComponent(cleanPackageName)}/${encodeURIComponent(update.currentVersion)}/${encodeURIComponent(update.newVersion)}` | |
| 387 | const currentVersion = this.escapeMarkdownTableValue(update.currentVersion) | |
| 388 | const newVersion = this.escapeMarkdownTableValue(update.newVersion) | |
| 389 | const change = `[\`${currentVersion}\` -> \`${newVersion}\`](${diffUrl})` | |
| 388 | 390 | |
| 389 | 391 | // Generate confidence badges with clean package name |
| 390 | 392 | const badges = this.releaseNotesFetcher.generatePackageBadges( |
| @@ -977,6 +979,13 @@ export class PullRequestGenerator { | ||
| 977 | 979 | return `${currentConstraint} -> ${newConstraint}` |
| 978 | 980 | } |
| 979 | 981 | |
| 982 | /** | |
| 983 | * Escape delimiters that would otherwise split a GitHub Markdown table row. | |
| 984 | */ | |
| 985 | private escapeMarkdownTableValue(value: string): string { | |
| 986 | return value.replaceAll('|', '\\|').replaceAll(/\r?\n/g, ' ') | |
| 987 | } | |
| 988 | ||
| 980 | 989 | /** |
| 981 | 990 | * Generate a redirect source URL for Composer packages (like npm) |
| 982 | 991 | */ |
| @@ -7,6 +7,7 @@ import path from 'node:path' | ||
| 7 | 7 | import process from 'node:process' |
| 8 | 8 | import { PackageRegistryError } from '../types' |
| 9 | 9 | import { getUpdateType } from '../utils/helpers' |
| 10 | import { shouldSkipPackageDirectory } from '../utils/package-paths' | |
| 10 | 11 | |
| 11 | 12 | export interface BunOutdatedResult { |
| 12 | 13 | name: string |
| @@ -1342,26 +1343,10 @@ export class RegistryClient { | ||
| 1342 | 1343 | } |
| 1343 | 1344 | |
| 1344 | 1345 | /** |
| 1345 | * Check if a directory should be skipped during scanning | |
| 1346 | * Check if a directory is safe to descend into during package discovery. | |
| 1346 | 1347 | */ |
| 1347 | 1348 | private shouldSkipDirectory(dirName: string): boolean { |
| 1348 | const skipDirs = [ | |
| 1349 | 'node_modules', | |
| 1350 | '.git', | |
| 1351 | '.next', | |
| 1352 | '.nuxt', | |
| 1353 | 'dist', | |
| 1354 | 'build', | |
| 1355 | 'coverage', | |
| 1356 | '.nyc_output', | |
| 1357 | 'tmp', | |
| 1358 | 'temp', | |
| 1359 | '.cache', | |
| 1360 | '.vscode', | |
| 1361 | '.idea', | |
| 1362 | ] | |
| 1363 | ||
| 1364 | return skipDirs.includes(dirName) || dirName.startsWith('.') | |
| 1349 | return shouldSkipPackageDirectory(dirName) | |
| 1365 | 1350 | } |
| 1366 | 1351 | |
| 1367 | 1352 | /** |
| @@ -10,6 +10,7 @@ import { BuddyError } from '../types' | ||
| 10 | 10 | import { isDependencyFile, parseDependencyFile as parseDepFile } from '../utils/dependency-file-parser' |
| 11 | 11 | import { isDockerfile, parseDockerfile as parseDockerfileUtil } from '../utils/dockerfile-parser' |
| 12 | 12 | import { isGitHubActionsFile, parseGitHubActionsFile } from '../utils/github-actions-parser' |
| 13 | import { shouldSkipPackageDirectory } from '../utils/package-paths' | |
| 13 | 14 | import { parsePantryLockFile } from '../utils/pantry-parser' |
| 14 | 15 | import { parseZigManifest } from '../utils/zig-parser' |
| 15 | 16 | |
| @@ -537,7 +538,7 @@ export class PackageScanner { | ||
| 537 | 538 | |
| 538 | 539 | if (stats.isDirectory()) { |
| 539 | 540 | // Skip node_modules and other common ignored directories |
| 540 | if (!this.shouldSkipDirectory(entry)) { | |
| 541 | if (!shouldSkipPackageDirectory(entry)) { | |
| 541 | 542 | const subFiles = await this.findFiles(fileName, fullPath) |
| 542 | 543 | files.push(...subFiles) |
| 543 | 544 | } |
| @@ -597,7 +598,7 @@ export class PackageScanner { | ||
| 597 | 598 | // Normalize path separators to forward slashes for consistency |
| 598 | 599 | files.push(relativePath.split(sep).join('/')) |
| 599 | 600 | } |
| 600 | else if (stats.isDirectory() && !this.shouldSkipDirectory(entry)) { | |
| 601 | else if (stats.isDirectory() && !shouldSkipPackageDirectory(entry)) { | |
| 601 | 602 | // Recursively search subdirectories |
| 602 | 603 | const subFiles = await this.findFilesByPatternInDir(pattern, fullPath) |
| 603 | 604 | files.push(...subFiles) |
| @@ -631,7 +632,7 @@ export class PackageScanner { | ||
| 631 | 632 | // Normalize path separators to forward slashes for consistency |
| 632 | 633 | files.push(relativePath.split(sep).join('/')) |
| 633 | 634 | } |
| 634 | else if (stats.isDirectory() && !this.shouldSkipDirectory(entry)) { | |
| 635 | else if (stats.isDirectory() && !shouldSkipPackageDirectory(entry)) { | |
| 635 | 636 | const subFiles = await this.findFilesByPatternInDir(pattern, fullPath) |
| 636 | 637 | files.push(...subFiles) |
| 637 | 638 | } |
| @@ -670,29 +671,6 @@ export class PackageScanner { | ||
| 670 | 671 | return false |
| 671 | 672 | } |
| 672 | 673 | |
| 673 | /** | |
| 674 | * Check if a directory should be skipped during scanning | |
| 675 | */ | |
| 676 | private shouldSkipDirectory(dirName: string): boolean { | |
| 677 | const skipDirs = [ | |
| 678 | 'node_modules', | |
| 679 | '.git', | |
| 680 | '.next', | |
| 681 | '.nuxt', | |
| 682 | 'dist', | |
| 683 | 'build', | |
| 684 | 'coverage', | |
| 685 | '.nyc_output', | |
| 686 | 'tmp', | |
| 687 | 'temp', | |
| 688 | '.cache', | |
| 689 | '.vscode', | |
| 690 | '.idea', | |
| 691 | ] | |
| 692 | ||
| 693 | return skipDirs.includes(dirName) || dirName.startsWith('.') | |
| 694 | } | |
| 695 | ||
| 696 | 674 | /** |
| 697 | 675 | * Get relative path from project root |
| 698 | 676 | */ |
| @@ -139,7 +139,7 @@ export class ReleaseNotesFetcher { | ||
| 139 | 139 | }, |
| 140 | 140 | releaseNotes: [], |
| 141 | 141 | changelog: [], |
| 142 | compareUrl: `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/v${currentVersion}...v${newVersion}`, | |
| 142 | compareUrl: this.generateCompareUrl(repoInfo.owner, repoInfo.repo, currentVersion, newVersion), | |
| 143 | 143 | } |
| 144 | 144 | } |
| 145 | 145 | |
| @@ -435,30 +435,69 @@ export class ReleaseNotesFetcher { | ||
| 435 | 435 | /** |
| 436 | 436 | * Generate compare URL between versions |
| 437 | 437 | */ |
| 438 | private generateCompareUrl(owner: string, repo: string, fromVersion: string, toVersion: string): string { | |
| 439 | const cleanFrom = fromVersion.startsWith('v') ? fromVersion : `v${fromVersion}` | |
| 440 | const cleanTo = toVersion.startsWith('v') ? toVersion : `v${toVersion}` | |
| 441 | return `https://github.com/${owner}/${repo}/compare/${cleanFrom}...${cleanTo}` | |
| 438 | private generateCompareUrl(owner: string, repo: string, fromVersion: string, toVersion: string): string | undefined { | |
| 439 | const cleanFrom = this.getComparableVersion(fromVersion) | |
| 440 | const cleanTo = this.getComparableVersion(toVersion) | |
| 441 | if (!cleanFrom || !cleanTo) | |
| 442 | return undefined | |
| 443 | ||
| 444 | return `https://github.com/${owner}/${repo}/compare/${encodeURIComponent(`v${cleanFrom}`)}...${encodeURIComponent(`v${cleanTo}`)}` | |
| 442 | 445 | } |
| 443 | 446 | |
| 444 | 447 | /** |
| 445 | 448 | * Check if version is between current and new version |
| 446 | 449 | */ |
| 447 | 450 | private isVersionBetween(version: string, current: string, target: string): boolean { |
| 448 | const cleanVersion = version.replace(/^v/, '') | |
| 449 | const cleanCurrent = current.replace(/^v/, '') | |
| 450 | const cleanTarget = target.replace(/^v/, '') | |
| 451 | const cleanVersion = this.getComparableVersion(version) | |
| 452 | const cleanCurrent = this.getComparableVersion(current) | |
| 453 | const cleanTarget = this.getComparableVersion(target) | |
| 454 | if (!cleanVersion || !cleanCurrent || !cleanTarget) | |
| 455 | return false | |
| 451 | 456 | |
| 452 | // Proper semver compare — lexicographic ordering gets `1.10.0 > 1.9.0` wrong. | |
| 453 | // Fall back to string equality if either side isn't valid semver. | |
| 454 | 457 | try { |
| 455 | return cleanVersion === cleanTarget || semver.order(cleanVersion, cleanCurrent) > 0 | |
| 458 | return semver.order(cleanVersion, cleanCurrent) > 0 | |
| 459 | && semver.order(cleanVersion, cleanTarget) <= 0 | |
| 456 | 460 | } |
| 457 | 461 | catch { |
| 458 | return cleanVersion === cleanTarget || cleanVersion > cleanCurrent | |
| 462 | return cleanVersion > cleanCurrent && cleanVersion <= cleanTarget | |
| 459 | 463 | } |
| 460 | 464 | } |
| 461 | 465 | |
| 466 | /** | |
| 467 | * Derive a concrete lower-bound version for compare links and ordering. | |
| 468 | * For OR ranges, use the highest alternative's lower bound. | |
| 469 | */ | |
| 470 | private getComparableVersion(version: string): string | null { | |
| 471 | const candidates = version.split('||').flatMap((alternative) => { | |
| 472 | const trimmed = alternative.trim() | |
| 473 | if (trimmed.startsWith('<')) | |
| 474 | return [] | |
| 475 | ||
| 476 | const match = trimmed.match(/^(?:[~^]|>=?|=)?\s*v?(\d+(?:\.\d+){0,2}(?:-[0-9A-Z.-]+)?)/i) | |
| 477 | if (!match) | |
| 478 | return [] | |
| 479 | ||
| 480 | const [core, prerelease] = match[1].split('-', 2) | |
| 481 | const parts = core.split('.') | |
| 482 | while (parts.length < 3) | |
| 483 | parts.push('0') | |
| 484 | ||
| 485 | return [`${parts.join('.')}${prerelease ? `-${prerelease}` : ''}`] | |
| 486 | }) | |
| 487 | ||
| 488 | if (candidates.length === 0) | |
| 489 | return null | |
| 490 | ||
| 491 | return candidates.reduce((highest, candidate) => { | |
| 492 | try { | |
| 493 | return semver.order(candidate, highest) > 0 ? candidate : highest | |
| 494 | } | |
| 495 | catch { | |
| 496 | return candidate > highest ? candidate : highest | |
| 497 | } | |
| 498 | }) | |
| 499 | } | |
| 500 | ||
| 462 | 501 | /** |
| 463 | 502 | * Generate confidence badges and metrics |
| 464 | 503 | */ |
| @@ -0,0 +1,41 @@ | ||
| 1 | import type { FileChange } from '../types' | |
| 2 | import { posix } from 'node:path' | |
| 3 | ||
| 4 | export class FileChangeValidationError extends Error { | |
| 5 | constructor(message: string) { | |
| 6 | super(message) | |
| 7 | this.name = 'FileChangeValidationError' | |
| 8 | } | |
| 9 | } | |
| 10 | ||
| 11 | /** | |
| 12 | * Convert a user or scanner supplied path into a safe repository-relative path. | |
| 13 | */ | |
| 14 | export function normalizeRepositoryPath(filePath: string): string { | |
| 15 | const normalized = posix.normalize(filePath.replaceAll('\\', '/').replace(/^\.\/+/, '')) | |
| 16 | ||
| 17 | if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../') || posix.isAbsolute(normalized)) { | |
| 18 | throw new FileChangeValidationError(`Refusing unsafe repository path: ${JSON.stringify(filePath)}`) | |
| 19 | } | |
| 20 | ||
| 21 | return normalized | |
| 22 | } | |
| 23 | ||
| 24 | /** | |
| 25 | * An update must modify a file that already exists on the base branch. Treating | |
| 26 | * a missing update target as a create is how generated cache paths can leak | |
| 27 | * into dependency PRs. | |
| 28 | */ | |
| 29 | export async function assertUpdateTargetsExist( | |
| 30 | files: FileChange[], | |
| 31 | targetExists: (path: string) => boolean | Promise<boolean>, | |
| 32 | ): Promise<void> { | |
| 33 | for (const file of files) { | |
| 34 | const cleanPath = normalizeRepositoryPath(file.path) | |
| 35 | if (file.type === 'update' && !(await targetExists(cleanPath))) { | |
| 36 | throw new FileChangeValidationError( | |
| 37 | `Refusing to create missing update target: ${cleanPath}. Use change type "create" for intentional new files.`, | |
| 38 | ) | |
| 39 | } | |
| 40 | } | |
| 41 | } | |
| @@ -0,0 +1,32 @@ | ||
| 1 | const GENERATED_DEPENDENCY_DIRECTORIES = new Set([ | |
| 2 | 'bower_components', | |
| 3 | 'node_modules', | |
| 4 | 'pantry', | |
| 5 | 'ts-pantry', | |
| 6 | 'vendor', | |
| 7 | ]) | |
| 8 | ||
| 9 | const GENERATED_OUTPUT_DIRECTORIES = new Set([ | |
| 10 | '.cache', | |
| 11 | '.idea', | |
| 12 | '.next', | |
| 13 | '.nuxt', | |
| 14 | '.nyc_output', | |
| 15 | '.vscode', | |
| 16 | 'build', | |
| 17 | 'coverage', | |
| 18 | 'dist', | |
| 19 | 'temp', | |
| 20 | 'tmp', | |
| 21 | ]) | |
| 22 | ||
| 23 | /** | |
| 24 | * Package discovery must not descend into installed dependencies, generated | |
| 25 | * package-manager trees, or build output. Manifests in those directories are | |
| 26 | * implementation details, not dependency declarations owned by the project. | |
| 27 | */ | |
| 28 | export function shouldSkipPackageDirectory(dirName: string): boolean { | |
| 29 | return dirName.startsWith('.') | |
| 30 | || GENERATED_DEPENDENCY_DIRECTORIES.has(dirName) | |
| 31 | || GENERATED_OUTPUT_DIRECTORIES.has(dirName) | |
| 32 | } | |
| @@ -28,14 +28,11 @@ const mockResolveDependencyFile = mock(() => Promise.resolve({ | ||
| 28 | 28 | |
| 29 | 29 | describe('Bun deps.yaml Update Tests', () => { |
| 30 | 30 | let testDir: string |
| 31 | let originalCwd: string | |
| 32 | 31 | let config: BuddyBotConfig |
| 33 | 32 | |
| 34 | 33 | beforeAll(async () => { |
| 35 | 34 | // Create a temporary directory for testing |
| 36 | 35 | testDir = await mkdtemp(join(tmpdir(), 'buddy-test-')) |
| 37 | originalCwd = process.cwd() | |
| 38 | process.chdir(testDir) | |
| 39 | 36 | |
| 40 | 37 | // Create a basic package.json file |
| 41 | 38 | await writeFile( |
| @@ -95,7 +92,6 @@ describe('Bun deps.yaml Update Tests', () => { | ||
| 95 | 92 | |
| 96 | 93 | afterAll(async () => { |
| 97 | 94 | // Clean up |
| 98 | process.chdir(originalCwd) | |
| 99 | 95 | await rm(testDir, { recursive: true, force: true }) |
| 100 | 96 | }) |
| 101 | 97 | |
| @@ -0,0 +1,39 @@ | ||
| 1 | import type { FileChange } from '../src/types' | |
| 2 | import { describe, expect, it } from 'bun:test' | |
| 3 | import { | |
| 4 | assertUpdateTargetsExist, | |
| 5 | FileChangeValidationError, | |
| 6 | normalizeRepositoryPath, | |
| 7 | } from '../src/utils/file-changes' | |
| 8 | ||
| 9 | describe('file change validation', () => { | |
| 10 | it('normalizes repository-relative paths', () => { | |
| 11 | expect(normalizeRepositoryPath('./packages\\app/package.json')).toBe('packages/app/package.json') | |
| 12 | }) | |
| 13 | ||
| 14 | it('rejects paths outside the repository', () => { | |
| 15 | expect(() => normalizeRepositoryPath('../package.json')).toThrow(FileChangeValidationError) | |
| 16 | expect(() => normalizeRepositoryPath('/tmp/package.json')).toThrow(FileChangeValidationError) | |
| 17 | }) | |
| 18 | ||
| 19 | it('allows updates only when the base target exists', async () => { | |
| 20 | const files: FileChange[] = [ | |
| 21 | { path: 'package.json', content: '{}', type: 'update' }, | |
| 22 | { path: 'generated.json', content: '{}', type: 'create' }, | |
| 23 | ] | |
| 24 | ||
| 25 | await expect(assertUpdateTargetsExist(files, path => path === 'package.json')).resolves.toBeUndefined() | |
| 26 | }) | |
| 27 | ||
| 28 | it('rejects an update that would create a missing file', async () => { | |
| 29 | const files: FileChange[] = [{ | |
| 30 | path: './pantry/install/cache/acorn/package.json', | |
| 31 | content: '{}', | |
| 32 | type: 'update', | |
| 33 | }] | |
| 34 | ||
| 35 | await expect(assertUpdateTargetsExist(files, () => false)).rejects.toThrow( | |
| 36 | 'Refusing to create missing update target: pantry/install/cache/acorn/package.json', | |
| 37 | ) | |
| 38 | }) | |
| 39 | }) | |
| @@ -0,0 +1,71 @@ | ||
| 1 | import { afterEach, describe, expect, it } from 'bun:test' | |
| 2 | import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { RegistryClient } from '../src/registry/registry-client' | |
| 6 | import { PackageScanner } from '../src/scanner/package-scanner' | |
| 7 | import { Logger } from '../src/utils/logger' | |
| 8 | import { shouldSkipPackageDirectory } from '../src/utils/package-paths' | |
| 9 | ||
| 10 | const generatedDirectories = [ | |
| 11 | 'bower_components', | |
| 12 | 'node_modules', | |
| 13 | 'pantry', | |
| 14 | 'ts-pantry', | |
| 15 | 'vendor', | |
| 16 | ] | |
| 17 | ||
| 18 | describe('generated dependency directory filtering', () => { | |
| 19 | const temporaryDirectories: string[] = [] | |
| 20 | const logger = Logger.quiet() | |
| 21 | ||
| 22 | afterEach(async () => { | |
| 23 | await Promise.all(temporaryDirectories.splice(0).map(directory => | |
| 24 | rm(directory, { recursive: true, force: true }), | |
| 25 | )) | |
| 26 | }) | |
| 27 | ||
| 28 | it('classifies package-manager install trees as generated', () => { | |
| 29 | for (const directory of generatedDirectories) | |
| 30 | expect(shouldSkipPackageDirectory(directory)).toBe(true) | |
| 31 | ||
| 32 | expect(shouldSkipPackageDirectory('packages')).toBe(false) | |
| 33 | }) | |
| 34 | ||
| 35 | it('excludes generated manifests from both package discovery paths', async () => { | |
| 36 | const projectPath = await mkdtemp(join(tmpdir(), 'buddy-generated-dependencies-')) | |
| 37 | temporaryDirectories.push(projectPath) | |
| 38 | ||
| 39 | await writePackageJson(projectPath, 'package.json', 'root') | |
| 40 | await writePackageJson(projectPath, 'packages/app/package.json', 'app') | |
| 41 | ||
| 42 | for (const directory of generatedDirectories) | |
| 43 | await writePackageJson(projectPath, `${directory}/cached/package.json`, `generated-${directory}`) | |
| 44 | ||
| 45 | const scanner = new PackageScanner(projectPath, logger) | |
| 46 | const scannedPaths = (await scanner.scanProject()).map(file => file.path) | |
| 47 | ||
| 48 | const registryClient = new RegistryClient(projectPath, logger) | |
| 49 | const registryPaths = await (registryClient as any).findPackageJsonFiles() | |
| 50 | ||
| 51 | expect(scannedPaths.filter(path => path.endsWith('package.json')).sort()).toEqual([ | |
| 52 | 'package.json', | |
| 53 | 'packages/app/package.json', | |
| 54 | ]) | |
| 55 | expect(registryPaths.sort()).toEqual([ | |
| 56 | 'package.json', | |
| 57 | 'packages/app/package.json', | |
| 58 | ]) | |
| 59 | }) | |
| 60 | }) | |
| 61 | ||
| 62 | async function writePackageJson(projectPath: string, relativePath: string, name: string): Promise<void> { | |
| 63 | const filePath = join(projectPath, relativePath) | |
| 64 | await mkdir(join(filePath, '..'), { recursive: true }) | |
| 65 | await writeFile(filePath, JSON.stringify({ | |
| 66 | name, | |
| 67 | dependencies: { | |
| 68 | acorn: '^8.0.0', | |
| 69 | }, | |
| 70 | })) | |
| 71 | } | |
| @@ -13,7 +13,6 @@ describe('IgnorePaths Auto-Close Functionality', () => { | ||
| 13 | 13 | |
| 14 | 14 | beforeEach(async () => { |
| 15 | 15 | testDir = await mkdtemp(join(tmpdir(), 'buddy-ignore-paths-autoclose-')) |
| 16 | process.chdir(testDir) | |
| 17 | 16 | logger = new Logger(false) // Quiet logger for tests |
| 18 | 17 | }) |
| 19 | 18 | |
| @@ -46,6 +46,30 @@ describe('No-op commit prevention', () => { | ||
| 46 | 46 | expect(hasPush).toBeTrue() |
| 47 | 47 | }) |
| 48 | 48 | |
| 49 | it('does not fall back to an API create when an update target is missing from base', async () => { | |
| 50 | const prov = new GitHubProvider('token', 'owner', 'repo', true) as any | |
| 51 | const commands: Array<{ cmd: string, args: string[] }> = [] | |
| 52 | let apiCalled = false | |
| 53 | ||
| 54 | prov.runCommand = async (command: string, args: string[]) => { | |
| 55 | commands.push({ cmd: command, args }) | |
| 56 | if (command === 'git' && args[0] === 'cat-file') | |
| 57 | throw new Error('missing from base') | |
| 58 | return '' | |
| 59 | } | |
| 60 | prov.apiRequest = async () => { | |
| 61 | apiCalled = true | |
| 62 | return {} | |
| 63 | } | |
| 64 | ||
| 65 | await expect(prov.commitChanges('buddy/test-branch', 'test message', filesChanged)).rejects.toThrow( | |
| 66 | 'Refusing to create missing update target: test-package.json', | |
| 67 | ) | |
| 68 | ||
| 69 | expect(apiCalled).toBeFalse() | |
| 70 | expect(commands.some(command => command.args[0] === 'add')).toBeFalse() | |
| 71 | }) | |
| 72 | ||
| 49 | 73 | it('skips API commit when new tree equals current tree (API fallback path)', async () => { |
| 50 | 74 | const prov = new GitHubProvider('token', 'owner', 'repo', true) as any |
| 51 | 75 | const apiCalls: Array<{ endpoint: string, data: any }> = [] |
| @@ -0,0 +1,47 @@ | ||
| 1 | import { beforeAll, describe, expect, it } from 'bun:test' | |
| 2 | import { PullRequestGenerator } from '../src/pr/pr-generator' | |
| 3 | import { ReleaseNotesFetcher } from '../src/services/release-notes-fetcher' | |
| 4 | ||
| 5 | describe('PR body version ranges', () => { | |
| 6 | beforeAll(() => { | |
| 7 | process.env.APP_ENV = 'test' | |
| 8 | }) | |
| 9 | ||
| 10 | it('keeps compound npm ranges inside one table cell and URL-encodes links', async () => { | |
| 11 | const generator = new PullRequestGenerator({ | |
| 12 | repository: { | |
| 13 | provider: 'github', | |
| 14 | owner: 'stacksjs', | |
| 15 | name: 'stacks', | |
| 16 | }, | |
| 17 | }) | |
| 18 | ||
| 19 | const body = await generator.generateBody({ | |
| 20 | name: 'Major Update - acorn', | |
| 21 | title: 'chore(deps): update dependency acorn to 8.17.0', | |
| 22 | body: '', | |
| 23 | updateType: 'major', | |
| 24 | updates: [{ | |
| 25 | name: 'acorn', | |
| 26 | currentVersion: '^6.0.0 || ^7.0.0 || ^8.0.0', | |
| 27 | newVersion: '8.17.0', | |
| 28 | updateType: 'major', | |
| 29 | dependencyType: 'peerDependencies', | |
| 30 | file: 'package.json', | |
| 31 | }], | |
| 32 | }) | |
| 33 | ||
| 34 | expect(body).toContain('`^6.0.0 \\|\\| ^7.0.0 \\|\\| ^8.0.0`') | |
| 35 | expect(body).toContain('%5E6.0.0%20%7C%7C%20%5E7.0.0%20%7C%7C%20%5E8.0.0') | |
| 36 | expect(body).toContain('/compare/v8.0.0...v8.17.0') | |
| 37 | expect(body).not.toContain('/compare/v^6.0.0 ||') | |
| 38 | }) | |
| 39 | ||
| 40 | it('bounds release selection to the requested range', () => { | |
| 41 | const fetcher = new ReleaseNotesFetcher() as any | |
| 42 | ||
| 43 | expect(fetcher.isVersionBetween('8.16.0', '^6.0.0 || ^7.0.0 || ^8.0.0', '8.17.0')).toBe(true) | |
| 44 | expect(fetcher.isVersionBetween('9.0.0', '^6.0.0 || ^7.0.0 || ^8.0.0', '8.17.0')).toBe(false) | |
| 45 | expect(fetcher.isVersionBetween('8.0.0', '^6.0.0 || ^7.0.0 || ^8.0.0', '8.17.0')).toBe(false) | |
| 46 | }) | |
| 47 | }) | |