mirror of
https://github.com/ivuorinen/branch-usage-checker.git
synced 2026-02-23 17:51:44 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
[*.md]
|
[*.md]
|
||||||
|
indent_size = 2
|
||||||
trim_trailing_whitespace = false
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
[*.json]
|
[*.xml]
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
insert_final_newline = true
|
|
||||||
|
|
||||||
[*.yml]
|
[*.{json,jsonc}]
|
||||||
indent_style = space
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
indent_size = 2
|
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",
|
"$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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout Repository"
|
- name: "Checkout Repository"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: "Dependency Review"
|
- 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:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
php: ["8.2"]
|
php: ["8.4"]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php }}
|
php-version: ${{ matrix.php }}
|
||||||
ini-values: phar.readonly=0
|
ini-values: phar.readonly=0
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
|
echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
|
||||||
|
|
||||||
- name: Install Composer dependencies
|
- name: Install Composer dependencies
|
||||||
uses: ramsey/composer-install@v2
|
uses: ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520 # 3.1.1
|
||||||
|
|
||||||
- name: PHPUnit Testing
|
- name: PHPUnit Testing
|
||||||
run: vendor/bin/pest --coverage
|
run: vendor/bin/pest --coverage
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
- name: Ensure the PHAR works
|
- name: Ensure the PHAR works
|
||||||
run: builds/branch-usage-checker --version
|
run: builds/branch-usage-checker --version
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
name: Upload the PHAR artifact
|
name: Upload the PHAR artifact
|
||||||
with:
|
with:
|
||||||
name: branch-usage-checker
|
name: branch-usage-checker
|
||||||
@@ -64,13 +64,13 @@ jobs:
|
|||||||
- "build-phar"
|
- "build-phar"
|
||||||
if: github.event_name == 'release'
|
if: github.event_name == 'release'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: branch-usage-checker
|
name: branch-usage-checker
|
||||||
path: builds/
|
path: builds/
|
||||||
|
|
||||||
- name: Upload box.phar
|
- name: Upload box.phar
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
files: builds/branch-usage-checker
|
files: builds/branch-usage-checker
|
||||||
|
|||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -1,6 +1,18 @@
|
|||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
/vendor
|
/vendor
|
||||||
/.idea
|
/.idea
|
||||||
/.vscode
|
/.vscode
|
||||||
/.vagrant
|
/.vagrant
|
||||||
|
|
||||||
|
# PHPUnit
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
|
.phpunit.cache
|
||||||
|
|
||||||
|
# Build output
|
||||||
/builds/branch-usage-checker
|
/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;
|
namespace App\Commands;
|
||||||
|
|
||||||
use App\Dto\PackagistApiPackagePayload;
|
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;
|
use LaravelZero\Framework\Commands\Command;
|
||||||
|
|
||||||
class CheckCommand extends Command
|
class CheckCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'check
|
protected $signature = 'check
|
||||||
{vendor : Package vendor (required)}
|
{vendor : Package vendor or vendor/package}
|
||||||
{package : Package name (required)}
|
{package? : Package name}
|
||||||
{months=9 : How many months should we return for review (optional)}
|
{months=9 : How many months should we return for review (optional)}
|
||||||
';
|
';
|
||||||
protected $description = 'Check package branch usage';
|
protected $description = 'Check package branch usage';
|
||||||
@@ -20,23 +21,30 @@ class CheckCommand extends Command
|
|||||||
private string $filter = '';
|
private string $filter = '';
|
||||||
private int $totalBranches = 0;
|
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
|
public function handle(): int
|
||||||
{
|
{
|
||||||
$this->vendor = (string)$this->argument('vendor');
|
$this->http = resolve(HttpFactory::class);
|
||||||
$this->package = (string)$this->argument('package');
|
|
||||||
$months = (int)$this->argument('months');
|
|
||||||
|
|
||||||
$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);
|
$this->info('Months: ' . $months);
|
||||||
|
|
||||||
$payload = Http::get(
|
$payload = $this->fetchPackageMetadata();
|
||||||
sprintf(
|
if ($payload === null) {
|
||||||
'https://packagist.org/packages/%s/%s.json',
|
return 1;
|
||||||
$this->vendor,
|
}
|
||||||
$this->package
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->filter = now()->subMonths($months)->day(1)->toDateString();
|
$this->filter = now()->subMonths($months)->day(1)->toDateString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -45,9 +53,9 @@ class CheckCommand extends Command
|
|||||||
|
|
||||||
$versions = collect($pkg->versions ?? [])
|
$versions = collect($pkg->versions ?? [])
|
||||||
->keys()
|
->keys()
|
||||||
// Filter actual versions out.
|
|
||||||
->filter(fn ($version) => \str_starts_with($version, 'dev-'))
|
->filter(fn ($version) => \str_starts_with($version, 'dev-'))
|
||||||
->sort();
|
->sort()
|
||||||
|
->values();
|
||||||
|
|
||||||
$this->totalBranches = $versions->count();
|
$this->totalBranches = $versions->count();
|
||||||
|
|
||||||
@@ -58,48 +66,150 @@ class CheckCommand extends Command
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
$statistics = collect($versions)
|
$responses = $this->http->pool(
|
||||||
->mapWithKeys(fn ($branch) => $this->getStatistics($branch))
|
fn (Pool $pool) => $versions->map(
|
||||||
->toArray();
|
fn ($branch) => $pool->as($branch)->timeout(self::TIMEOUT_SECONDS)->get($this->getStatsUrl($branch))
|
||||||
|
)->toArray()
|
||||||
|
);
|
||||||
|
|
||||||
|
$statistics = $this->collectBranchStats($versions, $responses);
|
||||||
|
|
||||||
$this->info('Downloaded statistics...');
|
$this->info('Downloaded statistics...');
|
||||||
|
|
||||||
$this->outputTable($statistics);
|
if ($this->outputTable($statistics)) {
|
||||||
$this->outputSuggestions($statistics);
|
$this->outputSuggestions($statistics);
|
||||||
} catch (\Exception $e) {
|
}
|
||||||
$this->error($e->getMessage(), $e);
|
} catch (\Throwable $e) {
|
||||||
|
if ($e instanceof \TypeError) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
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(
|
sprintf(
|
||||||
'https://packagist.org/packages/%s/%s/stats/%s.json?average=monthly&from=%s',
|
self::PACKAGIST_URL . '.json',
|
||||||
$this->vendor,
|
$this->vendor,
|
||||||
$this->package,
|
$this->package
|
||||||
$branch,
|
|
||||||
$this->filter
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
$data = collect($payload->json());
|
if ($payload->failed()) {
|
||||||
$labels = collect($data->get('labels', []))->toArray();
|
if ($payload->status() === 404) {
|
||||||
$values = collect($data->get('values', []))->flatten()->toArray();
|
$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';
|
return $payload;
|
||||||
$values[] = array_sum($values);
|
|
||||||
|
|
||||||
return [$branch => \array_combine($labels, $values)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)) {
|
if (empty($statistics)) {
|
||||||
$this->info('No statistics found... Stopping.');
|
$this->info('No statistics found... Stopping.');
|
||||||
exit(0);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tableHeaders = ['' => 'Branch'];
|
$tableHeaders = ['' => 'Branch'];
|
||||||
@@ -107,44 +217,41 @@ class CheckCommand extends Command
|
|||||||
|
|
||||||
foreach ($statistics as $branch => $stats) {
|
foreach ($statistics as $branch => $stats) {
|
||||||
foreach ($stats as $m => $v) {
|
foreach ($stats as $m => $v) {
|
||||||
$tableHeaders[$m] = (string)$m;
|
$tableHeaders[$m] = (string) $m;
|
||||||
$tableBranches[$branch][$branch] = $branch;
|
$tableBranches[$branch][$branch] = $branch;
|
||||||
$tableBranches[$branch][$m] = (string)$v;
|
$tableBranches[$branch][$m] = (string) $v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->line('');
|
$this->line('');
|
||||||
$this->table($tableHeaders, $tableBranches);
|
$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 = [];
|
$deletable = [];
|
||||||
if (empty($statistics)) {
|
|
||||||
$this->info('No statistics to give suggestions for. Quitting...');
|
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($statistics as $k => $values) {
|
foreach ($statistics as $k => $values) {
|
||||||
if (!empty($values['Total'])) {
|
if (!empty($values['Total'])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$deletable[$k] = $values['Total'];
|
$deletable[] = $k;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($deletable)) {
|
if (empty($deletable)) {
|
||||||
$this->info('No suggestions available. Good job!');
|
$this->info('No suggestions available. Good job!');
|
||||||
exit(0);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$keys = array_keys($deletable);
|
$branches = collect($deletable)->mapWithKeys(function ($branch) {
|
||||||
|
|
||||||
$branches = collect($keys)->mapWithKeys(function ($branch) {
|
|
||||||
return [
|
return [
|
||||||
$branch => [
|
$branch => [
|
||||||
$branch,
|
$branch,
|
||||||
sprintf(
|
sprintf(
|
||||||
'https://packagist.org/packages/%s/%s#%s',
|
self::PACKAGIST_URL . '#%s',
|
||||||
$this->vendor,
|
$this->vendor,
|
||||||
$this->package,
|
$this->package,
|
||||||
$branch
|
$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"
|
"source": "https://github.com/ivuorinen/branch-usage-checker"
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.4",
|
||||||
"guzzlehttp/guzzle": "^7",
|
"illuminate/http": "^12.17",
|
||||||
"illuminate/http": "^11",
|
|
||||||
"laravel-zero/phar-updater": "^1.2",
|
|
||||||
"nunomaduro/termwind": "^2",
|
|
||||||
"spatie/data-transfer-object": "^3.7"
|
"spatie/data-transfer-object": "^3.7"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
"captainhook/captainhook": "^5.28",
|
||||||
|
"captainhook/hook-installer": "^1.0",
|
||||||
"ergebnis/composer-normalize": "^2",
|
"ergebnis/composer-normalize": "^2",
|
||||||
"laravel-zero/framework": "^11",
|
"laravel-zero/framework": "^12",
|
||||||
"mockery/mockery": "^1",
|
"mockery/mockery": "^1",
|
||||||
"pestphp/pest": "^2",
|
"pestphp/pest": "^4",
|
||||||
"roave/security-advisories": "dev-latest"
|
"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",
|
"minimum-stability": "dev",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"App\\": "app/",
|
"App\\": "app/"
|
||||||
"Database\\Factories\\": "database/factories/",
|
|
||||||
"Database\\Seeders\\": "database/seeders/"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload-dev": {
|
"autoload-dev": {
|
||||||
@@ -55,14 +57,11 @@
|
|||||||
],
|
],
|
||||||
"config": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
"dealerdirect/phpcodesniffer-composer-installer": true,
|
"captainhook/hook-installer": true,
|
||||||
"ergebnis/composer-normalize": true,
|
"ergebnis/composer-normalize": true,
|
||||||
"pestphp/pest-plugin": true
|
"pestphp/pest-plugin": true
|
||||||
},
|
},
|
||||||
"optimize-autoloader": true,
|
"optimize-autoloader": true,
|
||||||
"platform": {
|
|
||||||
"php": "8.2"
|
|
||||||
},
|
|
||||||
"preferred-install": "dist",
|
"preferred-install": "dist",
|
||||||
"sort-packages": true
|
"sort-packages": true
|
||||||
},
|
},
|
||||||
@@ -72,8 +71,25 @@
|
|||||||
],
|
],
|
||||||
"build": [
|
"build": [
|
||||||
"cp application application.phar",
|
"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",
|
"test": "vendor/bin/pest",
|
||||||
"x": "@php builds/branch-usage-checker"
|
"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.
|
* below a list of commands that you don't to see in your app.
|
||||||
*/
|
*/
|
||||||
'remove' => [
|
'remove' => [
|
||||||
\App\Commands\InspireCommand::class,
|
|
||||||
Illuminate\Console\Scheduling\ScheduleRunCommand::class,
|
Illuminate\Console\Scheduling\ScheduleRunCommand::class,
|
||||||
Illuminate\Console\Scheduling\ScheduleListCommand::class,
|
Illuminate\Console\Scheduling\ScheduleListCommand::class,
|
||||||
Illuminate\Console\Scheduling\ScheduleFinishCommand::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"?>
|
<?xml version="1.0"?>
|
||||||
<ruleset name="PHP_CodeSniffer">
|
<ruleset name="PHP_CodeSniffer">
|
||||||
<description>PHP_CodeSniffer configuration</description>
|
<description>PHP_CodeSniffer configuration</description>
|
||||||
|
<file>app</file>
|
||||||
|
<file>tests</file>
|
||||||
<rule ref="PSR12">
|
<rule ref="PSR12">
|
||||||
<exclude name="PSR12.Operators.OperatorSpacing"/>
|
<exclude name="PSR12.Operators.OperatorSpacing"/>
|
||||||
<exclude name="PSR1.Files.SideEffects.FoundWithSymbols"/>
|
<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
|
<?php
|
||||||
|
|
||||||
test('check command', function () {
|
use Illuminate\Support\Facades\Http;
|
||||||
$this->artisan('check ivuorinen branch-usage-checker')
|
|
||||||
// ->expectsOutput('')
|
const TEST_VENDOR = 'test-vendor';
|
||||||
->assertExitCode(0);
|
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