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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ vendor/bin/coding-standard-metrics
vendor/bin/coding-standard-metrics-review --base=origin/master
```

`coding-standard-metrics` создаёт компактный `var/metrics/snapshot.json` и `var/metrics/index.html`. Команда review создаёт временный Git worktree на merge-base, повторно собирает baseline и записывает дельту в `var/metrics-review/comparison.json` и краткое резюме в `summary.md`. Агент читает дельту до создания PR; GitHub Actions может воспроизвести ту же команду, но не является источником результата.
`coding-standard-metrics` создаёт компактный `var/metrics/snapshot.json` и `var/metrics/index.html`. Команда review создаёт временный Git worktree на merge-base, повторно собирает baseline и записывает дельту в `var/metrics-review/comparison.json` и краткое резюме в `summary.md`. Модуль считается частью изменённой области, если diff содержит путь хотя бы одного входящего в него класса; совпавшие пути перечислены в `matched_changed_paths`. Агент читает дельту до создания PR; GitHub Actions может воспроизвести ту же команду, но не является источником результата.

Среди метрик — `project.command_handlers_without_event`: количество CommandHandler'ов без диспетчеризованного события `*Event` (правило — [конвенция Command Handler](docs/conventions/layers/application/command-handler.md)). Рост счётчика в дельте помечается регрессией: автор PR добавляет событие или обосновывает отклонение.

Expand Down
2 changes: 2 additions & 0 deletions docs/conventions/ops/quality-metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ description: Единая модель метрик поддерживаемос
- Для покрытия выполняется только PHPUnit-suite из `metrics.phpunit_suite` (по умолчанию `unit`); интеграционные suite не запускаются.
- `vendor/bin/coding-standard-metrics` записывает компактный снимок в `metrics.work_dir/snapshot.json`; по умолчанию это `var/metrics/snapshot.json`.
- Снимок содержит `metadata` и объекты уровней `project`, `module`, `class` и `method`. Поля объектов — идентификатор, относительный путь, атрибуты и метрики, необходимые для сравнения.
- Атрибут `source_paths` объекта модуля содержит отсортированные относительные пути всех входящих классов. Он дополняет необязательный единый `source_path` и позволяет сопоставлять с diff распределённые и удалённые модули.
- Снимок не содержит зеркала путей, отчётов промежуточных каталогов, ссылок `children` и дублированных `findings`.
- HTML-дашборд создаётся рядом со снимком и является представлением для человека, а не входом сравнения.

Expand All @@ -63,6 +64,7 @@ description: Единая модель метрик поддерживаемос
- Команда создаёт временный Git worktree на merge-base, собирает baseline и current одинаковым пайплайном, затем удаляет временный worktree.
- Результат — локальная дельта `var/metrics-review/comparison.json`, `summary.md` и `reproduction.json`; она не коммитится и не зависит от GitHub Actions.
- Для каждого уровня `project`, `module`, `class` и `method` дельта содержит `added`, `removed`, `changed` и `unchanged_count`. Изменённая метрика содержит `before`, `after`, `delta`, `direction` и `informational`.
- Модуль входит в изменённую область, когда путь хотя бы одного его класса из текущего или базового снимка присутствует в diff. Поле `matched_changed_paths` дельты объясняет совпадение; для старого снимка без `source_paths` используется единый `source_path` модуля.
- `improved` означает уменьшение `CC`, `WMC`, `LCOM4`, `Ca`, `Ce`, внешней связанности или циклов либо рост связности модуля и покрытия. Обратное изменение получает `regressed`.
- Общий размер, количество объектов, тестов и строк, churn, входящие зависимости модуля и изменения списков имеют направление `neutral`.
- `project.command_handlers_without_event` — количество CommandHandler'ов без диспетчеризации события (`dispatch`); рост помечается `regressed`, класс-флаг — `missing_event_dispatch` (см. [Command Handler](../layers/application/command-handler.md)). Общее число хендлеров `project.command_handlers` — `neutral`.
Expand Down
93 changes: 74 additions & 19 deletions src/Metrics/MetricsComparison.php
Original file line number Diff line number Diff line change
Expand Up @@ -148,17 +148,27 @@ private function compareObjects(
$unchanged++;
continue;
}
$changed[] = [
$sourcePath = (string) ($currentObject['source_path'] ?? $baselineObject['source_path'] ?? '');
$sourcePaths = array_values(array_unique([
...$this->sourcePaths($baselineObject),
...$this->sourcePaths($currentObject),
]));
sort($sourcePaths);
$changedObject = [
'id' => $identifier,
'source_path' => $currentObject['source_path'] ?? $baselineObject['source_path'] ?? null,
'changed_area' => $this->changedArea(
$kind,
(string) ($currentObject['source_path'] ?? $baselineObject['source_path'] ?? ''),
$changedPaths,
),
'source_path' => $sourcePath,
'changed_area' => $this->changedArea($kind, $sourcePath, $sourcePaths, $changedPaths),
'attribute_changes' => $attributeChanges,
'metric_changes' => $metricChanges,
];
if ($kind === 'module') {
$changedObject['matched_changed_paths'] = $this->matchedChangedPaths(
$sourcePath,
$sourcePaths,
$changedPaths,
);
}
$changed[] = $changedObject;
}

foreach ([$added, $removed, $changed] as &$items) {
Expand Down Expand Up @@ -314,32 +324,77 @@ private function valueChanges(array $before, array $after): array
private function objectReference(string $kind, array $object, array $changedPaths): array
{
$sourcePath = (string) ($object['source_path'] ?? '');

return [
$sourcePaths = $this->sourcePaths($object);
$reference = [
'id' => $object['id'] ?? null,
'source_path' => $sourcePath,
'changed_area' => $this->changedArea($kind, $sourcePath, $changedPaths),
'changed_area' => $this->changedArea($kind, $sourcePath, $sourcePaths, $changedPaths),
];
if ($kind === 'module') {
$reference['matched_changed_paths'] = $this->matchedChangedPaths(
$sourcePath,
$sourcePaths,
$changedPaths,
);
}

return $reference;
}

/** @param list<string> $changedPaths */
private function changedArea(string $kind, string $sourcePath, array $changedPaths): bool
/**
* @param list<string> $sourcePaths
* @param list<string> $changedPaths
*/
private function changedArea(string $kind, string $sourcePath, array $sourcePaths, array $changedPaths): bool
{
if ($changedPaths === []) {
return false;
}
if ($kind === 'project') {
return true;
}
foreach ($changedPaths as $path) {
$insideModule = $kind === 'module'
&& str_starts_with($path, rtrim($sourcePath, '/') . '/');
if ($path === $sourcePath || $insideModule) {
return true;
}
if ($kind === 'module') {
return $this->matchedChangedPaths($sourcePath, $sourcePaths, $changedPaths) !== [];
}

return false;
return in_array($sourcePath, $changedPaths, true);
}

/**
* @param list<string> $sourcePaths
* @param list<string> $changedPaths
* @return list<string>
*/
private function matchedChangedPaths(string $sourcePath, array $sourcePaths, array $changedPaths): array
{
if ($sourcePaths !== []) {
return array_values(array_intersect($changedPaths, $sourcePaths));
}
if ($sourcePath === '') {
return [];
}

return array_values(array_filter(
$changedPaths,
static fn (string $path): bool => $path === $sourcePath
|| str_starts_with($path, rtrim($sourcePath, '/') . '/'),
));
}

/** @param array<string, mixed> $object @return list<string> */
private function sourcePaths(array $object): array
{
$sourcePaths = $this->attributes($object)['source_paths'] ?? [];
if (!is_array($sourcePaths)) {
return [];
}
$paths = array_values(array_filter(
$sourcePaths,
static fn (mixed $path): bool => is_string($path) && $path !== '',
));
sort($paths);

return array_values(array_unique($paths));
}

/** @param list<string> $paths @return list<string> */
Expand Down
24 changes: 23 additions & 1 deletion src/Metrics/MetricsReportWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public function writeSnapshot(string $output, array $full): void
$classes = $this->records($metrics, 'classes');
$methods = $this->records($metrics, 'methods');
$modules = $this->modules($this->records($metrics, 'modules'));
$moduleSourcePaths = $this->moduleSourcePaths($classes);
$objects = ['project' => [], 'module' => [], 'class' => [], 'method' => []];
$metadata = $this->metadata($full);
$project = (string) ($metadata['project'] ?? '');
Expand All @@ -35,7 +36,7 @@ public function writeSnapshot(string $output, array $full): void
'id' => $identifier,
'source_path' => (string) ($module['path'] ?? ''),
'metrics' => array_diff_key($module, ['id' => true, 'path' => true]),
'attributes' => [],
'attributes' => ['source_paths' => $moduleSourcePaths[$identifier] ?? []],
];
}
foreach ($classes as $class) {
Expand Down Expand Up @@ -326,6 +327,27 @@ private function percentile(array $values, float $percentile): int|float|null
return $values[$lower] + ($values[$upper] - $values[$lower]) * ($index - $lower);
}

/** @param list<array<string, mixed>> $classes @return array<string, list<string>> */
private function moduleSourcePaths(array $classes): array
{
$paths = [];
foreach ($classes as $class) {
$module = $class['module'] ?? null;
if (!is_string($module) || $module === '') {
continue;
}
$paths[$module][$this->sourcePath($class['file'] ?? null)] = true;
}
foreach ($paths as &$modulePaths) {
$modulePaths = array_keys($modulePaths);
sort($modulePaths);
}
unset($modulePaths);
ksort($paths);

return $paths;
}

/** @param list<array<string, mixed>> $modules @return array<string, array<string, mixed>> */
private function modules(array $modules): array
{
Expand Down
46 changes: 44 additions & 2 deletions tests/Metrics/MetricsComparisonTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,37 @@ public function testMarksGrowthOfCommandHandlersWithoutEventAsRegression(): void
self::assertSame(1, $result['summary']['neutral_metric_count']);
}

public function testMarksModuleLifecycleAndRegressionByMemberSourcePaths(): void
{
$baseline = $this->snapshot([], [
'Changed' => $this->module('Changed', 1, ['apps/api/src/Changed.php']),
'Removed' => $this->module('Removed', 1, ['packages/shared/Removed.php']),
'Unrelated' => $this->module('Unrelated', 1, ['src/Unrelated.php']),
]);
$current = $this->snapshot([], [
'Added' => $this->module('Added', 1, ['apps/worker/src/Added.php']),
'Changed' => $this->module('Changed', 2, ['apps/api/src/Changed.php']),
'Unrelated' => $this->module('Unrelated', 2, ['src/Unrelated.php']),
]);

$result = (new MetricsComparison())->compare($baseline, $current, [
'apps/api/src/Changed.php',
'apps/worker/src/Added.php',
'packages/shared/Removed.php',
]);
$modules = $result['scopes']['module'];

self::assertTrue($modules['added'][0]['changed_area']);
self::assertSame(['apps/worker/src/Added.php'], $modules['added'][0]['matched_changed_paths']);
self::assertTrue($modules['removed'][0]['changed_area']);
self::assertSame(['packages/shared/Removed.php'], $modules['removed'][0]['matched_changed_paths']);
self::assertTrue($modules['changed'][0]['changed_area']);
self::assertSame(['apps/api/src/Changed.php'], $modules['changed'][0]['matched_changed_paths']);
self::assertSame('regressed', $modules['changed'][0]['metric_changes'][0]['direction']);
self::assertFalse($modules['changed'][1]['changed_area']);
self::assertSame([], $modules['changed'][1]['matched_changed_paths']);
}

#[DataProvider('incompatibleSnapshots')]
public function testRejectsIncompatibleSnapshots(string $field, mixed $value): void
{
Expand Down Expand Up @@ -116,7 +147,7 @@ public static function incompatibleSnapshots(): iterable
* @param array<string, array<string, mixed>> $classes
* @return array<string, mixed>
*/
private function snapshot(array $classes): array
private function snapshot(array $classes, array $modules = []): array
{
return [
'schema_version' => '1.0',
Expand All @@ -128,13 +159,24 @@ private function snapshot(array $classes): array
],
'objects' => [
'project' => ['example/project' => $this->object('example/project', '.', [])],
'module' => [],
'module' => $modules,
'class' => $classes,
'method' => [],
],
];
}

/** @param list<string> $sourcePaths @return array<string, mixed> */
private function module(string $id, int $outgoingDependencies, array $sourcePaths): array
{
return [
'id' => $id,
'source_path' => '',
'attributes' => ['source_paths' => $sourcePaths],
'metrics' => ['outgoing_dependencies' => $outgoingDependencies],
];
}

/** @param array<string, mixed> $metrics @return array<string, mixed> */
private function object(string $id, string $sourcePath, array $metrics): array
{
Expand Down
31 changes: 31 additions & 0 deletions tests/Metrics/MetricsReportWriterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,37 @@ public function testWritesProjectDirectoryAndFileReportsInMirror(): void
self::assertSame('Metrics', $module['metrics']['module']['id']);
}

public function testWritesSortedMemberSourcePathsForSnapshotModules(): void
{
$directory = sys_get_temp_dir() . '/metrics-writer-snapshot-' . uniqid();
$output = $directory . '/snapshot.json';
(new MetricsReportWriter())->writeSnapshot($output, [
'metadata' => ['project' => 'example/project'],
'metrics' => [
'modules' => [
['id' => 'Distributed', 'class_count' => 2],
['id' => 'Empty', 'class_count' => 0],
],
'classes' => [
['id' => 'App\\Second', 'file' => 'packages/shared/Second.php', 'module' => 'Distributed'],
['id' => 'App\\First', 'file' => 'apps/api/src/First.php', 'module' => 'Distributed'],
],
'methods' => [],
],
]);

try {
$snapshot = json_decode((string) file_get_contents($output), true, flags: JSON_THROW_ON_ERROR);
self::assertSame(
['apps/api/src/First.php', 'packages/shared/Second.php'],
$snapshot['objects']['module']['Distributed']['attributes']['source_paths'],
);
self::assertSame([], $snapshot['objects']['module']['Empty']['attributes']['source_paths']);
} finally {
$this->removeDirectory($directory);
}
}

public function testWritesApplicationModuleReportAtItsModuleDirectory(): void
{
$directory = sys_get_temp_dir() . '/metrics-writer-module-' . uniqid();
Expand Down
Loading
Loading