Skip to content

Commit aa76224

Browse files
authored
Improve readme badges processing (#3)
1 parent 8d33cb4 commit aa76224

File tree

9 files changed

+315
-7
lines changed

9 files changed

+315
-7
lines changed

AGENTS.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
## Project Overview
2+
3+
This is a tool for setting up project structure and configuration for PHP packages in
4+
the [PHPTG](https://github.com/phptg). It is based on [vjik/scaffolder](https://github.com/vjik/scaffolder) and
5+
automatically creates and configures essential project files including `composer.json`, GitHub Actions workflows,
6+
configuration files, and documentation structure.
7+
8+
The scaffolder uses a decision-based architecture where `Change` objects decide what modifications to apply,
9+
and `Fact` objects provide contextual information. This project extends the base library with PHPTG-specific
10+
Changes and Facts for creating standardized project structures.
11+
12+
## Commands
13+
14+
### Code Quality
15+
16+
```bash
17+
# Run PHPStan static analysis
18+
composer phpstan
19+
20+
# Fix code style (PER-CS3.0 standard)
21+
composer cs-fix
22+
23+
# Run Rector automated refactoring
24+
composer rector
25+
26+
# Run Composer Dependency Analyser
27+
composer dependency-analyser
28+
```
29+
30+
### Testing
31+
32+
There is currently no test suite configured in this project.
33+
34+
## Architecture
35+
36+
This project extends [vjik/scaffolder](https://github.com/vjik/scaffolder) with PHPTG-specific customizations.
37+
38+
### Entry Point
39+
40+
**src/run.php** - The main entry point that:
41+
- Loads changes from `src/changes.php`
42+
- Loads facts from `src/facts.php`
43+
- Loads params from `src/params.php`
44+
- Creates and runs the `Vjik\Scaffolder\Runner`
45+
46+
The Runner orchestrates the scaffolding process by executing Changes in sequence, resolving Facts on-demand,
47+
and providing a Context for file operations.
48+
49+
### Project Structure
50+
51+
**src/Change/** - Contains PHPTG-specific Change implementations for generating project files and configurations
52+
53+
**src/Fact/** - Contains custom Facts for gathering user preferences and project information
54+
55+
**src/changes.php** - Defines the complete scaffolding sequence and composer.json customizations for PHPTG
56+
57+
**src/facts.php** - Registers all custom Facts
58+
59+
**src/params.php** - Provides default values for PHPTG projects
60+
61+
## Key Patterns
62+
63+
**Decision-Based Changes**: Changes inspect Context and decide whether to apply. This allows idempotent
64+
operations - re-running applies only what's needed.
65+
66+
**Fact Resolution**: Facts are resolved on-demand and cached. This allows interactive prompts only when
67+
actually needed, and facts can depend on other facts or file content.
68+
69+
**Applier Callables**: Changes return callables rather than executing directly. This separates decision
70+
logic from execution, allowing the Command to collect all planned changes before applying.
71+
72+
**Conditional Changes**: Uses `ChangeIf` wrapper to apply changes only when a Fact resolves to true.
73+
74+
## Development Guidelines
75+
76+
### Code Style
77+
78+
- **Do NOT add comments inside methods** unless explicitly requested. Code should be self-explanatory.
79+
- **Do NOT use `assert()`** for type assertions or runtime checks. Rely on PHPStan's static analysis instead.
80+
- **PHPStan ignore for `ComposerJson`**: When using `Context::getFact(ComposerJson::class)`, add `// @phpstan-ignore argument.type` to suppress the template covariance error.
81+
82+
### Fact Normalization
83+
84+
When implementing a `Fact` that accepts a value from a command-line option and prompts the user when the option
85+
is not provided, both paths must use the same normalizer to ensure consistent validation:
86+
87+
- Extract the normalizer into a private static method (e.g., `normalize()`)
88+
- Use the normalizer for option values: `return self::normalize($value);`
89+
- Use first-class callable syntax for interactive prompts: `normalizer: self::normalize(...)`
90+
91+
### File Writing
92+
93+
- **Use `writeTextFile()`** for text files (LICENSE, README.md, composer.json, .php files, etc.)
94+
- **Use `writeFile()`** only for binary files or when exact control over file content is required

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@AGENTS.md

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
<br>
77
</div>
88

9+
[![Static analysis](https://github.com/phptg/scaffolder/actions/workflows/phpstan.yml/badge.svg?branch=master)](https://github.com/phptg/scaffolder/actions/workflows/phpstan.yml?query=branch%3Amaster)
10+
911
PHPTG Scaffolder is a tool for setting up project structure and configuration
1012
for PHP packages in the [PHPTG](https://github.com/phptg). It is based on
1113
[vjik/scaffolder](https://github.com/vjik/scaffolder) and automatically creates and configures essential

scaffolder.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@
1414
'use-psalm' => false,
1515
'use-phpstan' => true,
1616
'use-infection' => false,
17+
'badge-packagist-version' => false,
18+
'badge-packagist-downloads' => false,
1719
];

src/Change/PrepareReadme.php

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
namespace Phptg\Scaffolder\Change;
66

7+
use Phptg\Scaffolder\Fact\ReadmeBadges;
8+
use Phptg\Scaffolder\Fact\UpdateReadmeBadges;
79
use Vjik\Scaffolder\Change;
810
use Vjik\Scaffolder\Cli;
911
use Vjik\Scaffolder\Context;
@@ -23,7 +25,17 @@
2325
public function decide(Context $context): callable|array|null
2426
{
2527
$original = $context->tryReadFile(self::FILE);
26-
$new = $original ?? $this->createNew($context);
28+
29+
if ($original === null) {
30+
$new = $this->createNew($context);
31+
} else {
32+
$new = $original;
33+
if ($context->getFact(UpdateReadmeBadges::class)) {
34+
$badges = $this->createBadges($context);
35+
/** @var string $new */
36+
$new = preg_replace('~(?:^\[!\[.*\]\(.*\)\]\(.*\)\s*$\n)+~m', "$badges\n\n", $new, limit: 1);
37+
}
38+
}
2739

2840
if ($original === $new) {
2941
return null;
@@ -40,6 +52,7 @@ private function createNew(Context $context): string
4052
$title = $context->getFact(Title::class);
4153
$packageProject = $context->getFact(PackageProject::class);
4254
$phpConstraint = $this->createPhpConstraint($context);
55+
$badges = $this->createBadges($context);
4356

4457
return <<<README
4558
<div align="center">
@@ -50,12 +63,7 @@ private function createNew(Context $context): string
5063
<br>
5164
</div>
5265
53-
[![Latest Stable Version](https://poser.pugx.org/phptg/$packageProject/v)](https://packagist.org/packages/phptg/$packageProject)
54-
[![Total Downloads](https://poser.pugx.org/phptg/$packageProject/downloads)](https://packagist.org/packages/phptg/$packageProject)
55-
[![Build status](https://github.com/phptg/$packageProject/actions/workflows/build.yml/badge.svg)](https://github.com/phptg/$packageProject/actions/workflows/build.yml)
56-
[![Coverage Status](https://coveralls.io/repos/github/phptg/$packageProject/badge.svg)](https://coveralls.io/github/phptg/$packageProject)
57-
[![Mutation score](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fphptg%2F$packageProject%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/phptg/$packageProject/master)
58-
[![Static analysis](https://github.com/phptg/$packageProject/actions/workflows/static.yml/badge.svg?branch=master)](https://github.com/phptg/$packageProject/actions/workflows/static.yml?query=branch%3Amaster)
66+
$badges
5967
6068
## Requirements
6169
@@ -86,6 +94,44 @@ private function createNew(Context $context): string
8694
README;
8795
}
8896

97+
private function createBadges(Context $context): string
98+
{
99+
$packageProject = $context->getFact(PackageProject::class);
100+
$badgesConfig = $context->getFact(ReadmeBadges::class);
101+
102+
$badges = [];
103+
104+
if ($badgesConfig->packagistVersion) {
105+
$badges[] = "[![Latest Stable Version](https://poser.pugx.org/phptg/$packageProject/v)](https://packagist.org/packages/phptg/$packageProject)";
106+
}
107+
108+
if ($badgesConfig->packagistDownloads) {
109+
$badges[] = "[![Total Downloads](https://poser.pugx.org/phptg/$packageProject/downloads)](https://packagist.org/packages/phptg/$packageProject)";
110+
}
111+
112+
if ($badgesConfig->build) {
113+
$badges[] = "[![Build status](https://github.com/phptg/$packageProject/actions/workflows/build.yml/badge.svg)](https://github.com/phptg/$packageProject/actions/workflows/build.yml)";
114+
}
115+
116+
if ($badgesConfig->coverage) {
117+
$badges[] = "[![Coverage Status](https://coveralls.io/repos/github/phptg/$packageProject/badge.svg)](https://coveralls.io/github/phptg/$packageProject)";
118+
}
119+
120+
if ($badgesConfig->mutation) {
121+
$badges[] = "[![Mutation score](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fphptg%2F$packageProject%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/phptg/$packageProject/master)";
122+
}
123+
124+
if ($badgesConfig->psalm) {
125+
$badges[] = "[![Static analysis](https://github.com/phptg/$packageProject/actions/workflows/psalm.yml/badge.svg?branch=master)](https://github.com/phptg/$packageProject/actions/workflows/psalm.yml?query=branch%3Amaster)";
126+
}
127+
128+
if ($badgesConfig->phpstan) {
129+
$badges[] = "[![Static analysis](https://github.com/phptg/$packageProject/actions/workflows/phpstan.yml/badge.svg?branch=master)](https://github.com/phptg/$packageProject/actions/workflows/phpstan.yml?query=branch%3Amaster)";
130+
}
131+
132+
return implode("\n", $badges);
133+
}
134+
89135
private function createPhpConstraint(Context $context): string
90136
{
91137
$result = $context->getFact(PhpConstraintName::class) === 'php-64bit' ? 'PHP (64-bit)' : 'PHP';

src/Fact/ReadmeBadges.php

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Phptg\Scaffolder\Fact;
6+
7+
use Phptg\Scaffolder\Value\ReadmeBadgesConfig;
8+
use Symfony\Component\Console\Command\Command as SymfonyCommand;
9+
use Symfony\Component\Console\Input\InputOption;
10+
use Vjik\Scaffolder\Cli;
11+
use Vjik\Scaffolder\Context;
12+
use Vjik\Scaffolder\Fact;
13+
use Vjik\Scaffolder\Params;
14+
15+
/**
16+
* @extends Fact<ReadmeBadgesConfig>
17+
*/
18+
final class ReadmeBadges extends Fact
19+
{
20+
public const string PACKAGIST_VERSION_OPTION = 'badge-packagist-version';
21+
public const string PACKAGIST_DOWNLOADS_OPTION = 'badge-packagist-downloads';
22+
public const string BUILD_OPTION = 'badge-build';
23+
public const string COVERAGE_OPTION = 'badge-coverage';
24+
public const string MUTATION_OPTION = 'badge-mutation';
25+
public const string PSALM_OPTION = 'badge-psalm';
26+
public const string PHPSTAN_OPTION = 'badge-phpstan';
27+
28+
public static function configureCommand(SymfonyCommand $command, Params $params): void
29+
{
30+
$command
31+
->addOption(
32+
self::PACKAGIST_VERSION_OPTION,
33+
mode: InputOption::VALUE_OPTIONAL,
34+
default: $params->get(self::PACKAGIST_VERSION_OPTION),
35+
)
36+
->addOption(
37+
self::PACKAGIST_DOWNLOADS_OPTION,
38+
mode: InputOption::VALUE_OPTIONAL,
39+
default: $params->get(self::PACKAGIST_DOWNLOADS_OPTION),
40+
)
41+
->addOption(
42+
self::BUILD_OPTION,
43+
mode: InputOption::VALUE_OPTIONAL,
44+
default: $params->get(self::BUILD_OPTION),
45+
)
46+
->addOption(
47+
self::COVERAGE_OPTION,
48+
mode: InputOption::VALUE_OPTIONAL,
49+
default: $params->get(self::COVERAGE_OPTION),
50+
)
51+
->addOption(
52+
self::MUTATION_OPTION,
53+
mode: InputOption::VALUE_OPTIONAL,
54+
default: $params->get(self::MUTATION_OPTION),
55+
)
56+
->addOption(
57+
self::PSALM_OPTION,
58+
mode: InputOption::VALUE_OPTIONAL,
59+
default: $params->get(self::PSALM_OPTION),
60+
)
61+
->addOption(
62+
self::PHPSTAN_OPTION,
63+
mode: InputOption::VALUE_OPTIONAL,
64+
default: $params->get(self::PHPSTAN_OPTION),
65+
);
66+
}
67+
68+
public static function resolve(Cli $cli, Context $context): mixed
69+
{
70+
return new ReadmeBadgesConfig(
71+
packagistVersion: self::getBoolOption($cli, self::PACKAGIST_VERSION_OPTION, static fn() => true),
72+
packagistDownloads: self::getBoolOption($cli, self::PACKAGIST_DOWNLOADS_OPTION, static fn() => true),
73+
build: self::getBoolOption(
74+
$cli,
75+
self::BUILD_OPTION,
76+
static fn() => $context->getFact(UsePhpUnit::class),
77+
),
78+
coverage: self::getBoolOption(
79+
$cli,
80+
self::COVERAGE_OPTION,
81+
static fn() => $context->getFact(UsePhpUnit::class),
82+
),
83+
mutation: self::getBoolOption(
84+
$cli,
85+
self::MUTATION_OPTION,
86+
static fn() => $context->getFact(UseInfection::class),
87+
),
88+
psalm: self::getBoolOption(
89+
$cli,
90+
self::PSALM_OPTION,
91+
static fn() => $context->getFact(UsePsalm::class),
92+
),
93+
phpstan: self::getBoolOption(
94+
$cli,
95+
self::PHPSTAN_OPTION,
96+
static fn() => $context->getFact(UsePhpStan::class),
97+
),
98+
);
99+
}
100+
101+
/**
102+
* @param callable(): bool $default
103+
*/
104+
private static function getBoolOption(Cli $cli, string $option, callable $default): bool
105+
{
106+
$value = $cli->getOption($option);
107+
return $value === null ? $default() : filter_var($value, FILTER_VALIDATE_BOOLEAN);
108+
}
109+
}

src/Fact/UpdateReadmeBadges.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Phptg\Scaffolder\Fact;
6+
7+
use Symfony\Component\Console\Command\Command as SymfonyCommand;
8+
use Symfony\Component\Console\Input\InputOption;
9+
use Vjik\Scaffolder\Cli;
10+
use Vjik\Scaffolder\Context;
11+
use Vjik\Scaffolder\Fact;
12+
use Vjik\Scaffolder\Params;
13+
14+
/**
15+
* @extends Fact<bool>
16+
*/
17+
final class UpdateReadmeBadges extends Fact
18+
{
19+
public const string VALUE_OPTION = 'update-readme-badges';
20+
21+
public static function configureCommand(SymfonyCommand $command, Params $params): void
22+
{
23+
$command->addOption(
24+
self::VALUE_OPTION,
25+
mode: InputOption::VALUE_OPTIONAL,
26+
default: $params->get(self::VALUE_OPTION, true),
27+
);
28+
}
29+
30+
public static function resolve(Cli $cli, Context $context): mixed
31+
{
32+
return filter_var($cli->getOption(self::VALUE_OPTION), FILTER_VALIDATE_BOOLEAN);
33+
}
34+
}

src/Value/ReadmeBadgesConfig.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Phptg\Scaffolder\Value;
6+
7+
final readonly class ReadmeBadgesConfig
8+
{
9+
public function __construct(
10+
public bool $packagistVersion,
11+
public bool $packagistDownloads,
12+
public bool $build,
13+
public bool $coverage,
14+
public bool $mutation,
15+
public bool $psalm,
16+
public bool $phpstan,
17+
) {}
18+
}

src/facts.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
use Phptg\Scaffolder\Fact;
66

77
return [
8+
Fact\ReadmeBadges::class,
9+
Fact\UpdateReadmeBadges::class,
810
Fact\UseComposerDependencyAnalyser::class,
911
Fact\UseInfection::class,
1012
Fact\UsePhpStan::class,

0 commit comments

Comments
 (0)