19 Commits

Author SHA1 Message Date
renovate[bot]
a706aac668 chore(deps): lock file maintenance (#45) 2026-02-23 21:28:02 +02:00
2a37912685 chore: cleanup unused deps and dead code (#43)
* chore: remove unused code and dead files

Remove InspireCommand, GitHubApiBranch DTO, PackagistApiStatsPayload DTO,
and GitHubRestApi fetcher. Remove InspireCommand reference from config.

* chore(deps): remove unused composer dependencies

Remove guzzlehttp/guzzle, laravel-zero/phar-updater, and
nunomaduro/termwind. Clean up dead autoload entries and orphaned
allow-plugins configuration.

* feat: improve CheckCommand input handling and error reporting

Add vendor/package slash format support, input validation, HTTP error
handling, and concurrent stats fetching. Replace exit() calls with
return statements. Add 13 new test cases with Http::fake coverage.

* build: rebuild PHAR executable

* chore: add CLAUDE.md project instructions

* docs: add design for PHPCS + CaptainHook integration

* docs: add implementation plan for PHPCS + CaptainHook

* build(deps): add squizlabs/php_codesniffer

* build: add composer lint and format scripts

* build(deps): add captainhook and hook-installer

* build: configure CaptainHook pre-commit hook for PHPCS

* docs: add lint/format commands and hook info to CLAUDE.md

* build: add markdownlint-cli2 configuration

* build: add lint:md, lint:ec, lint:all, and format:md composer scripts

* build: add editorconfig and markdownlint to pre-commit hook

* docs: add new lint and format commands to CLAUDE.md

* chore: add missing gitignore patterns

* refactor: replace Http facade with injected HttpFactory

- Resolve HttpFactory via the container instead of using the Http facade
  directly, improving testability and explicitness
- Simplify early-return logic in package metadata error handling
- Reformat long strings and closures for PSR-12 line length compliance
- Extract repeated stats URL prefix into variable in tests

* build: add Claude Code shared settings and skills

Add shared hooks (auto-format PHP on edit, block vendor/lock edits),
shared permissions for common dev commands, and two user-invocable
skills (/build-phar, /release-check).

* refactor: extract test constants to reduce duplication in CheckCommandTest

Replace 6 occurrences of the command string and 4 occurrences of the
stats URL with TEST_COMMAND and TEST_STATS_URL constants. Fixes
SonarCloud S1192 code smells and brings duplicated lines density
below the 3% threshold.

* refactor: extract resolveInput and fetchPackageMetadata from handle()

Reduce handle() from 8 return statements to 3 by extracting input
validation into resolveInput() and HTTP fetching into
fetchPackageMetadata(). Fixes SonarCloud S1142 (too many returns).

* fix: improve CheckCommand error handling, input normalization, and timeouts

- Return exit code 1 from catch block instead of silently succeeding
- Normalize vendor/package inputs to lowercase before validation
- Add 10-second HTTP timeouts to metadata fetch and pool requests
- Return false from outputSuggestions when no suggestions found

* test: stub HTTP in live-request tests and assert exception output

- Add Http::fake() to slash-format and two-argument tests to prevent
  real Packagist requests during CI
- Assert exception message in try-block test and expect exit code 1
  to match the updated catch behavior

* docs: fix command signature, remove stale Fetchers reference in CLAUDE.md

- Change {package} to {package?} and note vendor/package combined form
- Remove non-existent app/Fetchers/ directory reference
- Update HTTP dependency note to reflect HttpFactory injection

* fix: guard array_combine against mismatched Packagist stats

Add a length check before array_combine() so malformed stats
(different label/value counts) produce a warning and skip the
branch instead of throwing a ValueError.

* refactor: change outputSuggestions return type to void

No caller uses the return value. Replace bool return type with
void and remove the now-unnecessary return statements.

* fix: narrow Throwable catch to Exception, add docblocks, remove unused default

- Catch \Exception instead of \Throwable so programming errors (TypeError,
  OutOfMemoryError) propagate instead of being silently displayed
- Add minimal PHPDoc summaries to all CheckCommand methods for coverage
- Remove unused default `= []` on outputSuggestions parameter
- Update test to expect TypeError propagation via ->throws()

* refactor: extract constants, fix dead store, deduplicate test setup

- Add TIMEOUT_SECONDS and PACKAGIST_URL constants in CheckCommand
- Replace inline timeout(10) and URL strings with constant references
- Fix dead store: $deletable[$k] = $values['Total'] → $deletable[] = $k
- Eliminate unused $keys intermediate variable in outputSuggestions()
- Standardize string formatting to use sprintf() consistently
- Add TEST_METADATA_URL constant and fakePackageResponses() helper in tests
- Refactor 6 tests to use shared helper, reducing Http::fake duplication

* docs: fix capitalization and remove stale GitHub reference in CLAUDE.md

* docs: add design for fixing SonarCloud duplication quality gate

* docs: add implementation plan for SonarCloud duplication fix

* refactor(tests): parameterize input-validation tests to fix duplication gate

* feat: add PHPMD linter and fix Codacy markdownlint config

Add PHPMD as a dev dependency with cleancode, codesize, unusedcode, and
naming rulesets. Integrate via composer lint:phpmd script, lint:all, and
CaptainHook pre-commit hook.

Split markdownlint config into .markdownlint.jsonc (rules) and
.markdownlint-cli2.jsonc (ignores only) so Codacy's plain markdownlint
can discover the shared rules file.

* fix: rename markdownlint config to .json and disable MD043

Rename .markdownlint.jsonc to .markdownlint.json so Codacy discovers
it at highest priority, and add required-headings: false to explicitly
disable MD043 which flags docs with varying heading structures.

* fix: guard against ConnectionException in pool responses and catch Throwable

Pool responses can be Throwable (e.g. ConnectionException) instead of
Response objects, causing crashes on ->failed(). Add instanceof guard,
widen catch to Throwable with TypeError re-throw, and extract loop into
collectBranchStats() to stay under cyclomatic complexity threshold.
2026-02-23 09:32:16 +02:00
renovate[bot]
4ac6554848 chore(deps): update actions/dependency-review-action action (v4.8.2 → v4.8.3) (#44)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-22 04:30:49 +00:00
renovate[bot]
4e74236eff chore(deps): lock file maintenance (#41) 2026-02-17 19:10:54 +02:00
renovate[bot]
328d89215a chore(deps): update actions/checkout action (v6.0.1 → v6.0.2) (#39)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-24 05:55:38 +00:00
c51399580e feat: upgrade laravel-zero, illuminate & pest (#38) 2025-12-22 14:10:19 +02:00
9dd3b08b84 chore(deps): bump php to 8.4, update packages, rebuild (#37) 2025-12-22 08:28:11 +02:00
266068326b chore: update workflows 2025-12-04 00:30:43 +02:00
d050ac3b52 chore(deps): upgrade packages
# Conflicts:
#	composer.lock
2025-12-04 00:29:25 +02:00
renovate[bot]
af506b4488 chore(deps)!: update actions/checkout (v5 → v6) (#34) 2025-12-03 20:18:46 +02:00
dependabot[bot]
beb28d8fdb build(deps): bump symfony/http-foundation from 7.2.3 to 7.3.7 (#33) 2025-11-13 01:08:25 +02:00
renovate[bot]
6fbd953b30 feat(github-action)!: Update actions/checkout (v4 → v5) (#29) 2025-08-16 16:09:00 +03:00
renovate[bot]
2fa5b469a1 feat(github-action)!: Update actions/download-artifact (v4 → v5) (#28)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-07 05:32:20 +03:00
Ismo Vuorinen
3ead4ff211 chore(deps): update composer packages 2025-03-19 10:51:04 +02:00
renovate[bot]
ed09f3e03d chore(deps): pin dependencies (#25) 2025-02-09 20:45:28 +02:00
dependabot[bot]
c31beafead build(deps): bump nesbot/carbon from 3.8.1 to 3.8.4 (#24)
Bumps [nesbot/carbon](https://github.com/CarbonPHP/carbon) from 3.8.1 to 3.8.4.
- [Release notes](https://github.com/CarbonPHP/carbon/releases)
- [Commits](https://github.com/CarbonPHP/carbon/compare/3.8.1...3.8.4)

---
updated-dependencies:
- dependency-name: nesbot/carbon
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-09 01:14:38 +02:00
Ismo Vuorinen
1103e59c35 chore(deps): update deps, remove temp phar (#23) 2024-11-07 09:25:12 +02:00
renovate[bot]
de9231d1cd chore(deps): update pestphp/pest to 3.0.1 (#20)
* chore(deps): update pestphp/pest to 3.0.1

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* chore(deps): fix dependencies for pest 3.x

---------

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Ismo Vuorinen <ismo@ivuorinen.net>
2024-09-10 10:07:35 +03:00
Ismo Vuorinen
29d11f41c7 chore(meta): update renovate.json 2024-07-23 03:50:33 +03:00
30 changed files with 5123 additions and 2329 deletions

44
.claude/settings.json Normal file
View 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:*)"
]
}
}

View 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

View 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

View File

@@ -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

View File

@@ -0,0 +1,7 @@
{
"Exclude": [
"\\.md$",
"builds/",
"vendor/"
]
}

View File

@@ -1,4 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>ivuorinen/.github:renovate-config"]
"extends": [
"github>ivuorinen/renovate-config"
]
}

View File

@@ -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

View File

@@ -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
View File

@@ -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
View File

@@ -0,0 +1,3 @@
{
"ignores": ["vendor/**", "node_modules/**"]
}

5
.markdownlint.json Normal file
View File

@@ -0,0 +1,5 @@
{
"line-length": { "line_length": 80 },
"no-duplicate-heading": false,
"required-headings": false
}

1
.php-version Normal file
View File

@@ -0,0 +1 @@
8.4

81
CLAUDE.md Normal file
View 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`

View File

@@ -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

View File

@@ -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();
}
}

View File

@@ -1,11 +0,0 @@
<?php
namespace App\Dto;
use Spatie\DataTransferObject\DataTransferObject;
class GitHubApiBranch extends DataTransferObject
{
public string $name;
public bool $protected;
}

View File

@@ -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';
}

View File

@@ -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
View 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": []
}
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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,

View 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

View 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`.

View 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

View 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"
```

View File

@@ -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
View 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>

View File

@@ -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);
});