From b95d41154b68f18d128d625123f39b3a7f5094e0 Mon Sep 17 00:00:00 2001 From: Dmitry Prikotov Date: Fri, 22 May 2026 16:59:00 +0700 Subject: [PATCH 1/5] feat: add standard exception templates and copy them on init - Add config/exceptions/ with 16 template files (9 interfaces + 7 classes) using {{ProjectName}} placeholder for namespace - Update coding-standard-init to auto-detect project name from composer.json autoload.psr-4 and copy exceptions with proper namespace - Add --no-exceptions and --exceptions-path options - Hierarchy: ClientError/ServerError roots, concrete exceptions extend DomainException/ValidationException/RuntimeException/LogicException --- bin/coding-standard-init | 120 ++++++++++++++++-- config/exceptions/AccessDeniedException.php | 9 ++ .../AccessDeniedExceptionInterface.php | 9 ++ .../ClientErrorExceptionInterface.php | 11 ++ config/exceptions/ConfigurationException.php | 11 ++ .../ConfigurationExceptionInterface.php | 9 ++ config/exceptions/ConflictException.php | 12 ++ .../exceptions/ConflictExceptionInterface.php | 9 ++ config/exceptions/DomainException.php | 9 ++ .../exceptions/DomainExceptionInterface.php | 11 ++ config/exceptions/InfrastructureException.php | 11 ++ .../InfrastructureExceptionInterface.php | 9 ++ config/exceptions/NotFoundException.php | 9 ++ .../exceptions/NotFoundExceptionInterface.php | 9 ++ .../ServerErrorExceptionInterface.php | 11 ++ config/exceptions/ValidationException.php | 12 ++ .../ValidationExceptionInterface.php | 9 ++ 17 files changed, 268 insertions(+), 12 deletions(-) create mode 100644 config/exceptions/AccessDeniedException.php create mode 100644 config/exceptions/AccessDeniedExceptionInterface.php create mode 100644 config/exceptions/ClientErrorExceptionInterface.php create mode 100644 config/exceptions/ConfigurationException.php create mode 100644 config/exceptions/ConfigurationExceptionInterface.php create mode 100644 config/exceptions/ConflictException.php create mode 100644 config/exceptions/ConflictExceptionInterface.php create mode 100644 config/exceptions/DomainException.php create mode 100644 config/exceptions/DomainExceptionInterface.php create mode 100644 config/exceptions/InfrastructureException.php create mode 100644 config/exceptions/InfrastructureExceptionInterface.php create mode 100644 config/exceptions/NotFoundException.php create mode 100644 config/exceptions/NotFoundExceptionInterface.php create mode 100644 config/exceptions/ServerErrorExceptionInterface.php create mode 100644 config/exceptions/ValidationException.php create mode 100644 config/exceptions/ValidationExceptionInterface.php diff --git a/bin/coding-standard-init b/bin/coding-standard-init index 3d61a9a..15314b3 100755 --- a/bin/coding-standard-init +++ b/bin/coding-standard-init @@ -8,21 +8,27 @@ declare(strict_types=1); * * Usage: * php vendor/bin/coding-standard-init [target-dir] [--docs-path=] [--deptrac-path=] [--force] + * [--exceptions-path=] [--no-exceptions] * - * target-dir — project root (default: current working directory). - * --docs-path — relative path where docs will be copied (default: docs/conventions). - * --deptrac-path — relative path where depfile.yaml will be copied (default: depfile.yaml). - * --no-deptrac — skip depfile.yaml copying entirely. - * --force — overwrite existing files with fresh copies from the package. + * target-dir — project root (default: current working directory). + * --docs-path — relative path where docs will be copied (default: docs/conventions). + * --deptrac-path — relative path where depfile.yaml will be copied (default: depfile.yaml). + * --no-deptrac — skip depfile.yaml copying entirely. + * --exceptions-path — relative path where exceptions will be copied (default: src/Common/Exception). + * Project name is auto-detected from composer.json autoload.psr-4. + * --no-exceptions — skip exceptions copying entirely. + * --force — overwrite existing files with fresh copies from the package. */ // ── Parse arguments ──────────────────────────────────────────────────────── -$force = false; -$noDeptrac = false; -$targetArg = null; -$docsPath = null; -$deptracPath = null; +$force = false; +$noDeptrac = false; +$noExceptions = false; +$targetArg = null; +$docsPath = null; +$deptracPath = null; +$exceptionsPath = null; foreach ($argv as $i => $arg) { if ($i === 0) { @@ -32,10 +38,14 @@ foreach ($argv as $i => $arg) { $force = true; } elseif ($arg === '--no-deptrac') { $noDeptrac = true; + } elseif ($arg === '--no-exceptions') { + $noExceptions = true; } elseif (str_starts_with($arg, '--docs-path=')) { $docsPath = substr($arg, strlen('--docs-path=')); } elseif (str_starts_with($arg, '--deptrac-path=')) { $deptracPath = substr($arg, strlen('--deptrac-path=')); + } elseif (str_starts_with($arg, '--exceptions-path=')) { + $exceptionsPath = substr($arg, strlen('--exceptions-path=')); } else { $targetArg = $arg; } @@ -59,7 +69,8 @@ if ($targetDir === false || !is_dir($targetDir)) { $sourceDocs = $packageRoot . '/docs/conventions'; $sourceConfig = $packageRoot . '/config'; -$docsPath = $docsPath ?? 'docs/conventions'; +$docsPath = $docsPath ?? 'docs/conventions'; +$exceptionsPath = $exceptionsPath ?? 'src/Common/Exception'; $targetDocs = $targetDir . '/' . $docsPath; if (!is_dir($sourceDocs)) { @@ -67,6 +78,33 @@ if (!is_dir($sourceDocs)) { exit(1); } +// ── Detect project name from composer.json ───────────────────────────────── + +$projectName = null; +$composerJsonPath = $targetDir . '/composer.json'; + +if (!$noExceptions && file_exists($composerJsonPath)) { + $composerData = json_decode(file_get_contents($composerJsonPath), true); + $psr4 = $composerData['autoload']['psr-4'] ?? []; + + $names = []; + foreach ($psr4 as $namespace => $path) { + $parts = explode('\\', trim($namespace, '\\')); + if ($parts[0] !== '') { + $names[$parts[0]] = true; + } + } + + $uniqueNames = array_keys($names); + if (count($uniqueNames) === 1) { + $projectName = $uniqueNames[0]; + } elseif (count($uniqueNames) > 1) { + fwrite(STDERR, "Error: ambiguous namespace roots in composer.json: " . implode(', ', $uniqueNames) . PHP_EOL); + fwrite(STDERR, "Use --no-exceptions to skip, or specify namespace via composer.json." . PHP_EOL); + $noExceptions = true; + } +} + if ($force) { echo " ⚠ --force: existing files will be overwritten." . PHP_EOL; } @@ -168,7 +206,60 @@ if (file_exists($phpstanSource)) { } } -// ── 5. Update .gitignore ────────────────────────────────────────────────── +// ── 5. Copy exceptions ──────────────────────────────────────────────────── + +if (!$noExceptions && $projectName !== null) { + $exceptionsSource = $sourceConfig . '/exceptions'; + + if (is_dir($exceptionsSource)) { + $targetExceptions = $targetDir . '/' . $exceptionsPath; + echo " Using project name: $projectName (detected from composer.json)" . PHP_EOL; + + $exIterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($exceptionsSource, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + + foreach ($exIterator as $item) { + $relativePath = substr($item->getPathname(), strlen($exceptionsSource) + 1); + $destPath = $targetExceptions . '/' . $relativePath; + + if ($item->isDir()) { + if (!is_dir($destPath)) { + mkdir($destPath, 0755, true); + } + continue; + } + + $content = file_get_contents($item->getPathname()); + $content = str_replace('{{ProjectName}}', $projectName, $content); + + $destDirForFile = dirname($destPath); + if (!is_dir($destDirForFile)) { + mkdir($destDirForFile, 0755, true); + } + + if (file_exists($destPath)) { + if ($force) { + file_put_contents($destPath, $content); + echo " Updated $exceptionsPath/$relativePath" . PHP_EOL; + $updated++; + } else { + echo " Skip $exceptionsPath/$relativePath (already exists)" . PHP_EOL; + $skipped++; + } + } else { + file_put_contents($destPath, $content); + echo " Copied $exceptionsPath/$relativePath" . PHP_EOL; + $copied++; + } + } + } +} elseif (!$noExceptions && $projectName === null) { + echo " Skip exceptions (could not detect project name from composer.json)" . PHP_EOL; +} + +// ── 6. Update .gitignore ────────────────────────────────────────────────── // 4a. docs/.gitignore — exclude copied conventions documentation @@ -204,3 +295,8 @@ if ($deptracPath !== '') { echo " 3. Run \`vendor/bin/deptrac analyse\` to verify architectural rules." . PHP_EOL; } echo " 4. Run \`vendor/bin/phpstan analyse\` to run static analysis." . PHP_EOL; +if (!$noExceptions && $projectName !== null) { + echo " 5. Review copied exceptions in $exceptionsPath/ and adjust if needed." . PHP_EOL; +} elseif (!$noExceptions) { + echo " 5. Add --no-exceptions or ensure composer.json has a single namespace root." . PHP_EOL; +} diff --git a/config/exceptions/AccessDeniedException.php b/config/exceptions/AccessDeniedException.php new file mode 100644 index 0000000..4487e77 --- /dev/null +++ b/config/exceptions/AccessDeniedException.php @@ -0,0 +1,9 @@ + Date: Fri, 22 May 2026 18:12:57 +0700 Subject: [PATCH 2/5] refactor: replace composer.json auto-detect with --project-name parameter Project name is now explicitly passed via --project-name=Task instead of fragile auto-detection from composer.json autoload.psr-4. --- bin/coding-standard-init | 40 +++++++--------------------------------- 1 file changed, 7 insertions(+), 33 deletions(-) diff --git a/bin/coding-standard-init b/bin/coding-standard-init index 15314b3..03fdb86 100755 --- a/bin/coding-standard-init +++ b/bin/coding-standard-init @@ -8,14 +8,14 @@ declare(strict_types=1); * * Usage: * php vendor/bin/coding-standard-init [target-dir] [--docs-path=] [--deptrac-path=] [--force] - * [--exceptions-path=] [--no-exceptions] + * [--project-name=] [--exceptions-path=] [--no-exceptions] * * target-dir — project root (default: current working directory). * --docs-path — relative path where docs will be copied (default: docs/conventions). * --deptrac-path — relative path where depfile.yaml will be copied (default: depfile.yaml). * --no-deptrac — skip depfile.yaml copying entirely. + * --project-name — project name used as namespace prefix for exceptions (e.g. Task). * --exceptions-path — relative path where exceptions will be copied (default: src/Common/Exception). - * Project name is auto-detected from composer.json autoload.psr-4. * --no-exceptions — skip exceptions copying entirely. * --force — overwrite existing files with fresh copies from the package. */ @@ -28,6 +28,7 @@ $noExceptions = false; $targetArg = null; $docsPath = null; $deptracPath = null; +$projectName = null; $exceptionsPath = null; foreach ($argv as $i => $arg) { @@ -44,6 +45,8 @@ foreach ($argv as $i => $arg) { $docsPath = substr($arg, strlen('--docs-path=')); } elseif (str_starts_with($arg, '--deptrac-path=')) { $deptracPath = substr($arg, strlen('--deptrac-path=')); + } elseif (str_starts_with($arg, '--project-name=')) { + $projectName = substr($arg, strlen('--project-name=')); } elseif (str_starts_with($arg, '--exceptions-path=')) { $exceptionsPath = substr($arg, strlen('--exceptions-path=')); } else { @@ -78,33 +81,6 @@ if (!is_dir($sourceDocs)) { exit(1); } -// ── Detect project name from composer.json ───────────────────────────────── - -$projectName = null; -$composerJsonPath = $targetDir . '/composer.json'; - -if (!$noExceptions && file_exists($composerJsonPath)) { - $composerData = json_decode(file_get_contents($composerJsonPath), true); - $psr4 = $composerData['autoload']['psr-4'] ?? []; - - $names = []; - foreach ($psr4 as $namespace => $path) { - $parts = explode('\\', trim($namespace, '\\')); - if ($parts[0] !== '') { - $names[$parts[0]] = true; - } - } - - $uniqueNames = array_keys($names); - if (count($uniqueNames) === 1) { - $projectName = $uniqueNames[0]; - } elseif (count($uniqueNames) > 1) { - fwrite(STDERR, "Error: ambiguous namespace roots in composer.json: " . implode(', ', $uniqueNames) . PHP_EOL); - fwrite(STDERR, "Use --no-exceptions to skip, or specify namespace via composer.json." . PHP_EOL); - $noExceptions = true; - } -} - if ($force) { echo " ⚠ --force: existing files will be overwritten." . PHP_EOL; } @@ -213,7 +189,7 @@ if (!$noExceptions && $projectName !== null) { if (is_dir($exceptionsSource)) { $targetExceptions = $targetDir . '/' . $exceptionsPath; - echo " Using project name: $projectName (detected from composer.json)" . PHP_EOL; + echo " Using project name: $projectName" . PHP_EOL; $exIterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($exceptionsSource, RecursiveDirectoryIterator::SKIP_DOTS), @@ -256,7 +232,7 @@ if (!$noExceptions && $projectName !== null) { } } } elseif (!$noExceptions && $projectName === null) { - echo " Skip exceptions (could not detect project name from composer.json)" . PHP_EOL; + echo " Skip exceptions (--project-name not specified)" . PHP_EOL; } // ── 6. Update .gitignore ────────────────────────────────────────────────── @@ -297,6 +273,4 @@ if ($deptracPath !== '') { echo " 4. Run \`vendor/bin/phpstan analyse\` to run static analysis." . PHP_EOL; if (!$noExceptions && $projectName !== null) { echo " 5. Review copied exceptions in $exceptionsPath/ and adjust if needed." . PHP_EOL; -} elseif (!$noExceptions) { - echo " 5. Add --no-exceptions or ensure composer.json has a single namespace root." . PHP_EOL; } From 9b2f78e7ab8aded0ce4b9944624c45e0967634e9 Mon Sep 17 00:00:00 2001 From: Dmitry Prikotov Date: Fri, 22 May 2026 18:16:28 +0700 Subject: [PATCH 3/5] docs: update README with exceptions, phpstan, and new init options --- README.md | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1a0e239..a1af3eb 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ AI-агенты склонны отклоняться от конвенций. | `phpcs.xml.dist` | PHP CodeSniffer | | `phpunit.xml.dist` | PHPUnit | | `phpmd.xml` | PHPMD | +| `phpstan.neon.dist` | PHPStan | | `psalm.xml` | Psalm | | `Makefile` | Команды проверки (`make check`) | @@ -71,7 +72,8 @@ composer require --dev prikotov/coding-standard - **Сниффы** — PHP CodeSniffer-правила, работают сразу из `vendor/` - **Deptrac-правила** — пользовательские правила для deptrac -- **Конфигурации** — `depfile.yaml` для Deptrac, `phpcs.xml.dist` для PHPCS +- **Конфигурации** — `depfile.yaml` для Deptrac, `phpcs.xml.dist` для PHPCS, `phpstan.neon.dist` для PHPStan +- **Шаблоны исключений** — типовые классы и интерфейсы, копируются с подстановкой namespace проекта - **Конвенции** — документация, копируется командой `coding-standard-init` ### Подключение PHPCS @@ -93,6 +95,36 @@ php vendor/bin/coding-standard-init php vendor/bin/coding-standard-init /path/to/project --docs-path=docs/ddd --deptrac-path=config/depfile.yaml --force ``` +### Копирование типовых исключений + +Шаблоны исключений хранятся в `config/exceptions/` и копируются в проект с подстановкой имени namespace. + +```bash +php vendor/bin/coding-standard-init --project-name=Task +``` + +Это создаст файлы в `src/Common/Exception/` с namespace `Task\Common\Exception`. + +| Опция | Описание | +|---|---| +| `--project-name=Task` | Имя проекта для namespace (обязательно для исключений) | +| `--exceptions-path=src/Common/Exception` | Путь копирования (по умолчанию) | +| `--no-exceptions` | Пропустить копирование исключений | + +**Иерархия исключений:** + +``` +ClientErrorExceptionInterface ServerErrorExceptionInterface +├── ValidationExceptionInterface ├── InfrastructureExceptionInterface +├── NotFoundExceptionInterface └── ConfigurationExceptionInterface +├── ConflictExceptionInterface +└── AccessDeniedExceptionInterface + +DomainExceptionInterface (独立的) +``` + +Без `--project-name` исключения пропускаются, остальные файлы копируются как обычно. + --- ## License From ed07d0e23127b8314fdfc3bf7693a1c9d27b8ec9 Mon Sep 17 00:00:00 2001 From: Dmitry Prikotov Date: Fri, 22 May 2026 18:18:26 +0700 Subject: [PATCH 4/5] docs: add PHPStan rules to package contents --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index a1af3eb..67e3fa5 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ composer require --dev prikotov/coding-standard - **Сниффы** — PHP CodeSniffer-правила, работают сразу из `vendor/` - **Deptrac-правила** — пользовательские правила для deptrac +- **PHPStan-правила** — пользовательские правила для phpstan - **Конфигурации** — `depfile.yaml` для Deptrac, `phpcs.xml.dist` для PHPCS, `phpstan.neon.dist` для PHPStan - **Шаблоны исключений** — типовые классы и интерфейсы, копируются с подстановкой namespace проекта - **Конвенции** — документация, копируется командой `coding-standard-init` From c02eb9a39911f5745bc0f11d0acf6a6c960c317d Mon Sep 17 00:00:00 2001 From: Dmitry Prikotov Date: Fri, 22 May 2026 18:20:29 +0700 Subject: [PATCH 5/5] docs: remove exception hierarchy diagram from README --- README.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/README.md b/README.md index 67e3fa5..5946ca7 100644 --- a/README.md +++ b/README.md @@ -112,18 +112,6 @@ php vendor/bin/coding-standard-init --project-name=Task | `--exceptions-path=src/Common/Exception` | Путь копирования (по умолчанию) | | `--no-exceptions` | Пропустить копирование исключений | -**Иерархия исключений:** - -``` -ClientErrorExceptionInterface ServerErrorExceptionInterface -├── ValidationExceptionInterface ├── InfrastructureExceptionInterface -├── NotFoundExceptionInterface └── ConfigurationExceptionInterface -├── ConflictExceptionInterface -└── AccessDeniedExceptionInterface - -DomainExceptionInterface (独立的) -``` - Без `--project-name` исключения пропускаются, остальные файлы копируются как обычно. ---