*/ private const RESERVED_NAMESPACES = ['core', 'module', 'plugin', 'template']; /** * IETF BCP-47 단순 검증 패턴 (대소문자 혼용 + 하이픈 서브태그). * * 완전한 BCP-47 파서는 아니지만 G7 에서 다루는 일반적 로케일(ko, en, ja, zh-CN, pt-BR)을 수용합니다. */ private const BCP47_PATTERN = '/^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2}|-[0-9]{3})?(-[a-zA-Z0-9]{5,8})?$/'; /** * 식별자 패턴 (kebab-case, 영문 시작, 숫자/언더스코어 금지) — namespace 와 vendor 양쪽에 동일 적용. */ private const KEBAB_IDENTIFIER_PATTERN = '/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/'; /** * manifest 배열을 검증합니다. * * 검증 실패 시 ValidationException 을 throw 합니다. * * @param array $manifest manifest 데이터 * @param string|null $packageRoot 파일 존재 검증을 위한 패키지 루트 디렉토리 (옵션, 미사용 — 호환을 위해 시그니처 유지) * @return void * * @throws ValidationException 검증 실패 시 */ public function validate(array $manifest, ?string $packageRoot = null): void { $errors = []; $this->validateRequiredKeys($manifest, $errors); if (! empty($errors)) { throw ValidationException::withMessages($errors); } $this->validateNamespace($manifest, $errors); $this->validateVendor($manifest, $errors); $this->validateScopeAndTarget($manifest, $errors); $this->validateLocale($manifest, $errors); $this->validateIdentifierNaming($manifest, $errors); $this->validateG7Version($manifest, $errors); $this->validateRequires($manifest, $errors); if (! empty($errors)) { throw ValidationException::withMessages($errors); } } /** * 필수 키 존재 여부를 검증합니다. * * @param array $manifest manifest 데이터 * @param array> $errors 에러 누적 배열 * @return void */ private function validateRequiredKeys(array $manifest, array &$errors): void { $required = ['identifier', 'namespace', 'vendor', 'name', 'version', 'scope', 'locale', 'locale_native_name']; foreach ($required as $key) { if (! array_key_exists($key, $manifest) || $manifest[$key] === '' || $manifest[$key] === null) { $errors['manifest.'.$key][] = sprintf('필수 필드 "%s" 가 누락되었습니다.', $key); } } } /** * namespace 필드를 검증합니다 (identifier 의 첫 segment 와 일치 — 번들 prefix). * * @param array $manifest manifest 데이터 * @param array> $errors 에러 누적 배열 * @return void */ private function validateNamespace(array $manifest, array &$errors): void { $namespace = (string) ($manifest['namespace'] ?? ''); if (! preg_match(self::KEBAB_IDENTIFIER_PATTERN, $namespace)) { $errors['manifest.namespace'][] = 'namespace 는 영문 소문자로 시작하는 kebab-case 형식이어야 합니다.'; } if (in_array($namespace, self::RESERVED_NAMESPACES, true)) { $errors['manifest.namespace'][] = sprintf('namespace "%s" 는 예약어이므로 사용할 수 없습니다.', $namespace); } } /** * vendor 필드(제작자)를 검증합니다 — 모듈/플러그인 매니페스트와 동일한 의미. * * @param array $manifest manifest 데이터 * @param array> $errors 에러 누적 배열 * @return void */ private function validateVendor(array $manifest, array &$errors): void { $vendor = (string) ($manifest['vendor'] ?? ''); if (! preg_match(self::KEBAB_IDENTIFIER_PATTERN, $vendor)) { $errors['manifest.vendor'][] = 'vendor 는 영문 소문자로 시작하는 kebab-case 형식이어야 합니다.'; } } /** * scope 와 target_identifier 정합성을 검증합니다. * * @param array $manifest manifest 데이터 * @param array> $errors 에러 누적 배열 * @return void */ private function validateScopeAndTarget(array $manifest, array &$errors): void { $scope = (string) ($manifest['scope'] ?? ''); if (! LanguagePackScope::isValid($scope)) { $errors['manifest.scope'][] = sprintf( 'scope 는 %s 중 하나여야 합니다.', implode(', ', LanguagePackScope::values()) ); return; } $scopeEnum = LanguagePackScope::from($scope); $target = $manifest['target_identifier'] ?? null; if ($scopeEnum->isCore() && $target !== null) { $errors['manifest.target_identifier'][] = 'scope=core 인 경우 target_identifier 는 null 이어야 합니다.'; } if ($scopeEnum->requiresTarget() && (empty($target) || ! is_string($target))) { $errors['manifest.target_identifier'][] = sprintf( 'scope=%s 인 경우 target_identifier 가 필수입니다.', $scope ); } } /** * locale 형식을 검증합니다. * * @param array $manifest manifest 데이터 * @param array> $errors 에러 누적 배열 * @return void */ private function validateLocale(array $manifest, array &$errors): void { $locale = (string) ($manifest['locale'] ?? ''); if (! preg_match(self::BCP47_PATTERN, $locale)) { $errors['manifest.locale'][] = sprintf( 'locale "%s" 는 IETF BCP-47 형식이 아닙니다 (예: ko, en, ja, zh-CN).', $locale ); } } /** * identifier 의 네이밍 공식을 검증합니다. * * 공식 ({namespace} 는 manifest.namespace 필드 — 모든 G7 공식 번들의 공통 prefix `g7`): * - scope=core : {namespace}-core-{locale} * - scope=module : {namespace}-module-{target}-{locale} * - scope=plugin : {namespace}-plugin-{target}-{locale} * - scope=template : {namespace}-template-{target}-{locale} * * @param array $manifest manifest 데이터 * @param array> $errors 에러 누적 배열 * @return void */ private function validateIdentifierNaming(array $manifest, array &$errors): void { $identifier = (string) ($manifest['identifier'] ?? ''); $namespace = (string) ($manifest['namespace'] ?? ''); $scope = (string) ($manifest['scope'] ?? ''); $locale = (string) ($manifest['locale'] ?? ''); $target = $manifest['target_identifier'] ?? null; if (! str_starts_with($identifier, $namespace.'-')) { $errors['manifest.identifier'][] = sprintf( 'identifier "%s" 는 namespace "%s" 로 시작해야 합니다.', $identifier, $namespace ); return; } $expected = match ($scope) { LanguagePackScope::Core->value => sprintf('%s-core-%s', $namespace, $locale), LanguagePackScope::Module->value, LanguagePackScope::Plugin->value, LanguagePackScope::Template->value => sprintf('%s-%s-%s-%s', $namespace, $scope, (string) $target, $locale), default => null, }; if ($expected !== null && $identifier !== $expected) { $errors['manifest.identifier'][] = sprintf( 'identifier 는 네이밍 공식상 "%s" 여야 합니다 (현재 "%s").', $expected, $identifier ); } } /** * top-level g7_version semver 제약을 검증합니다 (모듈/플러그인/템플릿 매니페스트와 동일한 위치). * * @param array $manifest manifest 데이터 * @param array> $errors 에러 누적 배열 * @return void */ private function validateG7Version(array $manifest, array &$errors): void { $g7 = $manifest['g7_version'] ?? null; if ($g7 !== null && $g7 !== '' && ! $this->isSemverConstraint((string) $g7)) { $errors['manifest.g7_version'][] = 'g7_version 제약이 올바른 semver 형식이 아닙니다.'; } } /** * requires 섹션을 검증합니다 (target_version, depends_on_core_locale). * * g7_version 은 top-level 로 분리됨 (validateG7Version 참조) — 모듈/플러그인/템플릿 매니페스트와 일관. * * @param array $manifest manifest 데이터 * @param array> $errors 에러 누적 배열 * @return void */ private function validateRequires(array $manifest, array &$errors): void { $requires = $manifest['requires'] ?? []; if (! is_array($requires)) { $errors['manifest.requires'][] = 'requires 는 객체여야 합니다.'; return; } $target = $requires['target_version'] ?? null; if ($target !== null && $target !== '' && ! $this->isSemverConstraint((string) $target)) { $errors['manifest.requires.target_version'][] = 'target_version 제약이 올바른 semver 형식이 아닙니다.'; } } /** * Composer 스타일 semver 제약의 단순 검증. * * 정확한 Composer 파서를 호출하지는 않으며, 일반적으로 사용되는 형태(`>=`, `^`, `~`, `||`, `,`)만 허용합니다. * * @param string $constraint 검사할 제약 문자열 * @return bool 유효 여부 */ private function isSemverConstraint(string $constraint): bool { $trimmed = trim($constraint); if ($trimmed === '') { return false; } return (bool) preg_match( '/^[\^~><=!\d\.\*\-\+a-zA-Z\s\|,]+$/', $trimmed ); } }