diff --git a/.github/workflows/release_pipeline.yml b/.github/workflows/release_pipeline.yml index d83500a..733e665 100644 --- a/.github/workflows/release_pipeline.yml +++ b/.github/workflows/release_pipeline.yml @@ -643,7 +643,7 @@ else: echo "```" } >> "${GITHUB_STEP_SUMMARY}" - - name: Build Joomla ZIP (extension type aware, src-only archive) + - name: Build Joomla/Dolibarr ZIP (platform-aware, src-only archive) id: build run: | set -euo pipefail @@ -657,44 +657,49 @@ else: DIST_DIR="${GITHUB_WORKSPACE}/dist" mkdir -p "${DIST_DIR}" - MANIFEST="" - if [ -f "src/templateDetails.xml" ]; then - MANIFEST="src/templateDetails.xml" - elif find src -maxdepth 4 -type f -name 'templateDetails.xml' | head -n 1 | grep -q .; then - MANIFEST="$(find src -maxdepth 4 -type f -name 'templateDetails.xml' | head -n 1)" - elif find src -maxdepth 4 -type f -name 'pkg_*.xml' | head -n 1 | grep -q .; then - MANIFEST="$(find src -maxdepth 4 -type f -name 'pkg_*.xml' | head -n 1)" - elif find src -maxdepth 4 -type f -name 'com_*.xml' | head -n 1 | grep -q .; then - MANIFEST="$(find src -maxdepth 4 -type f -name 'com_*.xml' | head -n 1)" - elif find src -maxdepth 4 -type f -name 'mod_*.xml' | head -n 1 | grep -q .; then - MANIFEST="$(find src -maxdepth 4 -type f -name 'mod_*.xml' | head -n 1)" - elif find src -maxdepth 6 -type f -name 'plg_*.xml' | head -n 1 | grep -q .; then - MANIFEST="$(find src -maxdepth 6 -type f -name 'plg_*.xml' | head -n 1)" - else - MANIFEST="$(grep -Rsl --include='*.xml' '> "${GITHUB_STEP_SUMMARY}" + # Detect platform and extension type using Python utility + PLATFORM_INFO=$(python3 -c " +import sys +sys.path.insert(0, '${GITHUB_WORKSPACE}/scripts/lib') +import extension_utils +ext_info = extension_utils.get_extension_info('${GITHUB_WORKSPACE}/src') +if ext_info: + print(f'{ext_info.platform.value}|{ext_info.extension_type}') +else: + sys.exit(1) + ") + + if [ $? -ne 0 ]; then + echo "ERROR: Could not detect extension platform and type" >> "${GITHUB_STEP_SUMMARY}" exit 1 fi + + PLATFORM="${PLATFORM_INFO%%|*}" + EXT_TYPE="${PLATFORM_INFO##*|}" - EXT_TYPE="$(grep -Eo 'type="[^"]+"' "${MANIFEST}" | head -n 1 | cut -d '"' -f2 || true)" - if [ -z "${EXT_TYPE}" ]; then - EXT_TYPE="unknown" - fi - - ZIP="${REPO_NAME}-${VERSION}-${CHANNEL}-${EXT_TYPE}.zip" + ZIP="${REPO_NAME}-${VERSION}-${CHANNEL}-${PLATFORM}-${EXT_TYPE}.zip" + # Create ZIP with development artifact exclusions zip -r -X "${DIST_DIR}/${ZIP}" src \ -x "src/**/.git/**" \ -x "src/**/.github/**" \ -x "src/**/.DS_Store" \ - -x "src/**/__MACOSX/**" + -x "src/**/__MACOSX/**" \ + -x "src/**/node_modules/**" \ + -x "src/**/vendor/**" \ + -x "src/**/tests/**" \ + -x "src/**/Tests/**" \ + -x "src/**/.phpstan.cache/**" \ + -x "src/**/.psalm/**" \ + -x "src/**/.rector/**" \ + -x "src/**/phpmd-cache/**" \ + -x "src/**/.php-cs-fixer.cache" \ + -x "src/**/.phplint-cache" \ + -x "src/**/*.log" echo "zip_name=${ZIP}" >> "${GITHUB_OUTPUT}" echo "dist_dir=${DIST_DIR}" >> "${GITHUB_OUTPUT}" - echo "manifest=${MANIFEST}" >> "${GITHUB_OUTPUT}" + echo "platform=${PLATFORM}" >> "${GITHUB_OUTPUT}" echo "ext_type=${EXT_TYPE}" >> "${GITHUB_OUTPUT}" ZIP_BYTES="$(stat -c%s "${DIST_DIR}/${ZIP}")" @@ -702,7 +707,7 @@ else: { echo "### Build report" echo "```json" - echo "{\"repository\":\"${GITHUB_REPOSITORY}\",\"workflow\":\"${GITHUB_WORKFLOW}\",\"job\":\"${GITHUB_JOB}\",\"run/id\":${GITHUB_RUN_ID},\"run/number\":${GITHUB_RUN_NUMBER},\"run/attempt\":${GITHUB_RUN_ATTEMPT},\"run/url\":\"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}\",\"actor\":\"${GITHUB_ACTOR}\",\"sha\":\"${GITHUB_SHA}\",\"archive_policy\":\"src_only\",\"manifest\":\"${MANIFEST}\",\"extension_type\":\"${EXT_TYPE}\",\"zip\":\"${DIST_DIR}/${ZIP}\",\"zip_bytes\":${ZIP_BYTES}}" + echo "{\"repository\":\"${GITHUB_REPOSITORY}\",\"workflow\":\"${GITHUB_WORKFLOW}\",\"job\":\"${GITHUB_JOB}\",\"run/id\":${GITHUB_RUN_ID},\"run/number\":${GITHUB_RUN_NUMBER},\"run/attempt\":${GITHUB_RUN_ATTEMPT},\"run/url\":\"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}\",\"actor\":\"${GITHUB_ACTOR}\",\"sha\":\"${GITHUB_SHA}\",\"archive_policy\":\"src_only\",\"platform\":\"${PLATFORM}\",\"extension_type\":\"${EXT_TYPE}\",\"zip\":\"${DIST_DIR}/${ZIP}\",\"zip_bytes\":${ZIP_BYTES}}" echo "```" } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.gitignore b/.gitignore index 8d3812a..30f7c6b 100644 --- a/.gitignore +++ b/.gitignore @@ -793,6 +793,14 @@ package-lock.json .phpunit.result.cache codeception.phar +# Development tool artifacts +.phpstan.cache +.psalm/ +.rector/ +phpmd-cache/ +.php-cs-fixer.cache +.phplint-cache + # Python __pycache__/ *.py[cod] @@ -804,8 +812,8 @@ develop-eggs/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ diff --git a/Makefile b/Makefile index d3e95aa..b3806da 100644 --- a/Makefile +++ b/Makefile @@ -26,8 +26,12 @@ install: @command -v composer >/dev/null 2>&1 || { echo "Error: composer not found. Please install composer first."; exit 1; } composer global require "squizlabs/php_codesniffer:^3.0" --with-all-dependencies composer global require "phpstan/phpstan:^1.0" --with-all-dependencies + composer global require "phpstan/extension-installer:^1.0" --with-all-dependencies composer global require "phpcompatibility/php-compatibility:^9.0" --with-all-dependencies composer global require "codeception/codeception" --with-all-dependencies + composer global require "vimeo/psalm:^5.0" --with-all-dependencies + composer global require "phpmd/phpmd:^2.0" --with-all-dependencies + composer global require "friendsofphp/php-cs-fixer:^3.0" --with-all-dependencies phpcs --config-set installed_paths ~/.composer/vendor/phpcompatibility/php-compatibility @echo "✓ Dependencies installed" @@ -93,6 +97,38 @@ phpcompat: @command -v phpcs >/dev/null 2>&1 || { echo "Error: phpcs not found. Run 'make install' first."; exit 1; } phpcs --standard=PHPCompatibility --runtime-set testVersion 8.0- src/ +## psalm: Run Psalm static analysis +psalm: + @echo "Running Psalm static analysis..." + @command -v psalm >/dev/null 2>&1 || { echo "Error: psalm not found. Run 'make install' first."; exit 1; } + psalm --show-info=false + +## phpmd: Run PHP Mess Detector +phpmd: + @echo "Running PHP Mess Detector..." + @command -v phpmd >/dev/null 2>&1 || { echo "Error: phpmd not found. Run 'make install' first."; exit 1; } + phpmd src/ text cleancode,codesize,controversial,design,naming,unusedcode + +## php-cs-fixer: Run PHP-CS-Fixer +php-cs-fixer: + @echo "Running PHP-CS-Fixer..." + @command -v php-cs-fixer >/dev/null 2>&1 || { echo "Error: php-cs-fixer not found. Run 'make install' first."; exit 1; } + php-cs-fixer fix --dry-run --diff src/ + +## php-cs-fixer-fix: Auto-fix with PHP-CS-Fixer +php-cs-fixer-fix: + @echo "Auto-fixing with PHP-CS-Fixer..." + @command -v php-cs-fixer >/dev/null 2>&1 || { echo "Error: php-cs-fixer not found. Run 'make install' first."; exit 1; } + php-cs-fixer fix src/ + +## quality-extended: Run extended quality checks (includes psalm, phpmd) +quality-extended: + @echo "Running extended code quality checks..." + @$(MAKE) quality + @$(MAKE) psalm + @$(MAKE) phpmd + @echo "✓ All quality checks passed" + ## package: Create distribution package package: @echo "Creating distribution package..." @@ -128,6 +164,12 @@ clean: @rm -rf dist/ @rm -rf tests/_output/ @rm -rf .phpunit.cache/ + @rm -rf .phpstan.cache/ + @rm -rf .psalm/ + @rm -rf .rector/ + @rm -rf phpmd-cache/ + @find . -type f -name ".php-cs-fixer.cache" -delete + @find . -type f -name ".phplint-cache" -delete @find . -type f -name "*.log" -delete @find . -type f -name ".DS_Store" -delete @echo "✓ Cleaned" diff --git a/scripts/lib/extension_utils.py b/scripts/lib/extension_utils.py new file mode 100644 index 0000000..4a14be7 --- /dev/null +++ b/scripts/lib/extension_utils.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +""" +Extension utilities for Joomla and Dolibarr. + +Copyright (C) 2025 Moko Consulting + +This file is part of a Moko Consulting project. + +SPDX-License-Identifier: GPL-3.0-or-later + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program (./LICENSE.md). + +FILE INFORMATION +DEFGROUP: Script.Library +INGROUP: Extension.Utils +REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia +PATH: /scripts/lib/extension_utils.py +VERSION: 01.00.00 +BRIEF: Platform-aware extension utilities for Joomla and Dolibarr +""" + +import re +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Optional, Union + + +class Platform(Enum): + """Supported extension platforms.""" + JOOMLA = "joomla" + DOLIBARR = "dolibarr" + UNKNOWN = "unknown" + + +@dataclass +class ExtensionInfo: + """Extension information.""" + platform: Platform + name: str + version: str + extension_type: str + manifest_path: Path + description: Optional[str] = None + author: Optional[str] = None + author_email: Optional[str] = None + license: Optional[str] = None + + +def detect_joomla_manifest(src_dir: Union[str, Path]) -> Optional[Path]: + """ + Detect Joomla manifest file. + + Args: + src_dir: Source directory + + Returns: + Path to manifest file or None + """ + src_path = Path(src_dir) + + # Common Joomla manifest locations and patterns + manifest_patterns = [ + "templateDetails.xml", + "pkg_*.xml", + "com_*.xml", + "mod_*.xml", + "plg_*.xml", + ] + + # Search in src_dir and subdirectories (max depth 4) + for pattern in manifest_patterns: + # Direct match + matches = list(src_path.glob(pattern)) + if matches: + return matches[0] + + # Search in subdirectories + matches = list(src_path.glob(f"*/{pattern}")) + if matches: + return matches[0] + + matches = list(src_path.glob(f"*/*/{pattern}")) + if matches: + return matches[0] + + # Fallback: search for any XML with Optional[Path]: + """ + Detect Dolibarr module descriptor file. + + Args: + src_dir: Source directory + + Returns: + Path to descriptor file or None + """ + src_path = Path(src_dir) + + # Dolibarr module descriptors follow pattern: core/modules/modMyModule.class.php + descriptor_patterns = [ + "core/modules/mod*.class.php", + "*/modules/mod*.class.php", + "mod*.class.php", + ] + + for pattern in descriptor_patterns: + matches = list(src_path.glob(pattern)) + if matches: + # Verify it's actually a Dolibarr module descriptor + for match in matches: + try: + content = match.read_text(encoding="utf-8") + if "extends DolibarrModules" in content or "class Mod" in content: + return match + except Exception: + continue + + return None + + +def parse_joomla_manifest(manifest_path: Path) -> Optional[ExtensionInfo]: + """ + Parse Joomla manifest XML. + + Args: + manifest_path: Path to manifest file + + Returns: + ExtensionInfo or None + """ + try: + tree = ET.parse(manifest_path) + root = tree.getroot() + + if root.tag != "extension": + return None + + # Get extension type + ext_type = root.get("type", "unknown") + + # Get name + name_elem = root.find("name") + name = name_elem.text if name_elem is not None else "unknown" + + # Get version + version_elem = root.find("version") + version = version_elem.text if version_elem is not None else "0.0.0" + + # Get description + desc_elem = root.find("description") + description = desc_elem.text if desc_elem is not None else None + + # Get author + author_elem = root.find("author") + author = author_elem.text if author_elem is not None else None + + # Get author email + author_email_elem = root.find("authorEmail") + author_email = author_email_elem.text if author_email_elem is not None else None + + # Get license + license_elem = root.find("license") + license_text = license_elem.text if license_elem is not None else None + + return ExtensionInfo( + platform=Platform.JOOMLA, + name=name, + version=version, + extension_type=ext_type, + manifest_path=manifest_path, + description=description, + author=author, + author_email=author_email, + license=license_text + ) + + except Exception: + return None + + +def parse_dolibarr_descriptor(descriptor_path: Path) -> Optional[ExtensionInfo]: + """ + Parse Dolibarr module descriptor PHP file. + + Args: + descriptor_path: Path to descriptor file + + Returns: + ExtensionInfo or None + """ + try: + content = descriptor_path.read_text(encoding="utf-8") + + # Extract module name from class name + name_match = re.search(r'class\s+(Mod\w+)', content) + name = name_match.group(1) if name_match else "unknown" + + # Extract version + version_match = re.search(r'\$this->version\s*=\s*[\'"]([^\'"]+)[\'"]', content) + version = version_match.group(1) if version_match else "0.0.0" + + # Extract description + desc_match = re.search(r'\$this->description\s*=\s*[\'"]([^\'"]+)[\'"]', content) + description = desc_match.group(1) if desc_match else None + + # Extract author + author_match = re.search(r'\$this->editor_name\s*=\s*[\'"]([^\'"]+)[\'"]', content) + author = author_match.group(1) if author_match else None + + return ExtensionInfo( + platform=Platform.DOLIBARR, + name=name, + version=version, + extension_type="module", + manifest_path=descriptor_path, + description=description, + author=author, + author_email=None, + license=None + ) + + except Exception: + return None + + +def get_extension_info(src_dir: Union[str, Path]) -> Optional[ExtensionInfo]: + """ + Detect and parse extension information from source directory. + Supports both Joomla and Dolibarr platforms. + + Args: + src_dir: Source directory containing extension files + + Returns: + ExtensionInfo or None if not detected + """ + src_path = Path(src_dir) + + if not src_path.is_dir(): + return None + + # Try Joomla first + joomla_manifest = detect_joomla_manifest(src_path) + if joomla_manifest: + ext_info = parse_joomla_manifest(joomla_manifest) + if ext_info: + return ext_info + + # Try Dolibarr + dolibarr_descriptor = detect_dolibarr_manifest(src_path) + if dolibarr_descriptor: + ext_info = parse_dolibarr_descriptor(dolibarr_descriptor) + if ext_info: + return ext_info + + return None + + +def is_joomla_extension(src_dir: Union[str, Path]) -> bool: + """ + Check if directory contains a Joomla extension. + + Args: + src_dir: Source directory + + Returns: + True if Joomla extension detected + """ + ext_info = get_extension_info(src_dir) + return ext_info is not None and ext_info.platform == Platform.JOOMLA + + +def is_dolibarr_extension(src_dir: Union[str, Path]) -> bool: + """ + Check if directory contains a Dolibarr module. + + Args: + src_dir: Source directory + + Returns: + True if Dolibarr module detected + """ + ext_info = get_extension_info(src_dir) + return ext_info is not None and ext_info.platform == Platform.DOLIBARR + + +def main() -> None: + """Test the extension utilities.""" + import sys + sys.path.insert(0, str(Path(__file__).parent)) + import common + + common.log_section("Testing Extension Utilities") + + # Test with current directory's src + repo_root = common.repo_root() + src_dir = repo_root / "src" + + if not src_dir.is_dir(): + common.log_warn(f"Source directory not found: {src_dir}") + return + + ext_info = get_extension_info(src_dir) + + if ext_info: + common.log_success("Extension detected!") + common.log_kv("Platform", ext_info.platform.value.upper()) + common.log_kv("Name", ext_info.name) + common.log_kv("Version", ext_info.version) + common.log_kv("Type", ext_info.extension_type) + common.log_kv("Manifest", str(ext_info.manifest_path)) + if ext_info.description: + common.log_kv("Description", ext_info.description[:60] + "...") + if ext_info.author: + common.log_kv("Author", ext_info.author) + else: + common.log_error("No extension detected") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/release/package_extension.py b/scripts/release/package_extension.py index d23727a..69f075b 100755 --- a/scripts/release/package_extension.py +++ b/scripts/release/package_extension.py @@ -62,6 +62,9 @@ EXCLUDE_PATTERNS = { # Documentation (optional, can be included) # Build artifacts "dist", "build", ".phpunit.cache", + # Development tool caches and artifacts + ".phpstan.cache", ".psalm", ".rector", + "phpmd-cache", ".php-cs-fixer.cache", ".phplint-cache", # OS files ".DS_Store", "Thumbs.db", # Logs @@ -78,10 +81,11 @@ EXCLUDE_PATTERNS = { "composer.json", "composer.lock", "package.json", "package-lock.json", "phpunit.xml", "phpstan.neon", "phpcs.xml", - "codeception.yml", + "codeception.yml", "psalm.xml", ".php-cs-fixer.php", # Others "README.md", "CHANGELOG.md", "CONTRIBUTING.md", "CODE_OF_CONDUCT.md", "SECURITY.md", "GOVERNANCE.md", + "Makefile", }