Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@
"squizlabs/php_codesniffer": "^4.0",
"slevomat/coding-standard": "^8.0"
},
"repositories": [
{"type": "vcs", "url": "https://github.com/prikotov/todo-md"}
],
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.2",
"deptrac/deptrac": "^2.0",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^10.5"
"phpunit/phpunit": "^10.5",
"prikotov/todo-md": "^0.0"
},
"autoload": {
"psr-4": {
Expand Down Expand Up @@ -50,11 +54,16 @@
"sniff-test": "php bin/run-sniff-tests.php",
"validate-docs": "php bin/validate-docs.php",
"validate-md-links": "php bin/validate-md-links",
"validate-todo": [
"php vendor/bin/todo-md-validate todo/*.todo.md",
"php vendor/bin/todo-md-validate todo/backlog/"
],
"check": [
"@test",
"@sniff-test",
"@validate-docs",
"@validate-md-links",
"@validate-todo",
"@phpstan",
"phpcs src/"
]
Expand Down
4 changes: 2 additions & 2 deletions docs/conventions/core-patterns/dto.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,10 @@ description: Правила создания и использования об
{ProjectName}\Common\Module\{ModuleName}\Infrastructure\Component\{Component}\Dto\{Name}Dto
```

- DTO доменного уровня (когда доменная операция возвращает структурированные данные):
- DTO доменного уровня (вход/выход конкретного Domain Service):

```
{ProjectName}\Common\Module\{ModuleName}\Domain\Dto\{Name}Dto
{ProjectName}\Common\Module\{ModuleName}\Domain\Service\{ServiceName}\{Name}Dto
```

## Как используем
Expand Down
20 changes: 20 additions & 0 deletions src/Sniffs/Structure/DtoStructureSniff.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ final class DtoStructureSniff implements Sniff
private const ERROR_CONSTRUCTOR_NOT_EMPTY = 'ConstructorMustBeEmpty';
private const ERROR_FORBIDDEN_MEMBERS = 'ForbiddenMembers';

private const ERROR_DOMAIN_DTO_PATH = 'DomainDtoPath';

private const DOC_REF = ' See: docs/conventions/core-patterns/dto.md';

public function register(): array
Expand All @@ -35,6 +37,8 @@ public function process(File $phpcsFile, $stackPtr): void
return;
}

$this->assertDomainDtoPath($phpcsFile, $stackPtr);

$this->assertFinalReadonly($phpcsFile, $stackPtr);

$scopeStart = $tokens[$stackPtr]['scope_opener'];
Expand All @@ -49,6 +53,22 @@ public function process(File $phpcsFile, $stackPtr): void
$this->assertNoMembers($phpcsFile, $stackPtr, $scopeStart, $scopeEnd);
}

private function assertDomainDtoPath(File $phpcsFile, int $classPtr): void
{
$normalizedPath = str_replace('\\', '/', $phpcsFile->getFilename());

if (str_contains($normalizedPath, '/Domain/Dto/') === false) {
return;
}

$phpcsFile->addError(
'Domain DTOs must be placed inside a Service context:'
. ' Domain/Service/{ServiceName}/{Name}Dto, not Domain/Dto/{Name}Dto.' . self::DOC_REF,
$classPtr,
self::ERROR_DOMAIN_DTO_PATH,
);
}

private function assertFinalReadonly(File $phpcsFile, int $classPtr): void
{
$properties = $phpcsFile->getClassProperties($classPtr);
Expand Down
7 changes: 5 additions & 2 deletions src/Sniffs/Structure/ServiceStructureSniff.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,11 @@ private function assertCompanionClassAllowed(
string $className,
string $normalizedPath,
): void {
if (str_ends_with($className, 'Helper') || str_ends_with($className, 'Factory')) {
return;
$allowedSuffixes = ['Helper', 'Factory', 'Dto'];
foreach ($allowedSuffixes as $suffix) {
if (str_ends_with($className, $suffix)) {
return;
}
}

$directory = dirname($normalizedPath);
Expand Down
2 changes: 1 addition & 1 deletion tests/MdLinksValidator/MdLinksValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ public function testConfigFileIsLoadedFromProjectRoot(): void

$this->assertSame(0, $result['exitCode'],
"validate-md-links should load .md-links.php from project root:\n" . $result['output']);
$this->assertStringContainsString('88 markdown files', $result['output']);
$this->assertStringContainsString('markdown files', $result['output']);
}

public function testConfigOptionOverridesDefaultConfigFile(): void
Expand Down
14 changes: 14 additions & 0 deletions tests/fixtures.php
Original file line number Diff line number Diff line change
Expand Up @@ -311,4 +311,18 @@
'errors' => [],
'warnings' => [],
],
// DtoStructureSniff — Domain DTO in Domain/Dto/ — wrong path
[
'file' => __DIR__ . '/fixtures/src/Module/Example/Domain/Dto/WrongPathDto.inc',
'errors' => [
6 => 1,
],
'warnings' => [],
],
// DtoStructureSniff — Domain DTO in Domain/Service/{Context}/ — correct path
[
'file' => __DIR__ . '/fixtures/src/Module/Example/Domain/Service/Payment/InitPaymentResultDto.inc',
'errors' => [],
'warnings' => [],
],
];
12 changes: 12 additions & 0 deletions tests/fixtures/src/Module/Example/Domain/Dto/WrongPathDto.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

// Domain DTO in Domain/Dto/ — wrong path, should be Domain/Service/{Context}/
namespace App\Module\Example\Domain\Dto;

final readonly class WrongPathDto
{
public function __construct(
public int $amount,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

// Domain DTO in Domain/Service/{Context}/ — correct path
namespace App\Module\Example\Domain\Service\Payment;

final readonly class InitPaymentResultDto
{
public function __construct(
public int $amount,
) {
}
}
95 changes: 95 additions & 0 deletions todo/backlog/TASK-sniff-dto-path-validation.todo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
type: feat
created: 2026-05-23
value: V2
complexity: C2
priority: P2
cost_plan:
cost_fact:
depends_on:
epic:
author: Dev (Pi)
assignee:
branch:
pr:
status: backlog
---

# TASK-sniff-dto-path-validation: Валидация путей DTO для всех слоёв

## 0. Простое описание (Human Brief)

### Проблема простыми словами (Problem)
- DtoStructureSniff проверяет структуру DTO (final readonly, нет методов), но не проверяет, лежат ли DTO в правильных директориях.
- Агенты могут разместить DTO в произвольной папке, и sniff это пропустит.
- Для Domain-слоя проверка уже добавлена (запрет `Domain/Dto/`), но Application, Infrastructure, Integration и Presentation не покрыты.

### Варианты или путь решения (Solution Sketch)
- Добавить в DtoStructureSniff проверку путей DTO по конвенции для каждого слоя.
- Использовать существующий подход: анализировать нормализованный путь файла и сопоставлять с разрешёнными паттернами.

### Ожидаемый результат (Expected Result)
- DTO вне разрешённых путей вызывают ошибку sniff с указанием правильного расположения.
- Агенты не могут разместить DTO в произвольной директории без замечания.

## 1. Concept and Goal (Концепция и Цель)

### Story (User Story)
> Как разработчик, я хочу чтобы sniff автоматически проверял расположение DTO в правильных namespace, чтобы агенты не размещали DTO в произвольных директориях.

### Goal (Цель по SMART)
- **S:** Реализовать валидацию путей DTO в DtoStructureSniff для слоёв Application, Infrastructure, Integration.
- **M:** Неверные пути вызывают ошибку sniff, правильные проходят. Тест-фикстуры покрывают каждый слой.
- **A:** Механизм уже работает для Domain — расширить подход на остальные слои.
- **R:** Предотвращает некорректное размещение DTO агентами.
- **T:** Одна задача, ограниченный scope.

## 2. Context and Scope (Контекст и Границы)

- **Где делаем:** `src/Sniffs/Structure/DtoStructureSniff.php`
- **Текущее поведение:** Sniff проверяет структуру DTO и путь для Domain (запрет `Domain/Dto/`). Остальные слои не проверяются.
- **Границы (Out of Scope):** Domain DTO — уже реализовано. Presentation DTO — отдельно (apps/, другая структура путей).

## 3. Requirements (Требования, MoSCoW)

### 🔴 Must Have (Обязательно)
- [ ] Application DTO: `{Module}\Application\Dto\{Name}Dto` или `{Module}\Application\UseCase\{Command|Query}\{Case}\{Name}Dto`
- [ ] Integration DTO: `{Module}\Integration\Component\{Component}\Dto\{Name}Dto`
- [ ] Infrastructure DTO: `{Module}\Infrastructure\Component\{Component}\Dto\{Name}Dto`
- [ ] Общие DTO: `Common\Application\Dto\{Name}Dto`

### 🟡 Should Have (Желательно)
- [ ] Presentation DTO: `apps/.../Dto/` (Request DTO, Query DTO, Response DTO)

### ⚫ Won't Have (Не будем делать)
- [ ] Domain DTO — уже реализовано
- [ ] Миграция существующих DTO — только sniff

## 4. Implementation Plan (План реализации)
*Заполняется исполнителем перед стартом.*

## 5. Definition of Done (Критерии приёмки)
- [ ] Неверные пути DTO ловятся ошибкой для каждого слоя
- [ ] Правильные пути проходят без ошибок
- [ ] Тест-фикстуры для каждого слоя
- [ ] `composer check` пройден

## 6. Verification (Самопроверка)
```bash
composer check
```

## 7. Risks and Dependencies (Риски и зависимости)
- Может потребоваться обновление `phpstan-baseline.neon`
- Presentation DTO живут в `apps/` — sniff работает с `src/Module/`, может потребоваться расширение

## 8. Sources (Источники)
- [dto.md](../../docs/conventions/core-patterns/dto.md)

## 9. Comments (Комментарии)
Доменные DTO уже проверяются: `assertDomainDtoPath` в DtoStructureSniff запрещает `Domain/Dto/`.

## Change History (История изменений)
| Дата | Автор (роль) | Изменение |
| :--- | :--- | :--- |
| 2026-05-23 | Dev (Pi) | Создание задачи |
Loading