코어 composer update 69 패키지(Laravel 12.69.1 · Reverb 1.11.1 · Scout 11.6.1 · Monolog 3.11 · Symfony 7.4.18 등, 메이저 상향은 보류). 번들 확장 16개 lock content-hash 재동기(신규 2), 코어·이커머스 vendor-bundle.zip 재빌드(g7_version 7.0.11). vendor-bundle:build-all --check 가 미설치 확장을 「소스 경로 없음」으로 건너뛰어 _bundled 의 stale 번들이 점검을 통과하던 결함을 수정 — 판정 파일을 빌드·해시와 같은 규칙(_bundled 우선, 활성 폴백)으로 고르고 회귀 테스트 2건 추가. 회귀 점검: 변경 로그 대조 9축 + PHPUnit 4배치 + Chrome MCP 매트릭스 11축 통과.
371 lines
12 KiB
PHP
371 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands\Vendor\Concerns;
|
|
|
|
use App\Extension\Vendor\Exceptions\VendorInstallException;
|
|
use App\Extension\Vendor\VendorBundler;
|
|
use App\Extension\Vendor\VendorBundleResult;
|
|
use App\Extension\Vendor\VendorIntegrityChecker;
|
|
use Illuminate\Support\Facades\File;
|
|
|
|
/**
|
|
* Vendor 번들 빌드/검증 액션 공통 로직.
|
|
*
|
|
* core:vendor-bundle / module:vendor-bundle / plugin:vendor-bundle 및
|
|
* 일괄 빌드 vendor-bundle:build-all 명령어들이 본 trait 을 공유한다.
|
|
*/
|
|
trait RunsVendorBundleAction
|
|
{
|
|
/**
|
|
* 빌드 또는 stale 체크 수행.
|
|
*
|
|
* @param array<int, array{type: string, identifier: ?string}> $targets
|
|
* @param bool $force 해시 체크 무시하고 강제 재빌드
|
|
* @param bool $check 실제 빌드 없이 stale 여부만 확인 (true 면 stale 시 종료코드 1)
|
|
*/
|
|
protected function runBuildAction(VendorBundler $bundler, array $targets, bool $force, bool $check): int
|
|
{
|
|
if (empty($targets)) {
|
|
$this->error('빌드 대상이 없습니다.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$this->line($check ? 'Vendor Bundle Status Check' : 'Vendor Bundle Build');
|
|
$this->line(str_repeat('─', 66));
|
|
$this->newLine();
|
|
|
|
$hasStale = false;
|
|
$results = [];
|
|
|
|
foreach ($targets as $target) {
|
|
try {
|
|
if ($check) {
|
|
if ($this->performCheck($bundler, $target)) {
|
|
$hasStale = true;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
$results[] = $this->performBuild($bundler, $target, $force);
|
|
} catch (VendorInstallException $e) {
|
|
$this->error(sprintf('✗ %s: %s', $this->formatTargetLabel($target), $e->getMessage()));
|
|
} catch (\Throwable $e) {
|
|
$this->error(sprintf('✗ %s: %s', $this->formatTargetLabel($target), $e->getMessage()));
|
|
}
|
|
}
|
|
|
|
$this->newLine();
|
|
$this->line(str_repeat('─', 66));
|
|
|
|
if ($check) {
|
|
return $hasStale ? self::FAILURE : self::SUCCESS;
|
|
}
|
|
|
|
$this->printBuildSummary($results);
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* 무결성 검증 수행.
|
|
*
|
|
* @param array<int, array{type: string, identifier: ?string}> $targets
|
|
*/
|
|
protected function runVerifyAction(VendorIntegrityChecker $checker, array $targets): int
|
|
{
|
|
if (empty($targets)) {
|
|
$this->error('검증 대상이 없습니다.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$this->line('Vendor Bundle Integrity Verification');
|
|
$this->line(str_repeat('─', 66));
|
|
$this->newLine();
|
|
|
|
$failures = 0;
|
|
foreach ($targets as $target) {
|
|
$label = $this->formatTargetLabel($target);
|
|
$outputPath = $this->resolveOutputPath($target);
|
|
|
|
if (! is_dir($outputPath)) {
|
|
$this->warn("- $label: 출력 경로 없음");
|
|
|
|
continue;
|
|
}
|
|
|
|
$zipPath = $outputPath.DIRECTORY_SEPARATOR.VendorIntegrityChecker::ZIP_FILENAME;
|
|
if (! file_exists($zipPath)) {
|
|
$this->line("- $label: 번들 없음 (스킵)");
|
|
|
|
continue;
|
|
}
|
|
|
|
$result = $checker->verify($outputPath);
|
|
|
|
if ($result->valid) {
|
|
$packageCount = $result->meta['package_count'] ?? 0;
|
|
$zipSize = @filesize($zipPath) ?: 0;
|
|
$this->line(sprintf(
|
|
'✓ %s: OK (%.1f MB, %d packages)',
|
|
$label,
|
|
$zipSize / 1024 / 1024,
|
|
$packageCount,
|
|
));
|
|
} else {
|
|
$failures++;
|
|
$this->error(sprintf('✗ %s: FAILED', $label));
|
|
foreach ($result->errorMessages() as $message) {
|
|
$this->line(' - '.$message);
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->newLine();
|
|
$this->line(str_repeat('─', 66));
|
|
|
|
if ($failures > 0) {
|
|
$this->error("검증 실패: $failures 개");
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$this->info('모든 번들이 무결성 검증을 통과했습니다.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* `_bundled` 디렉토리에서 모든 모듈/플러그인 식별자 목록 반환.
|
|
*
|
|
* @param string $type 'module' | 'plugin'
|
|
* @return array<int, string>
|
|
*/
|
|
protected function discoverBundledIdentifiers(string $type): array
|
|
{
|
|
$base = base_path($type === 'module' ? 'modules/_bundled' : 'plugins/_bundled');
|
|
if (! is_dir($base)) {
|
|
return [];
|
|
}
|
|
|
|
$identifiers = [];
|
|
foreach (File::directories($base) as $dir) {
|
|
if (is_file($dir.DIRECTORY_SEPARATOR.'composer.json')) {
|
|
$identifiers[] = basename($dir);
|
|
}
|
|
}
|
|
|
|
return $identifiers;
|
|
}
|
|
|
|
private function performBuild(VendorBundler $bundler, array $target, bool $force): VendorBundleResult
|
|
{
|
|
$label = $this->formatTargetLabel($target);
|
|
$this->line("▶ $label 빌드 중...");
|
|
|
|
$result = $target['type'] === 'core'
|
|
? $bundler->buildForCore($force)
|
|
: $bundler->buildForExtension($target['type'], $target['identifier'], $force);
|
|
|
|
if ($result->skipped && $result->reason === 'no-external-dependencies') {
|
|
$this->line(sprintf(
|
|
' ○ %s: 스킵 (외부 composer 의존성 없음)',
|
|
$label,
|
|
));
|
|
} elseif ($result->skipped && $result->reason === 'extension-not-installed') {
|
|
$this->line(sprintf(
|
|
' ○ %s: 스킵 (확장이 설치되지 않음 — 먼저 module:install / plugin:install 필요)',
|
|
$label,
|
|
));
|
|
} elseif ($result->skipped) {
|
|
$this->line(sprintf(
|
|
' ○ %s: 스킵 (%s, %s, %d packages)',
|
|
$label,
|
|
$result->reason,
|
|
$result->zipSizeHuman(),
|
|
$result->packageCount,
|
|
));
|
|
} else {
|
|
$this->line(sprintf(
|
|
' ✓ %s: %s, %d packages',
|
|
$label,
|
|
$result->zipSizeHuman(),
|
|
$result->packageCount,
|
|
));
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* stale 여부 확인만 수행.
|
|
*
|
|
* @return bool stale 이면 true
|
|
*/
|
|
private function performCheck(VendorBundler $bundler, array $target): bool
|
|
{
|
|
$label = $this->formatTargetLabel($target);
|
|
$sourcePath = $this->resolveSourcePath($target);
|
|
$outputPath = $this->resolveOutputPath($target);
|
|
|
|
// 판정 기준 파일은 빌드(VendorBundler::build / isStale)와 같은 규칙 — 출력(_bundled) 우선,
|
|
// 없으면 활성 디렉토리 폴백. 활성 디렉토리만 보면 미설치 확장을 "소스 경로 없음" 으로
|
|
// 보고해 _bundled 의 stale 번들이 --check 를 통과한다.
|
|
$composerJsonPath = $this->resolveCheckFile($sourcePath, $outputPath, 'composer.json');
|
|
if ($composerJsonPath === null) {
|
|
if (! is_dir($sourcePath) && ! is_dir($outputPath)) {
|
|
$this->warn("- $label: 경로 없음 ($outputPath)");
|
|
|
|
return false;
|
|
}
|
|
|
|
$this->line("- $label: SKIPPED (composer.json 없음)");
|
|
|
|
return false;
|
|
}
|
|
|
|
// 외부 composer 의존성이 없는 확장은 번들링 대상이 아님
|
|
if (! $this->composerJsonHasExternalDependencies($composerJsonPath)) {
|
|
$this->line("- $label: SKIPPED (외부 composer 의존성 없음)");
|
|
|
|
return false;
|
|
}
|
|
|
|
$stale = $bundler->isStale($sourcePath, $outputPath);
|
|
if ($stale) {
|
|
$this->error("✗ $label: STALE — 재빌드 필요");
|
|
} else {
|
|
$this->line("✓ $label: up-to-date");
|
|
}
|
|
|
|
return $stale;
|
|
}
|
|
|
|
/**
|
|
* --check 가 읽을 composer 파일 경로 — 출력(_bundled) 우선, 없으면 활성 디렉토리 폴백.
|
|
*
|
|
* VendorBundler::resolveHashTarget() 과 같은 규칙. 판정 파일이 빌드·해시 기준과
|
|
* 다르면 --check 결과가 실제 stale 여부와 어긋난다.
|
|
*/
|
|
private function resolveCheckFile(string $sourcePath, string $outputPath, string $filename): ?string
|
|
{
|
|
$outputFile = $outputPath.DIRECTORY_SEPARATOR.$filename;
|
|
if (is_file($outputFile)) {
|
|
return $outputFile;
|
|
}
|
|
|
|
$sourceFile = $sourcePath.DIRECTORY_SEPARATOR.$filename;
|
|
|
|
return is_file($sourceFile) ? $sourceFile : null;
|
|
}
|
|
|
|
/**
|
|
* composer.json 에 외부 패키지 의존성이 있는지 확인합니다.
|
|
*
|
|
* VendorBundler::hasExternalDependencies() 와 동일 로직 — trait 에서 중복 구현을
|
|
* 피하지 않고 독립적으로 구현하여 VendorBundler 인스턴스 없이도 사용 가능하게 함.
|
|
*/
|
|
private function composerJsonHasExternalDependencies(string $composerJsonPath): bool
|
|
{
|
|
$json = @file_get_contents($composerJsonPath);
|
|
if ($json === false) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (\JsonException $e) {
|
|
return false;
|
|
}
|
|
|
|
$require = $data['require'] ?? [];
|
|
if (! is_array($require) || empty($require)) {
|
|
return false;
|
|
}
|
|
|
|
foreach (array_keys($require) as $package) {
|
|
if ($package === 'php' || str_starts_with($package, 'ext-')) {
|
|
continue;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param array{type: string, identifier: ?string} $target
|
|
*/
|
|
private function formatTargetLabel(array $target): string
|
|
{
|
|
return $target['type'] === 'core'
|
|
? 'core'
|
|
: sprintf('%ss/%s', $target['type'], $target['identifier']);
|
|
}
|
|
|
|
/**
|
|
* 소스 경로 (composer.json, composer.lock, vendor/ 를 읽는 경로).
|
|
*
|
|
* 코어: base_path() 자체가 소스이자 출력
|
|
* 모듈/플러그인: 활성 디렉토리 (modules/{id}, plugins/{id}) — 설치 시 composer install 이 실행된 곳
|
|
*
|
|
* @param array{type: string, identifier: ?string} $target
|
|
*/
|
|
private function resolveSourcePath(array $target): string
|
|
{
|
|
return match ($target['type']) {
|
|
'core' => base_path(),
|
|
'module' => base_path('modules/'.$target['identifier']),
|
|
'plugin' => base_path('plugins/'.$target['identifier']),
|
|
default => '',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 출력 경로 (vendor-bundle.zip 과 vendor-bundle.json 을 쓰는 경로).
|
|
*
|
|
* 코어: base_path() (소스와 동일)
|
|
* 모듈/플러그인: _bundled 디렉토리 — Git 추적되는 배포 아티팩트 저장 위치
|
|
*
|
|
* @param array{type: string, identifier: ?string} $target
|
|
*/
|
|
private function resolveOutputPath(array $target): string
|
|
{
|
|
return match ($target['type']) {
|
|
'core' => base_path(),
|
|
'module' => base_path('modules/_bundled/'.$target['identifier']),
|
|
'plugin' => base_path('plugins/_bundled/'.$target['identifier']),
|
|
default => '',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<int, VendorBundleResult> $results
|
|
*/
|
|
private function printBuildSummary(array $results): void
|
|
{
|
|
$built = 0;
|
|
$skipped = 0;
|
|
$totalSize = 0;
|
|
|
|
foreach ($results as $result) {
|
|
if ($result->skipped) {
|
|
$skipped++;
|
|
} else {
|
|
$built++;
|
|
}
|
|
$totalSize += $result->zipSize;
|
|
}
|
|
|
|
$this->line(sprintf(
|
|
'Summary: %d built, %d skipped, total %.1f MB',
|
|
$built,
|
|
$skipped,
|
|
$totalSize / 1024 / 1024,
|
|
));
|
|
}
|
|
}
|