mirror of
https://github.com/ivuorinen/branch-usage-checker.git
synced 2026-02-23 20:51:50 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a706aac668 | ||
| 2a37912685 | |||
|
|
4ac6554848 | ||
|
|
4e74236eff | ||
|
|
328d89215a | ||
| c51399580e | |||
| 9dd3b08b84 | |||
| 266068326b | |||
| d050ac3b52 | |||
|
|
af506b4488 | ||
|
|
beb28d8fdb | ||
|
|
6fbd953b30 | ||
|
|
2fa5b469a1 | ||
|
|
3ead4ff211 | ||
|
|
ed09f3e03d | ||
|
|
c31beafead | ||
|
|
1103e59c35 | ||
|
|
de9231d1cd | ||
|
|
29d11f41c7 |
44
.claude/settings.json
Normal file
44
.claude/settings.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "case \"$CLAUDE_FILE_PATH\" in */vendor/*|*/composer.lock) echo 'BLOCK: Do not edit vendor or lock files directly' >&2; exit 1;; esac"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "case \"$CLAUDE_FILE_PATH\" in *.php) vendor/bin/phpcbf --standard=phpcs.xml \"$CLAUDE_FILE_PATH\" 2>/dev/null; true;; esac",
|
||||
"timeout": 10000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(composer test:*)",
|
||||
"Bash(composer lint:*)",
|
||||
"Bash(composer lint:all:*)",
|
||||
"Bash(composer format:*)",
|
||||
"Bash(composer format:md:*)",
|
||||
"Bash(composer coverage:*)",
|
||||
"Bash(composer build:*)",
|
||||
"Bash(vendor/bin/pest:*)",
|
||||
"Bash(vendor/bin/phpcs:*)",
|
||||
"Bash(vendor/bin/phpcbf:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git status:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
16
.claude/skills/build-phar/SKILL.md
Normal file
16
.claude/skills/build-phar/SKILL.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: build-phar
|
||||
description: Build PHAR executable and run smoke test
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Build PHAR
|
||||
|
||||
Build the PHAR executable and verify it works.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run `composer test` — abort if any tests fail
|
||||
2. Run `composer build` — build PHAR to `builds/branch-usage-checker`
|
||||
3. Run `builds/branch-usage-checker --version` — verify it launches successfully
|
||||
4. Report the file size of `builds/branch-usage-checker` and confirm success
|
||||
18
.claude/skills/release-check/SKILL.md
Normal file
18
.claude/skills/release-check/SKILL.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: release-check
|
||||
description: Run full pre-release verification suite
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Pre-Release Verification
|
||||
|
||||
Run the complete verification suite before a release.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run `composer lint:all` — all linters must pass (PHPCS, EditorConfig, markdownlint)
|
||||
2. Run `composer test` — all tests must pass
|
||||
3. Run `composer build` — PHAR must build successfully
|
||||
4. Run `builds/branch-usage-checker --version` — smoke test the PHAR
|
||||
5. Run `git status` — working tree should be clean (no uncommitted changes)
|
||||
6. Report go/no-go summary with results of each step
|
||||
@@ -9,12 +9,14 @@ indent_size = 4
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.json]
|
||||
[*.xml]
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
|
||||
[*.yml]
|
||||
indent_style = space
|
||||
[*.{json,jsonc}]
|
||||
indent_size = 2
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
7
.editorconfig-checker.json
Normal file
7
.editorconfig-checker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"Exclude": [
|
||||
"\\.md$",
|
||||
"builds/",
|
||||
"vendor/"
|
||||
]
|
||||
}
|
||||
4
.github/renovate.json
vendored
4
.github/renovate.json
vendored
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["github>ivuorinen/.github:renovate-config"]
|
||||
"extends": [
|
||||
"github>ivuorinen/renovate-config"
|
||||
]
|
||||
}
|
||||
|
||||
4
.github/workflows/dependency-review.yml
vendored
4
.github/workflows/dependency-review.yml
vendored
@@ -18,6 +18,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: "Dependency Review"
|
||||
uses: actions/dependency-review-action@v4
|
||||
uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3
|
||||
|
||||
14
.github/workflows/test-and-build.yml
vendored
14
.github/workflows/test-and-build.yml
vendored
@@ -14,15 +14,15 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php: ["8.2"]
|
||||
php: ["8.4"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
ini-values: phar.readonly=0
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
|
||||
|
||||
- name: Install Composer dependencies
|
||||
uses: ramsey/composer-install@v2
|
||||
uses: ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520 # 3.1.1
|
||||
|
||||
- name: PHPUnit Testing
|
||||
run: vendor/bin/pest --coverage
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- name: Ensure the PHAR works
|
||||
run: builds/branch-usage-checker --version
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
name: Upload the PHAR artifact
|
||||
with:
|
||||
name: branch-usage-checker
|
||||
@@ -64,13 +64,13 @@ jobs:
|
||||
- "build-phar"
|
||||
if: github.event_name == 'release'
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: branch-usage-checker
|
||||
path: builds/
|
||||
|
||||
- name: Upload box.phar
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
files: builds/branch-usage-checker
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -1,6 +1,18 @@
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
/vendor
|
||||
/.idea
|
||||
/.vscode
|
||||
/.vagrant
|
||||
|
||||
# PHPUnit
|
||||
.phpunit.result.cache
|
||||
.phpunit.cache
|
||||
|
||||
# Build output
|
||||
/builds/branch-usage-checker
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
|
||||
3
.markdownlint-cli2.jsonc
Normal file
3
.markdownlint-cli2.jsonc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ignores": ["vendor/**", "node_modules/**"]
|
||||
}
|
||||
5
.markdownlint.json
Normal file
5
.markdownlint.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"line-length": { "line_length": 80 },
|
||||
"no-duplicate-heading": false,
|
||||
"required-headings": false
|
||||
}
|
||||
1
.php-version
Normal file
1
.php-version
Normal file
@@ -0,0 +1 @@
|
||||
8.4
|
||||
81
CLAUDE.md
Normal file
81
CLAUDE.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Branch Usage Checker
|
||||
|
||||
This file provides guidance to Claude Code
|
||||
(claude.ai/code) when working with code in this
|
||||
repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Branch Usage Checker is a Laravel Zero CLI tool
|
||||
that cross-references GitHub branches with
|
||||
Packagist download statistics to identify branches
|
||||
safe to delete. Built as a PHAR-distributable PHP
|
||||
application.
|
||||
|
||||
## Commands
|
||||
|
||||
- `composer install` — Install dependencies
|
||||
- `composer test` — Run tests (Pest v4)
|
||||
- `composer build` — Build PHAR executable
|
||||
to `builds/branch-usage-checker`
|
||||
- `composer lint` — Check code style (PHPCS)
|
||||
- `composer lint:phpmd` — Static analysis (PHPMD)
|
||||
- `composer lint:md` — Lint markdown files
|
||||
- `composer lint:ec` — Check EditorConfig compliance
|
||||
- `composer lint:all` — Run all linters
|
||||
- `composer format` — Auto-fix code style (PHPCBF)
|
||||
- `composer format:md` — Format Markdown tables
|
||||
- `composer x` — Run the built PHAR
|
||||
- `vendor/bin/pest --filter "test name"` — Run a
|
||||
single test
|
||||
|
||||
## Code Standards
|
||||
|
||||
- PSR-12 via PHP CodeSniffer (`phpcs.xml`),
|
||||
with `PSR12.Operators.OperatorSpacing` excluded
|
||||
- PHP 8.4 required
|
||||
- Composer normalize runs automatically on
|
||||
autoload dump
|
||||
- CaptainHook pre-commit hook runs PHPCBF
|
||||
then PHPCS on staged PHP files automatically
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a Laravel Zero console application.
|
||||
Entry point is `./application`, which bootstraps
|
||||
via `bootstrap/app.php`.
|
||||
|
||||
### Core Flow (CheckCommand)
|
||||
|
||||
`check {vendor} {package?} {months=9}` — the main
|
||||
(and only functional) command. The `vendor` argument
|
||||
accepts a combined `vendor/package` form, making
|
||||
`package` optional in that case:
|
||||
|
||||
1. Fetches package metadata from
|
||||
`packagist.org/packages/{vendor}/{package}.json`
|
||||
2. Extracts branches (versions prefixed with
|
||||
`dev-`)
|
||||
3. For each branch, fetches monthly download
|
||||
stats from Packagist over the configured
|
||||
lookback window
|
||||
4. Displays a statistics table and a suggestions
|
||||
table (branches with zero downloads)
|
||||
|
||||
### Key Directories
|
||||
|
||||
- `app/Commands/` — CLI commands
|
||||
(CheckCommand is the primary one)
|
||||
- `app/Dto/` — Spatie DataTransferObject classes
|
||||
for Packagist API responses
|
||||
- `tests/Feature/Commands/` — Feature tests
|
||||
for commands
|
||||
- `builds/` — PHAR output directory
|
||||
|
||||
### Dependencies of Note
|
||||
|
||||
- HTTP requests use `Illuminate\Http\Client\Factory`
|
||||
(Guzzle-backed), injected via the container
|
||||
- DTOs use `spatie/data-transfer-object` with
|
||||
`MapFrom` attributes for JSON field mapping
|
||||
- PHAR building configured in `box.json`
|
||||
@@ -3,14 +3,15 @@
|
||||
namespace App\Commands;
|
||||
|
||||
use App\Dto\PackagistApiPackagePayload;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
use Illuminate\Http\Client\Pool;
|
||||
use LaravelZero\Framework\Commands\Command;
|
||||
|
||||
class CheckCommand extends Command
|
||||
{
|
||||
protected $signature = 'check
|
||||
{vendor : Package vendor (required)}
|
||||
{package : Package name (required)}
|
||||
{vendor : Package vendor or vendor/package}
|
||||
{package? : Package name}
|
||||
{months=9 : How many months should we return for review (optional)}
|
||||
';
|
||||
protected $description = 'Check package branch usage';
|
||||
@@ -20,23 +21,30 @@ class CheckCommand extends Command
|
||||
private string $filter = '';
|
||||
private int $totalBranches = 0;
|
||||
|
||||
private const NAME_PATTERN = '/^[a-z0-9]([_.\-]?[a-z0-9]+)*$/';
|
||||
private const TIMEOUT_SECONDS = 10;
|
||||
private const PACKAGIST_URL = 'https://packagist.org/packages/%s/%s';
|
||||
|
||||
private HttpFactory $http;
|
||||
|
||||
/** Execute the check command. */
|
||||
public function handle(): int
|
||||
{
|
||||
$this->vendor = (string)$this->argument('vendor');
|
||||
$this->package = (string)$this->argument('package');
|
||||
$months = (int)$this->argument('months');
|
||||
$this->http = resolve(HttpFactory::class);
|
||||
|
||||
$this->info('Checking: ' . sprintf('%s/%s', $this->vendor, $this->package));
|
||||
if (!$this->resolveInput()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$months = (int) $this->argument('months');
|
||||
|
||||
$this->info(sprintf('Checking: %s/%s', $this->vendor, $this->package));
|
||||
$this->info('Months: ' . $months);
|
||||
|
||||
$payload = Http::get(
|
||||
sprintf(
|
||||
'https://packagist.org/packages/%s/%s.json',
|
||||
$this->vendor,
|
||||
$this->package
|
||||
)
|
||||
);
|
||||
|
||||
$payload = $this->fetchPackageMetadata();
|
||||
if ($payload === null) {
|
||||
return 1;
|
||||
}
|
||||
$this->filter = now()->subMonths($months)->day(1)->toDateString();
|
||||
|
||||
try {
|
||||
@@ -45,9 +53,9 @@ class CheckCommand extends Command
|
||||
|
||||
$versions = collect($pkg->versions ?? [])
|
||||
->keys()
|
||||
// Filter actual versions out.
|
||||
->filter(fn ($version) => \str_starts_with($version, 'dev-'))
|
||||
->sort();
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
$this->totalBranches = $versions->count();
|
||||
|
||||
@@ -58,48 +66,150 @@ class CheckCommand extends Command
|
||||
)
|
||||
);
|
||||
|
||||
$statistics = collect($versions)
|
||||
->mapWithKeys(fn ($branch) => $this->getStatistics($branch))
|
||||
->toArray();
|
||||
$responses = $this->http->pool(
|
||||
fn (Pool $pool) => $versions->map(
|
||||
fn ($branch) => $pool->as($branch)->timeout(self::TIMEOUT_SECONDS)->get($this->getStatsUrl($branch))
|
||||
)->toArray()
|
||||
);
|
||||
|
||||
$statistics = $this->collectBranchStats($versions, $responses);
|
||||
|
||||
$this->info('Downloaded statistics...');
|
||||
|
||||
$this->outputTable($statistics);
|
||||
$this->outputSuggestions($statistics);
|
||||
} catch (\Exception $e) {
|
||||
$this->error($e->getMessage(), $e);
|
||||
if ($this->outputTable($statistics)) {
|
||||
$this->outputSuggestions($statistics);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if ($e instanceof \TypeError) {
|
||||
throw $e;
|
||||
}
|
||||
$this->error($e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function getStatistics(string $branch): array
|
||||
/** Parse and validate vendor/package input arguments. */
|
||||
private function resolveInput(): bool
|
||||
{
|
||||
$payload = Http::get(
|
||||
$vendor = strtolower((string) $this->argument('vendor'));
|
||||
$package = $this->argument('package');
|
||||
|
||||
if (str_contains($vendor, '/')) {
|
||||
if ($package !== null) {
|
||||
$this->error(
|
||||
'Conflicting arguments: vendor/package format'
|
||||
. ' and separate package argument cannot be used together.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
[$vendor, $package] = explode('/', $vendor, 2);
|
||||
}
|
||||
|
||||
if ($package === null || $package === '') {
|
||||
$this->error('Missing package name. Usage: check vendor/package or check vendor package');
|
||||
return false;
|
||||
}
|
||||
|
||||
$package = strtolower((string) $package);
|
||||
|
||||
if (!preg_match(self::NAME_PATTERN, $vendor)) {
|
||||
$this->error("Invalid vendor name: {$vendor}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!preg_match(self::NAME_PATTERN, $package)) {
|
||||
$this->error("Invalid package name: {$package}");
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->vendor = $vendor;
|
||||
$this->package = $package;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Fetch package metadata from Packagist. */
|
||||
private function fetchPackageMetadata(): ?\Illuminate\Http\Client\Response
|
||||
{
|
||||
$payload = $this->http->timeout(self::TIMEOUT_SECONDS)->get(
|
||||
sprintf(
|
||||
'https://packagist.org/packages/%s/%s/stats/%s.json?average=monthly&from=%s',
|
||||
self::PACKAGIST_URL . '.json',
|
||||
$this->vendor,
|
||||
$this->package,
|
||||
$branch,
|
||||
$this->filter
|
||||
$this->package
|
||||
)
|
||||
);
|
||||
|
||||
$data = collect($payload->json());
|
||||
$labels = collect($data->get('labels', []))->toArray();
|
||||
$values = collect($data->get('values', []))->flatten()->toArray();
|
||||
if ($payload->failed()) {
|
||||
if ($payload->status() === 404) {
|
||||
$this->error("Package not found: {$this->vendor}/{$this->package}");
|
||||
return null;
|
||||
}
|
||||
$this->error("Failed to fetch package metadata (HTTP {$payload->status()})");
|
||||
return null;
|
||||
}
|
||||
|
||||
$labels[] = 'Total';
|
||||
$values[] = array_sum($values);
|
||||
|
||||
return [$branch => \array_combine($labels, $values)];
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function outputTable(array $statistics): void
|
||||
/** Build the Packagist stats API URL for a branch. */
|
||||
private function getStatsUrl(string $branch): string
|
||||
{
|
||||
return sprintf(
|
||||
self::PACKAGIST_URL . '/stats/%s.json?average=monthly&from=%s',
|
||||
$this->vendor,
|
||||
$this->package,
|
||||
$branch,
|
||||
$this->filter
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse pooled responses into branch download statistics. */
|
||||
private function collectBranchStats($versions, array $responses): array
|
||||
{
|
||||
$statistics = [];
|
||||
foreach ($versions as $branch) {
|
||||
$response = $responses[$branch];
|
||||
|
||||
if ($response instanceof \Throwable) {
|
||||
$this->warn("Failed to fetch stats for {$branch}, skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->warn("Failed to fetch stats for {$branch} (HTTP {$response->status()}), skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = collect($response->json());
|
||||
$labels = collect($data->get('labels', []))->toArray();
|
||||
$values = collect($data->get('values', []))->flatten()->toArray();
|
||||
|
||||
$labels[] = 'Total';
|
||||
$values[] = array_sum($values);
|
||||
|
||||
if (count($labels) !== count($values)) {
|
||||
$this->warn(sprintf(
|
||||
'Malformed stats for %s (labels: %d, values: %d), skipping.',
|
||||
$branch,
|
||||
count($labels),
|
||||
count($values)
|
||||
));
|
||||
continue;
|
||||
}
|
||||
$statistics[$branch] = \array_combine($labels, $values);
|
||||
}
|
||||
|
||||
return $statistics;
|
||||
}
|
||||
|
||||
/** Render the download statistics table. */
|
||||
private function outputTable(array $statistics): bool
|
||||
{
|
||||
if (empty($statistics)) {
|
||||
$this->info('No statistics found... Stopping.');
|
||||
exit(0);
|
||||
return false;
|
||||
}
|
||||
|
||||
$tableHeaders = ['' => 'Branch'];
|
||||
@@ -107,44 +217,41 @@ class CheckCommand extends Command
|
||||
|
||||
foreach ($statistics as $branch => $stats) {
|
||||
foreach ($stats as $m => $v) {
|
||||
$tableHeaders[$m] = (string)$m;
|
||||
$tableHeaders[$m] = (string) $m;
|
||||
$tableBranches[$branch][$branch] = $branch;
|
||||
$tableBranches[$branch][$m] = (string)$v;
|
||||
$tableBranches[$branch][$m] = (string) $v;
|
||||
}
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
$this->table($tableHeaders, $tableBranches);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function outputSuggestions(array $statistics = []): void
|
||||
/** Render suggestions for zero-download branches. */
|
||||
private function outputSuggestions(array $statistics): void
|
||||
{
|
||||
$deletable = [];
|
||||
if (empty($statistics)) {
|
||||
$this->info('No statistics to give suggestions for. Quitting...');
|
||||
exit(0);
|
||||
}
|
||||
|
||||
foreach ($statistics as $k => $values) {
|
||||
if (!empty($values['Total'])) {
|
||||
continue;
|
||||
}
|
||||
$deletable[$k] = $values['Total'];
|
||||
$deletable[] = $k;
|
||||
}
|
||||
|
||||
if (empty($deletable)) {
|
||||
$this->info('No suggestions available. Good job!');
|
||||
exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
$keys = array_keys($deletable);
|
||||
|
||||
$branches = collect($keys)->mapWithKeys(function ($branch) {
|
||||
$branches = collect($deletable)->mapWithKeys(function ($branch) {
|
||||
return [
|
||||
$branch => [
|
||||
$branch,
|
||||
sprintf(
|
||||
'https://packagist.org/packages/%s/%s#%s',
|
||||
self::PACKAGIST_URL . '#%s',
|
||||
$this->vendor,
|
||||
$this->package,
|
||||
$branch
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use LaravelZero\Framework\Commands\Command;
|
||||
|
||||
use function Termwind\{render};
|
||||
|
||||
class InspireCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The signature of the command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'inspire {name=Artisan}';
|
||||
|
||||
/**
|
||||
* The description of the command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Display an inspiring quote';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
render(
|
||||
<<<'HTML'
|
||||
<div class="py-1 ml-2">
|
||||
<div class="px-1 bg-blue-300 text-black">Laravel Zero</div>
|
||||
<em class="ml-1">Simplicity is the ultimate sophistication.</em>
|
||||
</div>
|
||||
HTML
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the command's schedule.
|
||||
*
|
||||
* @param \Illuminate\Console\Scheduling\Schedule $schedule
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function schedule(Schedule $schedule)
|
||||
{
|
||||
// $schedule->command(static::class)->everyMinute();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Dto;
|
||||
|
||||
use Spatie\DataTransferObject\DataTransferObject;
|
||||
|
||||
class GitHubApiBranch extends DataTransferObject
|
||||
{
|
||||
public string $name;
|
||||
public bool $protected;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Dto;
|
||||
|
||||
use Spatie\DataTransferObject\Attributes\MapFrom;
|
||||
|
||||
class PackagistApiStatsPayload extends \Spatie\DataTransferObject\DataTransferObject
|
||||
{
|
||||
public array $labels;
|
||||
#[MapFrom('values.[0]')]
|
||||
public string $version;
|
||||
#[MapFrom('values.[0][]')]
|
||||
public array $values;
|
||||
public string $average = 'monthly';
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Fetchers;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class GitHubRestApi
|
||||
{
|
||||
public static function getBranches(string $vendor, string $package): array
|
||||
{
|
||||
$pages = self::downloader($vendor, $package);
|
||||
$pages = \collect($pages)
|
||||
->flatten(1)
|
||||
->toArray();
|
||||
|
||||
return $pages;
|
||||
}
|
||||
|
||||
public static function downloader($vendor, $package): array
|
||||
{
|
||||
$responses = [];
|
||||
|
||||
$continue = true;
|
||||
$page = 1;
|
||||
$gh_api = sprintf(
|
||||
'https://api.github.com/repos/%s/%s/branches?per_page=100',
|
||||
$vendor,
|
||||
$package
|
||||
);
|
||||
|
||||
while ($continue) {
|
||||
$response = Http::get($gh_api . '&page=' . $page);
|
||||
|
||||
if (empty($response)) {
|
||||
$continue = false;
|
||||
}
|
||||
|
||||
$responses[$page] = $response;
|
||||
$page++;
|
||||
}
|
||||
|
||||
return $responses;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
51
captainhook.json
Normal file
51
captainhook.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"pre-commit": {
|
||||
"enabled": true,
|
||||
"actions": [
|
||||
{
|
||||
"action": "vendor/bin/phpcbf --standard=phpcs.xml {$STAGED_FILES|of-type:php}",
|
||||
"config": {
|
||||
"label": "Fix code style with PHPCBF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "vendor/bin/phpcs --standard=phpcs.xml {$STAGED_FILES|of-type:php}",
|
||||
"config": {
|
||||
"label": "Check code style with PHPCS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "vendor/bin/phpmd {$STAGED_FILES|of-type:php|separated-by:,} text phpmd.xml",
|
||||
"conditions": [
|
||||
{
|
||||
"exec": "\\CaptainHook\\App\\Hook\\Condition\\FileStaged\\OfType",
|
||||
"args": ["php"]
|
||||
}
|
||||
],
|
||||
"config": {
|
||||
"label": "Check code quality with PHPMD"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "npx editorconfig-checker {$STAGED_FILES}",
|
||||
"config": {
|
||||
"label": "Check EditorConfig compliance"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "npx markdownlint-cli2 {$STAGED_FILES|of-type:md}",
|
||||
"config": {
|
||||
"label": "Lint Markdown files"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"pre-push": {
|
||||
"enabled": false,
|
||||
"actions": []
|
||||
},
|
||||
"commit-msg": {
|
||||
"enabled": false,
|
||||
"actions": []
|
||||
}
|
||||
}
|
||||
@@ -22,27 +22,29 @@
|
||||
"source": "https://github.com/ivuorinen/branch-usage-checker"
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"guzzlehttp/guzzle": "^7",
|
||||
"illuminate/http": "^11",
|
||||
"laravel-zero/phar-updater": "^1.2",
|
||||
"nunomaduro/termwind": "^2",
|
||||
"php": "^8.4",
|
||||
"illuminate/http": "^12.17",
|
||||
"spatie/data-transfer-object": "^3.7"
|
||||
},
|
||||
"require-dev": {
|
||||
"captainhook/captainhook": "^5.28",
|
||||
"captainhook/hook-installer": "^1.0",
|
||||
"ergebnis/composer-normalize": "^2",
|
||||
"laravel-zero/framework": "^11",
|
||||
"laravel-zero/framework": "^12",
|
||||
"mockery/mockery": "^1",
|
||||
"pestphp/pest": "^2",
|
||||
"roave/security-advisories": "dev-latest"
|
||||
"pestphp/pest": "^4",
|
||||
"phpmd/phpmd": "^2.15",
|
||||
"roave/security-advisories": "dev-latest",
|
||||
"squizlabs/php_codesniffer": "^4.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-pcov": "Required for code coverage reporting (composer coverage)"
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true,
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
"App\\": "app/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
@@ -55,14 +57,11 @@
|
||||
],
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": true,
|
||||
"captainhook/hook-installer": true,
|
||||
"ergebnis/composer-normalize": true,
|
||||
"pestphp/pest-plugin": true
|
||||
},
|
||||
"optimize-autoloader": true,
|
||||
"platform": {
|
||||
"php": "8.2"
|
||||
},
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true
|
||||
},
|
||||
@@ -72,8 +71,25 @@
|
||||
],
|
||||
"build": [
|
||||
"cp application application.phar",
|
||||
"@php application app:build branch-usage-checker"
|
||||
"@php application app:build branch-usage-checker",
|
||||
"rm -f application.phar || true"
|
||||
],
|
||||
"coverage": "php -d pcov.enabled=1 vendor/bin/pest --coverage",
|
||||
"format": "vendor/bin/phpcbf || true",
|
||||
"format:md": "npx markdown-table-formatter *.md docs/**/*.md",
|
||||
"lint": "vendor/bin/phpcs",
|
||||
"lint:all": [
|
||||
"@lint",
|
||||
"@lint:phpmd",
|
||||
"@lint:md",
|
||||
"@lint:ec"
|
||||
],
|
||||
"lint:ec": "npx editorconfig-checker",
|
||||
"lint:md": [
|
||||
"npx markdownlint-cli2 '**/*.md' '#vendor' '#node_modules'",
|
||||
"npx markdown-table-formatter --check *.md docs/**/*.md"
|
||||
],
|
||||
"lint:phpmd": "vendor/bin/phpmd app,tests text phpmd.xml",
|
||||
"test": "vendor/bin/pest",
|
||||
"x": "@php builds/branch-usage-checker"
|
||||
}
|
||||
|
||||
6025
composer.lock
generated
6025
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -53,7 +53,6 @@ return [
|
||||
* below a list of commands that you don't to see in your app.
|
||||
*/
|
||||
'remove' => [
|
||||
\App\Commands\InspireCommand::class,
|
||||
Illuminate\Console\Scheduling\ScheduleRunCommand::class,
|
||||
Illuminate\Console\Scheduling\ScheduleListCommand::class,
|
||||
Illuminate\Console\Scheduling\ScheduleFinishCommand::class,
|
||||
|
||||
57
docs/plans/2026-02-23-fix-sonar-duplications-design.md
Normal file
57
docs/plans/2026-02-23-fix-sonar-duplications-design.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Fix SonarCloud Duplication Quality Gate
|
||||
|
||||
## Problem
|
||||
|
||||
PR #43 fails the Sonar Way quality gate: `new_duplicated_lines_density` is
|
||||
7.5% (threshold: <=3%). SonarCloud detects 2 duplicated blocks / 28 lines,
|
||||
all within `tests/Feature/Commands/CheckCommandTest.php`.
|
||||
|
||||
The duplication is between 4 structurally identical input-validation tests
|
||||
(lines 71-93) that each follow the same 3-line pattern:
|
||||
|
||||
```php
|
||||
$this->artisan('<args>')
|
||||
->expectsOutputToContain('<message>')
|
||||
->assertExitCode(1);
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
Replace the 4 separate tests with one Pest parameterized test using a named
|
||||
`with()` dataset:
|
||||
|
||||
```php
|
||||
test('rejects invalid input', function (
|
||||
string $args,
|
||||
string $expected,
|
||||
) {
|
||||
$this->artisan($args)
|
||||
->expectsOutputToContain($expected)
|
||||
->assertExitCode(1);
|
||||
})->with([
|
||||
'missing package' => [
|
||||
'check ivuorinen',
|
||||
'Missing package name',
|
||||
],
|
||||
'conflicting arguments' => [
|
||||
'check ivuorinen/branch-usage-checker extra',
|
||||
'Conflicting arguments',
|
||||
],
|
||||
'invalid vendor' => [
|
||||
'check INVALID!/package-name',
|
||||
'Invalid vendor name',
|
||||
],
|
||||
'invalid package' => [
|
||||
'check valid-vendor INVALID!',
|
||||
'Invalid package name',
|
||||
],
|
||||
]);
|
||||
```
|
||||
|
||||
## Impact
|
||||
|
||||
- **File:** `tests/Feature/Commands/CheckCommandTest.php`
|
||||
- **Lines removed:** ~24 (4 test blocks)
|
||||
- **Lines added:** ~10 (1 parameterized test)
|
||||
- **Test count:** Stays at 14 (Pest expands each dataset row)
|
||||
- **Expected duplication:** 0% on new code
|
||||
135
docs/plans/2026-02-23-fix-sonar-duplications-plan.md
Normal file
135
docs/plans/2026-02-23-fix-sonar-duplications-plan.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Fix SonarCloud Duplication Quality Gate — Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use
|
||||
> superpowers:executing-plans to implement this
|
||||
> plan task-by-task.
|
||||
|
||||
**Goal:** Eliminate test code duplication that
|
||||
fails the Sonar Way quality gate on PR #43.
|
||||
|
||||
**Architecture:** Replace 4 structurally identical
|
||||
input-validation tests with one Pest parameterized
|
||||
test using a named `with()` dataset. No production
|
||||
code changes.
|
||||
|
||||
**Tech Stack:** Pest v4, PHP 8.4
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Replace validation tests with dataset
|
||||
|
||||
Files to modify:
|
||||
`tests/Feature/Commands/CheckCommandTest.php:71-93`
|
||||
|
||||
### Step 1: Replace the 4 test blocks
|
||||
|
||||
Replace these 4 tests (lines 71-93):
|
||||
|
||||
```php
|
||||
test('check command with missing package shows error',
|
||||
function () {
|
||||
$this->artisan('check ivuorinen')
|
||||
->expectsOutputToContain('Missing package name')
|
||||
->assertExitCode(1);
|
||||
});
|
||||
|
||||
test('check command with conflicting arguments shows error',
|
||||
function () {
|
||||
$this->artisan(
|
||||
'check ivuorinen/branch-usage-checker extra'
|
||||
)
|
||||
->expectsOutputToContain(
|
||||
'Conflicting arguments'
|
||||
)
|
||||
->assertExitCode(1);
|
||||
});
|
||||
|
||||
test('check command with invalid vendor shows error',
|
||||
function () {
|
||||
$this->artisan('check INVALID!/package-name')
|
||||
->expectsOutputToContain('Invalid vendor name')
|
||||
->assertExitCode(1);
|
||||
});
|
||||
|
||||
test('check command with invalid package name shows error',
|
||||
function () {
|
||||
$this->artisan('check valid-vendor INVALID!')
|
||||
->expectsOutputToContain(
|
||||
'Invalid package name'
|
||||
)
|
||||
->assertExitCode(1);
|
||||
});
|
||||
```
|
||||
|
||||
With one parameterized test:
|
||||
|
||||
```php
|
||||
test('check command rejects invalid input',
|
||||
function (string $args, string $expected) {
|
||||
$this->artisan($args)
|
||||
->expectsOutputToContain($expected)
|
||||
->assertExitCode(1);
|
||||
})->with([
|
||||
'missing package' => [
|
||||
'check ivuorinen',
|
||||
'Missing package name',
|
||||
],
|
||||
'conflicting arguments' => [
|
||||
'check ivuorinen/branch-usage-checker extra',
|
||||
'Conflicting arguments',
|
||||
],
|
||||
'invalid vendor' => [
|
||||
'check INVALID!/package-name',
|
||||
'Invalid vendor name',
|
||||
],
|
||||
'invalid package' => [
|
||||
'check valid-vendor INVALID!',
|
||||
'Invalid package name',
|
||||
],
|
||||
]);
|
||||
```
|
||||
|
||||
### Step 2: Run tests to verify all pass
|
||||
|
||||
Run: `composer test`
|
||||
|
||||
Expected: 14 passed (Pest expands dataset rows
|
||||
into individual test runs, so `with missing
|
||||
package`, `with conflicting arguments`, etc. each
|
||||
appear separately).
|
||||
|
||||
### Step 3: Run linter
|
||||
|
||||
Run: `composer lint`
|
||||
|
||||
Expected: Clean (no PHPCS errors).
|
||||
|
||||
### Step 4: Commit
|
||||
|
||||
```bash
|
||||
git add tests/Feature/Commands/CheckCommandTest.php
|
||||
git commit -m "refactor(tests): parameterize \
|
||||
input-validation tests to fix duplication gate"
|
||||
```
|
||||
|
||||
## Task 2: Push and verify quality gate
|
||||
|
||||
### Step 1: Push
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
|
||||
### Step 2: Verify on SonarCloud
|
||||
|
||||
Check the quality gate status via API:
|
||||
|
||||
```bash
|
||||
curl -s 'https://sonarcloud.io/api/qualitygates/\
|
||||
project_status?projectKey=\
|
||||
ivuorinen_branch-usage-checker&pullRequest=43' \
|
||||
| python3 -m json.tool
|
||||
```
|
||||
|
||||
Expected: `new_duplicated_lines_density` condition
|
||||
status changes from `ERROR` to `OK`.
|
||||
49
docs/plans/2026-02-23-phpcs-captainhook-design.md
Normal file
49
docs/plans/2026-02-23-phpcs-captainhook-design.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# PHPCS + PHPCBF + CaptainHook Integration
|
||||
|
||||
Date: 2026-02-23
|
||||
|
||||
## Goal
|
||||
|
||||
Add local code formatting enforcement via PHPCS/PHPCBF
|
||||
with automatic pre-commit hooks managed by CaptainHook.
|
||||
|
||||
## New Dependencies
|
||||
|
||||
- `squizlabs/php_codesniffer` (dev) — linter (`phpcs`)
|
||||
and auto-fixer (`phpcbf`)
|
||||
- `captainhook/captainhook` (dev) — git hook manager
|
||||
- `captainhook/hook-installer` (dev) — Composer plugin
|
||||
that auto-installs hooks on `composer install`
|
||||
|
||||
## Composer Scripts
|
||||
|
||||
- `composer lint` — runs `phpcs` to report violations
|
||||
- `composer format` — runs `phpcbf` to auto-fix
|
||||
|
||||
## Config Files
|
||||
|
||||
### phpcs.xml (existing, unchanged)
|
||||
|
||||
PSR-12 with two exclusions:
|
||||
|
||||
- `PSR12.Operators.OperatorSpacing`
|
||||
- `PSR1.Files.SideEffects.FoundWithSymbols`
|
||||
|
||||
### captainhook.json (new)
|
||||
|
||||
Pre-commit hook that runs `phpcbf` on staged PHP files.
|
||||
If unfixable issues remain, the commit is blocked.
|
||||
|
||||
## Pre-commit Hook Behavior
|
||||
|
||||
1. Developer commits
|
||||
2. CaptainHook triggers pre-commit
|
||||
3. Runs `phpcbf` on staged PHP files
|
||||
4. If all issues auto-fixed, commit proceeds
|
||||
5. If unfixable issues remain, commit blocked
|
||||
|
||||
## What Does NOT Change
|
||||
|
||||
- `phpcs.xml` rules stay the same
|
||||
- CI workflow unchanged (Codacy handles remote checks)
|
||||
- No functional code changes
|
||||
340
docs/plans/2026-02-23-phpcs-captainhook-plan.md
Normal file
340
docs/plans/2026-02-23-phpcs-captainhook-plan.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# PHPCS + CaptainHook Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use
|
||||
> superpowers:executing-plans to implement this plan
|
||||
> task-by-task.
|
||||
|
||||
**Goal:** Add PHPCS/PHPCBF as a dev dependency with
|
||||
composer scripts and CaptainHook-managed pre-commit
|
||||
hook for automatic formatting.
|
||||
|
||||
**Architecture:** Install three dev packages
|
||||
(`squizlabs/php_codesniffer`, `captainhook/captainhook`,
|
||||
`captainhook/hook-installer`). Add `composer lint` and
|
||||
`composer format` scripts. Configure CaptainHook to run
|
||||
PHPCBF on staged PHP files before each commit. The
|
||||
existing `phpcs.xml` is unchanged.
|
||||
|
||||
**Tech Stack:** PHP 8.4, Composer, PHPCS/PHPCBF,
|
||||
CaptainHook
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Install squizlabs/php\_codesniffer
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `composer.json` (auto-updated by composer)
|
||||
- Modify: `composer.lock` (auto-updated by composer)
|
||||
|
||||
### Step 1: Install the package
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
composer require --dev squizlabs/php_codesniffer
|
||||
```
|
||||
|
||||
Expected: Package installs successfully.
|
||||
`composer.json` now lists `squizlabs/php_codesniffer`
|
||||
in `require-dev`.
|
||||
|
||||
### Step 2: Verify phpcs works with existing config
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
vendor/bin/phpcs --standard=phpcs.xml app/ tests/
|
||||
```
|
||||
|
||||
Expected: Either clean output or a list of violations.
|
||||
The command should not error out — it should find and
|
||||
use `phpcs.xml`.
|
||||
|
||||
### Step 3: Verify phpcbf works
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
vendor/bin/phpcbf --standard=phpcs.xml app/ tests/
|
||||
```
|
||||
|
||||
Note: `phpcbf` exits non-zero when it finds fixable
|
||||
issues. The `|| true` prevents that from stopping
|
||||
execution. What matters is it runs without crashing.
|
||||
|
||||
### Step 4: Commit
|
||||
|
||||
```bash
|
||||
git add composer.json composer.lock
|
||||
git commit -m "build(deps): add squizlabs/php_codesniffer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add composer lint and format scripts
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `composer.json` — add two entries to
|
||||
`"scripts"`
|
||||
|
||||
### Step 1: Add the scripts
|
||||
|
||||
In `composer.json`, add these two entries inside the
|
||||
`"scripts"` object:
|
||||
|
||||
```json
|
||||
"lint": "vendor/bin/phpcs",
|
||||
"format": "vendor/bin/phpcbf || true"
|
||||
```
|
||||
|
||||
Note: `phpcbf` returns exit code 1 when it fixes files
|
||||
(which is normal behavior, not an error). The `|| true`
|
||||
prevents composer from treating successful fixes as
|
||||
failures. If there are unfixable errors, `phpcbf`
|
||||
returns exit code 2, but `|| true` masks that too —
|
||||
this is acceptable since `composer lint` is the proper
|
||||
check command.
|
||||
|
||||
### Step 2: Verify `composer lint` works
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
composer lint
|
||||
```
|
||||
|
||||
Expected: Runs `phpcs` against the project. Either
|
||||
reports violations or shows no output (clean).
|
||||
|
||||
### Step 3: Verify `composer format` works
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
composer format
|
||||
```
|
||||
|
||||
Expected: Runs `phpcbf`. Auto-fixes any fixable
|
||||
violations.
|
||||
|
||||
### Step 4: Run tests to confirm nothing broke
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
composer test
|
||||
```
|
||||
|
||||
Expected: All 14 tests pass.
|
||||
|
||||
### Step 5: Commit
|
||||
|
||||
```bash
|
||||
git add composer.json
|
||||
git commit -m "build: add composer lint and format scripts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Install CaptainHook
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `composer.json` (auto-updated by composer)
|
||||
- Modify: `composer.lock` (auto-updated by composer)
|
||||
|
||||
**Step 1: Install captainhook and the hook-installer
|
||||
plugin**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
composer require --dev captainhook/captainhook captainhook/hook-installer
|
||||
```
|
||||
|
||||
Note: The installer may prompt about allowing the
|
||||
plugin. Answer yes. If `composer.json`'s
|
||||
`config.allow-plugins` needs updating, composer will
|
||||
do it automatically when you approve.
|
||||
|
||||
### Step 2: Verify CaptainHook is available
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
vendor/bin/captainhook --version
|
||||
```
|
||||
|
||||
Expected: Prints a version string
|
||||
(e.g. `CaptainHook x.x.x`).
|
||||
|
||||
### Step 3: Commit
|
||||
|
||||
```bash
|
||||
git add composer.json composer.lock
|
||||
git commit -m "build(deps): add captainhook and hook-installer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Configure CaptainHook pre-commit hook
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `captainhook.json`
|
||||
|
||||
### Step 1: Create the CaptainHook config
|
||||
|
||||
Create `captainhook.json` in the project root with
|
||||
this content:
|
||||
|
||||
```json
|
||||
{
|
||||
"pre-commit": {
|
||||
"enabled": true,
|
||||
"actions": [
|
||||
{
|
||||
"action": "vendor/bin/phpcbf --standard=phpcs.xml {$STAGED_FILES|of-type:php}",
|
||||
"config": {
|
||||
"label": "Fix code style with PHPCBF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "vendor/bin/phpcs --standard=phpcs.xml {$STAGED_FILES|of-type:php}",
|
||||
"config": {
|
||||
"label": "Check code style with PHPCS"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"pre-push": {
|
||||
"enabled": false,
|
||||
"actions": []
|
||||
},
|
||||
"commit-msg": {
|
||||
"enabled": false,
|
||||
"actions": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The two pre-commit actions run in order:
|
||||
|
||||
1. `phpcbf` auto-fixes what it can
|
||||
2. `phpcs` checks for remaining violations — if any
|
||||
exist, the commit is blocked
|
||||
|
||||
### Step 2: Install the hooks into `.git/hooks`
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
vendor/bin/captainhook install --force
|
||||
```
|
||||
|
||||
Expected: CaptainHook installs hook scripts into
|
||||
`.git/hooks/`. Output mentions installing hooks.
|
||||
|
||||
### Step 3: Verify the hook is installed
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
head -5 .git/hooks/pre-commit
|
||||
```
|
||||
|
||||
Expected: Shows a CaptainHook-generated script
|
||||
(not a sample hook).
|
||||
|
||||
### Step 4: Commit
|
||||
|
||||
```bash
|
||||
git add captainhook.json
|
||||
git commit -m "build: configure CaptainHook pre-commit hook for PHPCS"
|
||||
```
|
||||
|
||||
Note: This commit itself will trigger the pre-commit
|
||||
hook for the first time. If it blocks due to formatting
|
||||
issues in existing files, run `composer format` first,
|
||||
then re-stage and commit.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Fix existing violations and final verify
|
||||
|
||||
### Step 1: Run the formatter on the whole project
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
composer format
|
||||
```
|
||||
|
||||
Expected: Fixes any existing violations across `app/`
|
||||
and `tests/`.
|
||||
|
||||
### Step 2: Run the linter to confirm clean
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
composer lint
|
||||
```
|
||||
|
||||
Expected: No violations reported (clean exit).
|
||||
|
||||
### Step 3: Run tests to confirm nothing broke
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
composer test
|
||||
```
|
||||
|
||||
Expected: All 14 tests pass.
|
||||
|
||||
### Step 4: Commit any formatting changes
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git status
|
||||
git commit -m "style: auto-fix code style with phpcbf"
|
||||
```
|
||||
|
||||
Note: Only commit if there are actual changes. If
|
||||
`composer format` made no changes, skip this step.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Update CLAUDE.md
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `CLAUDE.md` — add `composer lint` and
|
||||
`composer format` to Commands section, note
|
||||
CaptainHook in Code Standards
|
||||
|
||||
### Step 1: Update CLAUDE.md
|
||||
|
||||
In the `## Commands` section, add:
|
||||
|
||||
```markdown
|
||||
- `composer lint` — Check code style (PHPCS)
|
||||
- `composer format` — Auto-fix code style (PHPCBF)
|
||||
```
|
||||
|
||||
In the `## Code Standards` section, add a note:
|
||||
|
||||
```markdown
|
||||
- CaptainHook pre-commit hook runs PHPCBF then
|
||||
PHPCS on staged PHP files automatically
|
||||
```
|
||||
|
||||
### Step 2: Commit
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: add lint/format commands and hook info to CLAUDE.md"
|
||||
```
|
||||
@@ -1,6 +1,8 @@
|
||||
<?xml version="1.0"?>
|
||||
<ruleset name="PHP_CodeSniffer">
|
||||
<description>PHP_CodeSniffer configuration</description>
|
||||
<file>app</file>
|
||||
<file>tests</file>
|
||||
<rule ref="PSR12">
|
||||
<exclude name="PSR12.Operators.OperatorSpacing"/>
|
||||
<exclude name="PSR1.Files.SideEffects.FoundWithSymbols"/>
|
||||
|
||||
24
phpmd.xml
Normal file
24
phpmd.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0"?>
|
||||
<ruleset name="BranchUsageChecker"
|
||||
xmlns="http://pmd.sf.net/ruleset/1.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://pmd.sf.net/ruleset/1.0.0 http://pmd.sf.net/ruleset_xml_schema.xsd"
|
||||
xsi:noNamespaceSchemaLocation="http://pmd.sf.net/ruleset_xml_schema.xsd">
|
||||
<description>PHPMD rules for Branch Usage Checker</description>
|
||||
|
||||
<rule ref="rulesets/cleancode.xml">
|
||||
<!-- Laravel uses facades and static calls extensively -->
|
||||
<exclude name="StaticAccess"/>
|
||||
</rule>
|
||||
|
||||
<rule ref="rulesets/codesize.xml"/>
|
||||
|
||||
<rule ref="rulesets/unusedcode.xml"/>
|
||||
|
||||
<!-- Allow short variables common in loops and closures -->
|
||||
<rule ref="rulesets/naming.xml/ShortVariable">
|
||||
<properties>
|
||||
<property name="exceptions" value="e,k,v,m,id"/>
|
||||
</properties>
|
||||
</rule>
|
||||
</ruleset>
|
||||
@@ -1,7 +1,165 @@
|
||||
<?php
|
||||
|
||||
test('check command', function () {
|
||||
$this->artisan('check ivuorinen branch-usage-checker')
|
||||
// ->expectsOutput('')
|
||||
->assertExitCode(0);
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
const TEST_VENDOR = 'test-vendor';
|
||||
const TEST_PACKAGE = 'test-package';
|
||||
const TEST_COMMAND = 'check ' . TEST_VENDOR . ' ' . TEST_PACKAGE;
|
||||
const TEST_METADATA_URL = 'packagist.org/packages/' . TEST_VENDOR . '/' . TEST_PACKAGE . '.json';
|
||||
const TEST_STATS_URL = 'packagist.org/packages/' . TEST_VENDOR . '/' . TEST_PACKAGE . '/stats';
|
||||
|
||||
beforeEach(function () {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
function validMetadata(): array
|
||||
{
|
||||
return [
|
||||
'package' => [
|
||||
'name' => TEST_VENDOR . '/' . TEST_PACKAGE,
|
||||
'description' => 'Test',
|
||||
'time' => '2024-01-01T00:00:00+00:00',
|
||||
'type' => 'library',
|
||||
'repository' => 'https://github.com/' . TEST_VENDOR . '/' . TEST_PACKAGE,
|
||||
'language' => 'PHP',
|
||||
'versions' => [
|
||||
'dev-main' => ['version' => 'dev-main'],
|
||||
'dev-feature' => ['version' => 'dev-feature'],
|
||||
'1.0.0' => ['version' => '1.0.0'],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function statsResponse(array $downloads): array
|
||||
{
|
||||
return [
|
||||
'labels' => ['2024-01', '2024-02', '2024-03'],
|
||||
'values' => [$downloads],
|
||||
];
|
||||
}
|
||||
|
||||
function fakePackageResponses(array $statsPerBranch = []): void
|
||||
{
|
||||
$fakes = [TEST_METADATA_URL => Http::response(validMetadata())];
|
||||
foreach ($statsPerBranch as $branch => $response) {
|
||||
$fakes[TEST_STATS_URL . '/' . $branch . '.json*'] = $response;
|
||||
}
|
||||
Http::fake($fakes);
|
||||
}
|
||||
|
||||
test('check command with slash format', function () {
|
||||
fakePackageResponses([
|
||||
'dev-feature' => Http::response(statsResponse([1, 2, 3])),
|
||||
'dev-main' => Http::response(statsResponse([1, 2, 3])),
|
||||
]);
|
||||
|
||||
$this->artisan('check ' . TEST_VENDOR . '/' . TEST_PACKAGE)
|
||||
->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('check command with two arguments', function () {
|
||||
fakePackageResponses([
|
||||
'dev-feature' => Http::response(statsResponse([1, 2, 3])),
|
||||
'dev-main' => Http::response(statsResponse([1, 2, 3])),
|
||||
]);
|
||||
|
||||
$this->artisan('check ' . TEST_VENDOR . ' ' . TEST_PACKAGE)
|
||||
->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('check command rejects invalid input', function (string $args, string $expected) {
|
||||
$this->artisan($args)
|
||||
->expectsOutputToContain($expected)
|
||||
->assertExitCode(1);
|
||||
})->with([
|
||||
'missing package' => ['check ivuorinen', 'Missing package name'],
|
||||
'conflicting arguments' => ['check ivuorinen/branch-usage-checker extra', 'Conflicting arguments'],
|
||||
'invalid vendor' => ['check INVALID!/package-name', 'Invalid vendor name'],
|
||||
'invalid package' => ['check valid-vendor INVALID!', 'Invalid package name'],
|
||||
]);
|
||||
|
||||
test('check command with 404 shows package not found', function () {
|
||||
Http::fake([
|
||||
'packagist.org/packages/test-vendor/nonexistent-pkg.json' => Http::response([], 404),
|
||||
]);
|
||||
|
||||
$this->artisan('check test-vendor nonexistent-pkg')
|
||||
->expectsOutputToContain('Package not found')
|
||||
->assertExitCode(1);
|
||||
});
|
||||
|
||||
test('check command with 500 shows server error', function () {
|
||||
Http::fake([
|
||||
TEST_METADATA_URL => Http::response([], 500),
|
||||
]);
|
||||
|
||||
$this->artisan(TEST_COMMAND)
|
||||
->expectsOutputToContain('Failed to fetch package metadata (HTTP 500)')
|
||||
->assertExitCode(1);
|
||||
});
|
||||
|
||||
test('check command skips branch when stats fetch fails', function () {
|
||||
fakePackageResponses([
|
||||
'dev-feature' => Http::response([], 500),
|
||||
'dev-main' => Http::response(statsResponse([10, 20, 30])),
|
||||
]);
|
||||
|
||||
$this->artisan(TEST_COMMAND)
|
||||
->expectsOutputToContain('Failed to fetch stats for dev-feature')
|
||||
->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('check command skips branch on connection failure', function () {
|
||||
fakePackageResponses([
|
||||
'dev-feature' => Http::failedConnection(),
|
||||
'dev-main' => Http::response(statsResponse([10, 20, 30])),
|
||||
]);
|
||||
|
||||
$this->artisan(TEST_COMMAND)
|
||||
->expectsOutputToContain('Failed to fetch stats for dev-feature')
|
||||
->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('check command stops when all stats fail', function () {
|
||||
fakePackageResponses([
|
||||
'dev-feature' => Http::response([], 500),
|
||||
'dev-main' => Http::response([], 500),
|
||||
]);
|
||||
|
||||
$this->artisan(TEST_COMMAND)
|
||||
->expectsOutputToContain('No statistics found... Stopping.')
|
||||
->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('check command lets TypeError propagate from malformed payload', function () {
|
||||
Http::fake([
|
||||
TEST_METADATA_URL => Http::response([
|
||||
'package' => ['versions' => 'not-an-array'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->artisan(TEST_COMMAND);
|
||||
})->throws(\TypeError::class);
|
||||
|
||||
test('check command shows no suggestions when all branches have downloads', function () {
|
||||
fakePackageResponses([
|
||||
'dev-main' => Http::response(statsResponse([10, 20, 30])),
|
||||
'dev-feature' => Http::response(statsResponse([5, 10, 15])),
|
||||
]);
|
||||
|
||||
$this->artisan(TEST_COMMAND)
|
||||
->expectsOutputToContain('No suggestions available. Good job!')
|
||||
->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('check command suggests branches with zero downloads', function () {
|
||||
fakePackageResponses([
|
||||
'dev-main' => Http::response(statsResponse([10, 20, 30])),
|
||||
'dev-feature' => Http::response(statsResponse([0, 0, 0])),
|
||||
]);
|
||||
|
||||
$this->artisan(TEST_COMMAND)
|
||||
->expectsOutputToContain('Found 1 branches')
|
||||
->assertExitCode(0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user