.PHONY: help install start update-translation check-translation test test-sequential test-mysql test-postgres test-filter test-filter-mysql test-filter-postgres test-coverage test-coverage-filter test-tia test-tia-baseline backup-test lint-check lint-fix lint migrate migrate-fresh migrate-fresh-seed db-seed setup clean import-db docs docs-build release # Colors for output GREEN := $(shell printf '\033[0;32m') YELLOW := $(shell printf '\033[0;33m') NC := $(shell printf '\033[0m') # Docker / PHP helpers # # The stack runs in one Compose project, started from the main checkout, which # bind-mounts the repo at /app. A git worktree carries its own copy of # docker-compose.yml, so a bare `docker compose` there starts a *second* # project named after the worktree directory -- with no running containers, and # host ports already taken by the first. Pointing Compose at the main checkout # keeps every target on the one running project, and the worktree is reachable # inside it because the mount covers the whole repo. Both values fall back to # the plain main-checkout case: `/app`, and no --project-directory. COMPOSE_ROOT := $(patsubst %/.git,%,$(shell git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)) DOCKER_COMPOSE := docker compose $(if $(COMPOSE_ROOT),--project-directory $(COMPOSE_ROOT)) PHP_SERVICE := app PHP_WORKDIR := /app$(if $(COMPOSE_ROOT),$(subst $(COMPOSE_ROOT),,$(CURDIR))) # Forward AI-agent env vars (Claude Code, Cursor, Gemini, ...) into the container so # laravel/pao detects the agent and emits compact JSON output instead of verbose logs. # `-e VAR` is only added when VAR is set on the host, avoiding empty-string false positives. AGENT_ENV := $(foreach v,CLAUDECODE CLAUDE_CODE AI_AGENT CURSOR_AGENT GEMINI_CLI PAO_DISABLE,$(if $($(v)),-e $(v))) PHP_EXEC := $(DOCKER_COMPOSE) exec --user application -T $(AGENT_ENV) -w $(PHP_WORKDIR) $(PHP_SERVICE) PHP_COMPOSER := $(PHP_EXEC) composer PHP_ARTISAN := $(PHP_EXEC) php artisan # Localization: target locales for `make update-translation`, and the gitignored # file holding the Anthropic key, a single `ANTHROPIC_API_KEY=...` line. It lives # in the main checkout, which is also where a worktree's Compose project runs. LOCALES ?= fr es el zh_TW zh_CN ENV_LOCAL ?= $(if $(COMPOSE_ROOT),$(COMPOSE_ROOT),.)/.env.local # --locale is an array option and the JSON command does not split on commas: a # comma-joined value is taken as one locale name and writes lang/fr,es,el.json. LOCALE_FLAGS := $(foreach locale,$(LOCALES),--locale=$(locale)) NPM_EXEC := npm ##@ Help help: ## Display this help message @echo "$(GREEN)Available commands:$(NC)" @awk 'BEGIN {FS = ":.*##"; printf "\n"} /^[a-zA-Z_-]+:.*?##/ { printf " $(YELLOW)%-15s$(NC) %s\n", $$1, $$2 } /^##@/ { printf "\n$(GREEN)%s$(NC)\n", substr($$0, 5) } ' $(MAKEFILE_LIST) ##@ Development install: ## Install dependencies (composer + npm) $(PHP_COMPOSER) install $(PHP_ARTISAN) boost:update --no-discover --no-interaction $(NPM_EXEC) ci setup: start install $(DOCKER_COMPOSE) restart app $(MAKE) build migrate db-seed $(DOCKER_COMPOSE) restart worker start: ## Start development server (all services: php, queue, mysql, postgres) $(DOCKER_COMPOSE) up -d migrate: $(PHP_ARTISAN) migrate logs: $(DOCKER_COMPOSE) logs -f php create-bucket: ## Create S3 bucket in rustfs (usage: make create-bucket BUCKET=my-bucket) docker run --rm --network=$$(docker network ls --filter name=databasement -q | head -1) \ -e AWS_ACCESS_KEY_ID=rustfsadmin \ -e AWS_SECRET_ACCESS_KEY=rustfsadmin \ amazon/aws-cli \ --endpoint-url=http://rustfs:9000 \ s3 mb s3://$(or $(BUCKET),test-bucket) 2>/dev/null || true ##@ Testing test: ## Run all tests in parallel (default) $(PHP_ARTISAN) test --parallel test-filter: ## Run tests with filter (usage: make test-filter FILTER=DatabaseServer) $(PHP_ARTISAN) test --parallel --filter="$(FILTER)" test-coverage: ## Run tests with coverage $(PHP_ARTISAN) test --parallel --coverage test-coverage-filter: ## Run tests with coverage and filter (usage: make test-coverage-filter FILTER=FailureNotification) $(PHP_ARTISAN) test --parallel --coverage --filter="$(FILTER)" test-sequential: ## Run all tests sequentially (for debugging) $(PHP_ARTISAN) test # --- Test Impact Analysis (Pest 5) ------------------------------------------- # TIA replays cached results and re-runs only the tests affected by your working # tree, turning a ~110s suite into a ~2s replay. # # DELIBERATELY NOT wired into `make test`, the pre-commit hook, or CI. On this # codebase the recorded graph only carries test edges for 177 of 291 app/ files; # app/Livewire is almost entirely missing (2 of 70 files have edges), so edits # there replay green instead of running the tests that cover them. Verified by # deleting an authorize() call from a Livewire component: TIA reported 1487 # passed while the full suite failed. Treat TIA output as a hint, never a gate. # # `php artisan test` rejects --tia (Collision does not forward the option), so # these targets invoke vendor/bin/pest directly. test-tia: ## Fast inner-loop replay via Test Impact Analysis (NOT a substitute for `make test`) $(PHP_EXEC) vendor/bin/pest --parallel --tia test-tia-baseline: ## (Re)record the TIA baseline -- --coverage is required, without it no app/ edges are recorded $(PHP_EXEC) vendor/bin/pest --parallel --tia --coverage --fresh ##@ Code Quality lint-check: ## Check code style with Laravel Pint $(PHP_EXEC) vendor/bin/pint --test lint-fix: ## Fix code style with Laravel Pint $(PHP_EXEC) vendor/bin/pint lint: lint-fix ## Alias for lint-fix ide-helper: ## Regenerate _ide_helper_models.php (gitignored; consumed by PHPStan via scanFiles) $(PHP_EXEC) php artisan ide-helper:models --write-mixin --no-interaction phpstan: ## Run PHPStan static analysis $(PHP_EXEC) vendor/bin/phpstan analyse --memory-limit=1G pre-commit: lint-fix ide-helper phpstan test ## Run all pre-commit checks (lint, ide-helper, phpstan, tests) ##@ Assets build: ## Build production assets $(NPM_EXEC) run build dev-assets: ## Start Vite dev server only $(NPM_EXEC) run dev ##@ Documentation docs: ## Start documentation dev server (Docusaurus) cd docs && $(NPM_EXEC) install && $(NPM_EXEC) run start docs-build: ## Build documentation for production (Docusaurus) cd docs && $(NPM_EXEC) install && $(NPM_EXEC) run build ##@ Localization # Extract -> prune -> translate -> normalise -> report. Only the translate step # calls the API, and it only sends the keys a locale is missing, so re-running # after a failure costs nothing for what already landed. Sourcing .env.local with # `set -a` exports the key into that one shell, and `-e ANTHROPIC_API_KEY` (name, # no value) forwards it into the container without it reaching a command line. update-translation: ## Update lang/*.json from the code, translating new strings with AI @test -f $(ENV_LOCAL) || { echo "$(YELLOW)Missing $(ENV_LOCAL) with ANTHROPIC_API_KEY (see the Makefile comment)$(NC)"; exit 1; } $(PHP_ARTISAN) translatable:export en $(PHP_ARTISAN) translations:sync set -a; . $(ENV_LOCAL); set +a; $(DOCKER_COMPOSE) exec -e ANTHROPIC_API_KEY \ --user application -T -w $(PHP_WORKDIR) $(PHP_SERVICE) \ php artisan ai-translator:translate-json --source=en $(LOCALE_FLAGS) --non-interactive $(PHP_ARTISAN) translations:sync $(PHP_ARTISAN) translations:sync --check check-translation: ## Report locales that are out of sync with the code (no API calls) $(PHP_ARTISAN) translatable:export en $(PHP_ARTISAN) translations:sync --check ##@ Database migrate-fresh: ## Drop all tables and re-migrate $(PHP_ARTISAN) migrate:fresh migrate-fresh-seed: ## Drop all tables, re-migrate and seed $(PHP_ARTISAN) migrate:fresh --seed db-seed: ## Run database seeders $(PHP_ARTISAN) db:seed import-db: ## Import a gzipped SQL dump into local MySQL (usage: make import-db FILE=/path/to/dump.sql.gz) @if [ -z "$(FILE)" ]; then \ echo "$(YELLOW)Usage: make import-db FILE=/path/to/dump.sql.gz$(NC)"; \ exit 1; \ fi @if [ ! -f "$(FILE)" ]; then \ echo "$(YELLOW)Error: File '$(FILE)' not found$(NC)"; \ exit 1; \ fi @echo "$(GREEN)Dropping and recreating 'databasement' database...$(NC)" $(DOCKER_COMPOSE) exec -T mysql mysql -uroot -proot -e "DROP DATABASE IF EXISTS databasement; CREATE DATABASE databasement;" @echo "$(GREEN)Importing $(FILE)...$(NC)" gunzip -c "$(FILE)" | $(DOCKER_COMPOSE) exec -T mysql mysql -uroot -proot databasement @echo "$(GREEN)Import complete!$(NC)" ##@ Maintenance clean: ## Clear all caches $(PHP_ARTISAN) cache:clear $(PHP_ARTISAN) config:clear $(PHP_ARTISAN) route:clear $(PHP_ARTISAN) view:clear optimize: ## Optimize the application for production $(PHP_ARTISAN) config:cache $(PHP_ARTISAN) route:cache $(PHP_ARTISAN) view:cache ##@ Release release: ## Create a new release (usage: make release VERSION=1.0.1) @if [ -z "$(VERSION)" ]; then \ echo "$(YELLOW)Usage: make release VERSION=1.0.1$(NC)"; \ exit 1; \ fi @for skill in changelog release-issues; do \ if [ ! -f .claude/skills/$$skill/SKILL.md ]; then \ echo "$(YELLOW)Error: the /$$skill skill is not installed in .claude/skills. Run 'make install' (or php artisan boost:update) first.$(NC)"; \ exit 1; \ fi; \ done @if [ "$$(git branch --show-current)" != "main" ]; then \ echo "$(YELLOW)Error: You must be on the main branch to create a release.$(NC)"; \ exit 1; \ fi @git fetch origin main --quiet @if [ "$$(git rev-parse HEAD)" != "$$(git rev-parse origin/main)" ]; then \ echo "$(YELLOW)Error: Local main is not up to date with origin/main. Run 'git pull' first.$(NC)"; \ exit 1; \ fi @if [ -n "$$(git status --porcelain)" ]; then \ echo "$(YELLOW)Error: Working directory is not clean. Commit or stash changes first.$(NC)"; \ exit 1; \ fi @version=$(VERSION); breaking=$$(git log HEAD --not --tags --no-merges --format='%h %s' | grep -E '^[0-9a-f]+ [a-z]+\(agent\)!:'); \ if [ -n "$$breaking" ] && [ "$${version##*.}" != "0" ]; then \ echo "$(YELLOW)Error: these commits break agents, so $(VERSION) must open a new minor (x.y.0), not a patch:$(NC)"; \ echo "$$breaking"; \ exit 1; \ fi @if ! grep -q '^- `$(VERSION)`' CHANGELOG.md; then \ echo "$(GREEN)No $(VERSION) entries in CHANGELOG.md, writing them with Claude Code (/changelog $(VERSION))...$(NC)"; \ claude -p "/changelog $(VERSION)" \ --allowedTools "Bash(git *),Bash(gh *),Bash(date *),Read,Edit,Write" || exit 1; \ fi @if ! grep -q '^- `$(VERSION)`' CHANGELOG.md; then \ echo "$(YELLOW)Error: CHANGELOG.md still has no $(VERSION) entries. Run /changelog $(VERSION) in Claude Code and check its output.$(NC)"; \ exit 1; \ fi @if [ -n "$$(git status --porcelain)" ]; then \ echo "$(YELLOW)Error: the changelog entry was written but not committed. Commit and push it, then rerun.$(NC)"; \ exit 1; \ fi @git fetch origin main --quiet @if [ "$$(git rev-parse HEAD)" != "$$(git rev-parse origin/main)" ]; then \ echo "$(YELLOW)Error: the changelog commit is not on origin/main yet. Push it, then rerun.$(NC)"; \ exit 1; \ fi @echo "$(GREEN)Creating release v$(VERSION)...$(NC)" git tag v$(VERSION) git push origin v$(VERSION) @echo "$(GREEN)Release v$(VERSION) created! Workflows will build Docker images, Helm chart, and GitHub Release.$(NC)" @echo "$(GREEN)Commenting on the issues v$(VERSION) resolves (/release-issues $(VERSION))...$(NC)" @claude -p "/release-issues $(VERSION)" \ --allowedTools "Bash(git *),Bash(gh *),Read" \ || echo "$(YELLOW)Warning: the issue comments failed. The release is out; rerun /release-issues $(VERSION) in Claude Code.$(NC)"