diff --git a/README.md b/README.md index 2bf1daab..9d5b3415 100644 --- a/README.md +++ b/README.md @@ -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 добавляет событие или обосновывает отклонение. diff --git a/docs/conventions/ops/quality-metrics.md b/docs/conventions/ops/quality-metrics.md index d91f5671..835966f6 100644 --- a/docs/conventions/ops/quality-metrics.md +++ b/docs/conventions/ops/quality-metrics.md @@ -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-дашборд создаётся рядом со снимком и является представлением для человека, а не входом сравнения. @@ -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`. diff --git a/src/Metrics/MetricsComparison.php b/src/Metrics/MetricsComparison.php index 6a366734..b4c7d093 100644 --- a/src/Metrics/MetricsComparison.php +++ b/src/Metrics/MetricsComparison.php @@ -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) { @@ -314,16 +324,28 @@ 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 $changedPaths */ - private function changedArea(string $kind, string $sourcePath, array $changedPaths): bool + /** + * @param list $sourcePaths + * @param list $changedPaths + */ + private function changedArea(string $kind, string $sourcePath, array $sourcePaths, array $changedPaths): bool { if ($changedPaths === []) { return false; @@ -331,15 +353,48 @@ private function changedArea(string $kind, string $sourcePath, array $changedPat 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 $sourcePaths + * @param list $changedPaths + * @return list + */ + 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 $object @return list */ + 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 $paths @return list */ diff --git a/src/Metrics/MetricsReportWriter.php b/src/Metrics/MetricsReportWriter.php index c87eb59b..7c57192d 100644 --- a/src/Metrics/MetricsReportWriter.php +++ b/src/Metrics/MetricsReportWriter.php @@ -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'] ?? ''); @@ -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) { @@ -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> $classes @return array> */ + 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> $modules @return array> */ private function modules(array $modules): array { diff --git a/tests/Metrics/MetricsComparisonTest.php b/tests/Metrics/MetricsComparisonTest.php index e65cf756..c42cfef3 100644 --- a/tests/Metrics/MetricsComparisonTest.php +++ b/tests/Metrics/MetricsComparisonTest.php @@ -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 { @@ -116,7 +147,7 @@ public static function incompatibleSnapshots(): iterable * @param array> $classes * @return array */ - private function snapshot(array $classes): array + private function snapshot(array $classes, array $modules = []): array { return [ 'schema_version' => '1.0', @@ -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 $sourcePaths @return array */ + 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 $metrics @return array */ private function object(string $id, string $sourcePath, array $metrics): array { diff --git a/tests/Metrics/MetricsReportWriterTest.php b/tests/Metrics/MetricsReportWriterTest.php index 2d7dad93..435d3e29 100644 --- a/tests/Metrics/MetricsReportWriterTest.php +++ b/tests/Metrics/MetricsReportWriterTest.php @@ -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(); diff --git a/tests/Metrics/MetricsReviewPipelineTest.php b/tests/Metrics/MetricsReviewPipelineTest.php index 2ea06a6f..119d8891 100644 --- a/tests/Metrics/MetricsReviewPipelineTest.php +++ b/tests/Metrics/MetricsReviewPipelineTest.php @@ -40,7 +40,10 @@ protected function setUp(): void 'metadata' => ['project' => 'example/consumer', 'metric_definitions_version' => '1.0', 'configuration_hash' => 'sha256:configuration', 'input_hash' => hash('sha256', $source . $handlerSource), 'source_versions' => ['analyzer' => 'metrics-collector/1.0']], 'objects' => [ 'project' => ['example/consumer' => ['id' => 'example/consumer', 'source_path' => '.', 'metrics' => ['project' => ['loc' => $loc, 'command_handlers' => 1, 'command_handlers_without_event' => $handlerMissing]], 'attributes' => []]], - 'module' => [], + 'module' => [ + 'Main' => ['id' => 'Main', 'source_path' => '', 'metrics' => ['cohesion' => $loc === 16 ? 0.7 : 0.8], 'attributes' => ['source_paths' => ['src/Foo.php', 'src/Handler.php']]], + 'Untouched' => ['id' => 'Untouched', 'source_path' => '', 'metrics' => ['outgoing_dependencies' => $loc === 16 ? 2 : 1], 'attributes' => ['source_paths' => ['src/Untouched.php']]], + ], 'class' => [ 'App\\Foo' => ['id' => 'App\\Foo', 'source_path' => 'src/Foo.php', 'metrics' => ['loc' => $loc], 'attributes' => ['kind' => 'class', 'module' => 'Main']], 'App\\Handler' => ['id' => 'App\\Handler', 'source_path' => 'src/Handler.php', 'metrics' => ['missing_event_dispatch' => $handlerMissing], 'attributes' => ['kind' => 'class', 'module' => 'Main']], @@ -92,6 +95,10 @@ public function testBuildsAReproducibleReviewArtifactFromMergeBaseAndCurrentSnap $comparison = $this->json($output . '/comparison.json'); self::assertTrue($comparison['scopes']['class']['changed'][0]['changed_area']); self::assertSame('regressed', $comparison['scopes']['method']['changed'][0]['metric_changes'][0]['direction']); + $modules = array_column($comparison['scopes']['module']['changed'], null, 'id'); + self::assertTrue($modules['Main']['changed_area']); + self::assertSame(['src/Foo.php', 'src/Handler.php'], $modules['Main']['matched_changed_paths']); + self::assertFalse($modules['Untouched']['changed_area']); $handler = null; foreach ($comparison['scopes']['class']['changed'] as $changed) { if ($changed['id'] === 'App\\Handler') { @@ -110,7 +117,10 @@ public function testBuildsAReproducibleReviewArtifactFromMergeBaseAndCurrentSnap self::assertNotNull($projectMetric); self::assertSame('regressed', $projectMetric['direction']); self::assertSame(1, $projectMetric['delta']); - self::assertStringContainsString('command_handlers_without_event', (string) file_get_contents($output . '/summary.md')); + $summary = (string) file_get_contents($output . '/summary.md'); + self::assertStringContainsString('command_handlers_without_event', $summary); + self::assertStringContainsString('`module` `Main` (`cohesion`', $summary); + self::assertStringNotContainsString('`module` `Untouched`', $summary); $firstHash = $this->directoryHash($output); $pipeline->run('HEAD^', 'HEAD', 'var/metrics-review'); diff --git a/todo/TASK-metrics-module-changed-area-detection.todo.md b/todo/done/TASK-metrics-module-changed-area-detection.todo.md similarity index 83% rename from todo/TASK-metrics-module-changed-area-detection.todo.md rename to todo/done/TASK-metrics-module-changed-area-detection.todo.md index 9c1537c4..ce2f0043 100644 --- a/todo/TASK-metrics-module-changed-area-detection.todo.md +++ b/todo/done/TASK-metrics-module-changed-area-detection.todo.md @@ -2,8 +2,8 @@ type: fix created: 2026-08-27 03:36:53 (1787801813) due: -started: -completed: +started: 2026-08-27 14:53:57 (1787842437) +completed: 2026-08-27 15:14:07 (1787843647) cancelled: value: V3 complexity: C2 @@ -13,10 +13,10 @@ cost_fact: depends_on: epic: author: Аналитик (pi) -assignee: -branch: -pr: -status: todo +assignee: Разработчик (pi) +branch: task/metrics-module-changed-area-detection +pr: https://github.com/prikotov/coding-standard/pull/120 +status: done --- # TASK-metrics-module-changed-area-detection: корректно определять изменённую область для модульных метрик @@ -53,16 +53,16 @@ status: todo ## 3. Требования, MoSCoW (Requirements) ### 🔴 Обязательно (Must Have) -- [ ] При изменении файла, принадлежащего модулю, соответствующее изменение module scope получает `changed_area=true`, даже если у агрегата нет единственного `source_path`. -- [ ] При изменении файла другого модуля незатронутый модуль не получает ложный `changed_area=true`. -- [ ] Поведение корректно для добавленных, изменённых и удалённых модулей с использованием состава current/baseline snapshot (текущего/базового снимка). -- [ ] Модульная регрессия с `changed_area=true` попадает в раздел регрессий изменённой области `summary.md`. -- [ ] Формат снимка остаётся детерминированным; если меняется его схема или атрибуты, совместимость версий обрабатывается явно. -- [ ] Добавлен автоматический сценарий проекта-потребителя, где изменён файл модуля и проверены `comparison.json` и `summary.md`. +- [x] При изменении файла, принадлежащего модулю, соответствующее изменение module scope получает `changed_area=true`, даже если у агрегата нет единственного `source_path`. +- [x] При изменении файла другого модуля незатронутый модуль не получает ложный `changed_area=true`. +- [x] Поведение корректно для добавленных, изменённых и удалённых модулей с использованием состава current/baseline snapshot (текущего/базового снимка). +- [x] Модульная регрессия с `changed_area=true` попадает в раздел регрессий изменённой области `summary.md`. +- [x] Формат снимка остаётся детерминированным; если меняется его схема или атрибуты, совместимость версий обрабатывается явно. +- [x] Добавлен автоматический сценарий проекта-потребителя, где изменён файл модуля и проверены `comparison.json` и `summary.md`. ### 🟡 Желательно (Should Have) -- [ ] Для модулей, распределённых по нескольким каталогам, принадлежность определяется по всем входящим объектам, а не по угаданному общему префиксу. -- [ ] В `comparison.json` сохраняется достаточно данных, чтобы объяснить, почему модуль признан частью изменённой области. +- [x] Для модулей, распределённых по нескольким каталогам, принадлежность определяется по всем входящим объектам, а не по угаданному общему префиксу. +- [x] В `comparison.json` сохраняется достаточно данных, чтобы объяснить, почему модуль признан частью изменённой области. ### 🟢 Опционально (Could Have) - [ ] Добавить диагностическое сообщение для модуля без объектов и без определяемого пути. @@ -75,20 +75,20 @@ status: todo ## 4. План реализации (Implementation Plan) -1. [ ] Добавить воспроизводящий тест: module scope с пустым `source_path`, классом этого модуля и путём класса в `changed_paths`. -2. [ ] Выбрать минимальное представление принадлежности модуля к исходным файлам и реализовать его для baseline/current snapshot. -3. [ ] Покрыть изменённый, добавленный, удалённый и незатронутый модуль, включая формирование `summary.md`. -4. [ ] Добавить потребительский интеграционный сценарий и при изменении контракта обновить README и конвенцию метрик. -5. [ ] Проверить пайплайн на проекте-потребителе и выполнить `composer check`. +1. [x] Добавить воспроизводящий тест: module scope с пустым `source_path`, классом этого модуля и путём класса в `changed_paths`. +2. [x] Выбрать минимальное представление принадлежности модуля к исходным файлам и реализовать его для baseline/current snapshot. +3. [x] Покрыть изменённый, добавленный, удалённый и незатронутый модуль, включая формирование `summary.md`. +4. [x] Добавить потребительский интеграционный сценарий и при изменении контракта обновить README и конвенцию метрик. +5. [x] Проверить пайплайн на проекте-потребителе и выполнить `composer check`. ## 5. Критерии приёмки (Definition of Done) -- [ ] В воспроизводящем сценарии `Common:TInvest` или эквивалентный модуль получает `changed_area=true` без ручной классификации. -- [ ] Регрессия этого модуля присутствует в `summary.md` в изменённой области. -- [ ] Контрольный незатронутый модуль остаётся с `changed_area=false`. -- [ ] Unit tests (модульные тесты) и потребительский интеграционный тест проходят. -- [ ] Документация обновлена, если изменился контракт снимка или сравнения. -- [ ] `composer check` проходит. +- [x] В воспроизводящем сценарии `Common:TInvest` или эквивалентный модуль получает `changed_area=true` без ручной классификации. +- [x] Регрессия этого модуля присутствует в `summary.md` в изменённой области. +- [x] Контрольный незатронутый модуль остаётся с `changed_area=false`. +- [x] Unit tests (модульные тесты) и потребительский интеграционный тест проходят. +- [x] Документация обновлена, если изменился контракт снимка или сравнения. +- [x] `composer check` проходит. ## 6. Самопроверка (Verification) @@ -109,8 +109,8 @@ php vendor/bin/todo-md validate todo/TASK-metrics-module-changed-area-detection. ## 8. Источники (Sources) - [x] [PR `prikotov/stocks2#198`](https://github.com/prikotov/stocks2/pull/198) — фактическое обнаружение проблемы. -- [x] [`TASK-metrics-pr-review-workflow`](done/TASK-metrics-pr-review-workflow.todo.md) — контракт выделения изменённой области при ревью. -- [x] [Конвенция метрик качества](../docs/conventions/ops/quality-metrics.md). +- [x] [`TASK-metrics-pr-review-workflow`](TASK-metrics-pr-review-workflow.todo.md) — контракт выделения изменённой области при ревью. +- [x] [Конвенция метрик качества](../../docs/conventions/ops/quality-metrics.md). ## 9. Комментарии (Comments) @@ -121,3 +121,4 @@ php vendor/bin/todo-md validate todo/TASK-metrics-module-changed-area-detection. | Дата | Автор (роль) | Изменение | | :--- | :--- | :--- | | 2026-08-27 03:36:53 (1787801813) | Аналитик (pi) | Создание задачи по результатам анализа метрик PR `prikotov/stocks2#198`. | +| 2026-08-27 21:57:51 (1787842671) | Разработчик (pi) | Реализовано сопоставление модулей по `source_paths`, добавлены тесты и документация; `composer check` и сбор метрик TasK прошли. |