From 4c662a038a94bec1f8b59f2c50bc012c5886d0a8 Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 09:59:46 -0300 Subject: [PATCH 01/21] feat(assertions): implement TestQuality + ParallelSafety assertion traits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two shipped traits previously threw RuntimeException('Not yet implemented') in all 7 methods while CHANGELOG/README marketed them as working — a correctness bug wired into the published TestQualityTest stub. Implement them via a framework-free AntiPatternScanner (Symfony Finder + localized regex) that walks tests/ + database/factories/ honouring an allowlist, ported from Arch's inline grep-based reference. Traits become thin adapters that fail the test (PHPUnit Assert) on violations. - AntiPatternScanner: 7 checks (tautological assertions, Eloquent alias mocking, bare assertNotNull, truncate/forceDelete in tests, DB queries + eager create() in Factory::definition with comment/string stripping) - ResolvesAntiPatternScanner concern shared by both traits - 21 unit tests against hermetic temp fixtures (no Laravel boot) - Drop 2 now-stale trait.unused baseline entries (traits are live again) Suite 377 -> 398 green. PHPStan level 5 clean. --- phpstan-baseline.neon | 12 - src/Assertions/AntiPatternScanner.php | 319 ++++++++++++++++++ .../Concerns/ResolvesAntiPatternScanner.php | 28 ++ src/Assertions/ParallelSafetyAssertions.php | 53 ++- src/Assertions/TestQualityAssertions.php | 43 ++- .../Assertions/AntiPatternScannerTest.php | 317 +++++++++++++++++ tests/Unit/Assertions/AssertionTraitsTest.php | 168 +++++++++ 7 files changed, 896 insertions(+), 44 deletions(-) create mode 100644 src/Assertions/AntiPatternScanner.php create mode 100644 src/Assertions/Concerns/ResolvesAntiPatternScanner.php create mode 100644 tests/Unit/Assertions/AntiPatternScannerTest.php create mode 100644 tests/Unit/Assertions/AssertionTraitsTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 2e97319..c306c75 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,17 +1,5 @@ parameters: ignoreErrors: - - - message: '#^Trait Henryavila\\Codeguard\\Assertions\\ParallelSafetyAssertions is used zero times and is not analysed\.$#' - identifier: trait.unused - count: 1 - path: src/Assertions/ParallelSafetyAssertions.php - - - - message: '#^Trait Henryavila\\Codeguard\\Assertions\\TestQualityAssertions is used zero times and is not analysed\.$#' - identifier: trait.unused - count: 1 - path: src/Assertions/TestQualityAssertions.php - - message: '#^Strict comparison using \=\=\= between \(int\|string\) and null will always evaluate to false\.$#' identifier: identical.alwaysFalse diff --git a/src/Assertions/AntiPatternScanner.php b/src/Assertions/AntiPatternScanner.php new file mode 100644 index 0000000..a9dc530 --- /dev/null +++ b/src/Assertions/AntiPatternScanner.php @@ -0,0 +1,319 @@ + */ + private const TAUTOLOGICAL_PATTERNS = [ + '/expect\(\s*(?:true|false|null)\s*\)\s*->\s*(?:toBeTrue|toBeFalse|toBeNull)\(/', + '/->assertTrue\(\s*true\s*\)/', + '/->assertFalse\(\s*false\s*\)/', + '/->assertNull\(\s*null\s*\)/', + ]; + + /** @var list */ + private const ELOQUENT_MOCK_PATTERNS = [ + '/Mockery::mock\(\s*[\'"]alias:[A-Z]/', + ]; + + /** @var list */ + private const BARE_ASSERT_NOT_NULL_PATTERNS = [ + '/->assertNotNull\(\$[a-zA-Z_]+\)\s*;/', + ]; + + /** @var list */ + private const TRUNCATE_PATTERNS = [ + '/->truncate\(\s*\)/', + ]; + + /** @var list */ + private const FORCE_DELETE_PATTERNS = [ + '/->forceDelete\(\s*\)/', + ]; + + /** @var list */ + private const FACTORY_DB_QUERY_PATTERNS = [ + '/\bDB::(?:table|select|raw|connection|statement|insert|update)\(/', + '/[A-Z][a-zA-Z]+::query\(\)/', + '/[A-Z][a-zA-Z]+::where\(/', + '/[A-Z][a-zA-Z]+::find\(/', + '/[A-Z][a-zA-Z]+::first(?:OrCreate)?\(/', + ]; + + /** @var list */ + private const FACTORY_EAGER_CREATE_PATTERNS = [ + '/::factory\(\)\s*->\s*create\(/', + ]; + + /** + * @param string $basePath Project root (e.g. Laravel `base_path()`). + * @param string $testsDir Tests directory relative to the base path. + * @param list $excludeDirs Directory names under `$testsDir` to skip + * (the Arch test dir is excluded by default so + * these very patterns, written as string literals + * in the arch test, do not self-match). + * @param string $factoriesDir Model factories directory relative to the base path. + */ + public function __construct( + private readonly string $basePath, + private readonly string $testsDir = 'tests', + private readonly array $excludeDirs = ['Arch'], + private readonly string $factoriesDir = 'database/factories', + ) {} + + /** + * Assertions that can never fail (e.g. `expect(true)->toBeTrue()`). + * + * @param list $allowlist + * @return list + */ + public function tautologicalAssertions(array $allowlist = []): array + { + return $this->scanTestFiles( + self::TAUTOLOGICAL_PATTERNS, + 'tautological assertion — assert real state instead', + $allowlist, + ); + } + + /** + * Mockery alias-mocking of an Eloquent model. + * + * @param list $allowlist + * @return list + */ + public function eloquentModelMocking(array $allowlist = []): array + { + return $this->scanTestFiles( + self::ELOQUENT_MOCK_PATTERNS, + "Mockery::mock('alias:Model') — use a partial mock or container injection", + $allowlist, + ); + } + + /** + * `assertNotNull($var)` used as the only assertion on a value. + * + * @param list $allowlist + * @return list + */ + public function bareAssertNotNull(array $allowlist = []): array + { + return $this->scanTestFiles( + self::BARE_ASSERT_NOT_NULL_PATTERNS, + 'bare assertNotNull — follow with a behavioural assertion (or use expect()->not->toBeNull())', + $allowlist, + ); + } + + /** + * `->truncate()` in tests — leaks across parallel workers. + * + * @param list $allowlist + * @return list + */ + public function truncateInTests(array $allowlist = []): array + { + return $this->scanTestFiles( + self::TRUNCATE_PATTERNS, + 'truncate() in test — corrupts parallel workers + resets auto-increment', + $allowlist, + ); + } + + /** + * `->forceDelete()` in tests — commits state outside the test transaction. + * + * @param list $allowlist + * @return list + */ + public function forceDeleteInTests(array $allowlist = []): array + { + return $this->scanTestFiles( + self::FORCE_DELETE_PATTERNS, + 'forceDelete() in test — use ->delete() (soft-delete) for parallel safety', + $allowlist, + ); + } + + /** + * DB queries inside `Factory::definition()` — runs on every make()/create(). + * + * @param list $allowlist + * @return list + */ + public function dbQueriesInFactoryDefinition(array $allowlist = []): array + { + return $this->scanFactoryDefinitions( + self::FACTORY_DB_QUERY_PATTERNS, + 'DB query inside Factory::definition() — runs every make()/create(); pass via state instead', + $allowlist, + ); + } + + /** + * Eager `->create()` inside `Factory::definition()` — use lazy `Model::factory()`. + * + * @param list $allowlist + * @return list + */ + public function eagerCreateInFactoryDefinition(array $allowlist = []): array + { + return $this->scanFactoryDefinitions( + self::FACTORY_EAGER_CREATE_PATTERNS, + 'eager create() inside Factory::definition() — use Model::factory() lazy (no ->create())', + $allowlist, + ); + } + + /** + * @param list $patterns + * @param list $allowlist + * @return list + */ + private function scanTestFiles(array $patterns, string $message, array $allowlist): array + { + $violations = []; + + foreach ($this->testFiles() as $file) { + $relative = $this->testsDir.'/'.$this->normalizeSlashes($file->getRelativePathname()); + if (in_array($relative, $allowlist, true)) { + continue; + } + + if ($this->matchesAny($patterns, $this->readFile($file->getPathname()))) { + $violations[] = sprintf('%s: %s', $relative, $message); + } + } + + return $violations; + } + + /** + * @param list $patterns + * @param list $allowlist + * @return list + */ + private function scanFactoryDefinitions(array $patterns, string $message, array $allowlist): array + { + $violations = []; + + foreach ($this->factoryFiles() as $file) { + $relative = $this->factoriesDir.'/'.$this->normalizeSlashes($file->getRelativePathname()); + if (in_array($relative, $allowlist, true)) { + continue; + } + + $body = $this->extractDefinitionBody($this->readFile($file->getPathname())); + if ($body === null) { + continue; + } + + if ($this->matchesAny($patterns, $body)) { + $violations[] = sprintf('%s: %s', $relative, $message); + } + } + + return $violations; + } + + /** + * @return iterable + */ + private function testFiles(): iterable + { + $dir = $this->basePath.'/'.$this->testsDir; + if (! is_dir($dir)) { + return []; + } + + $finder = Finder::create()->files()->in($dir)->name('*.php')->sortByName(); + + if ($this->excludeDirs !== []) { + $finder->exclude($this->excludeDirs); + } + + return $finder; + } + + /** + * @return iterable + */ + private function factoryFiles(): iterable + { + $dir = $this->basePath.'/'.$this->factoriesDir; + if (! is_dir($dir)) { + return []; + } + + return Finder::create()->files()->in($dir)->name('*Factory.php')->sortByName(); + } + + /** + * Extracts the body of a Factory's `definition()` method, stripping + * comments and string literals so explanatory text or string content + * cannot trip a pattern. Returns null when there is no `definition()`. + */ + private function extractDefinitionBody(string $content): ?string + { + if (preg_match('/public function definition\(\)[^{]*\{(.+?)^\s*\}/sm', $content, $matches) !== 1) { + return null; + } + + $body = $matches[1]; + $body = preg_replace('/\/\*.*?\*\//s', '', $body) ?? $body; + $body = preg_replace('/\/\/[^\n]*/', '', $body) ?? $body; + $body = preg_replace('/(? $patterns + */ + private function matchesAny(array $patterns, string $haystack): bool + { + foreach ($patterns as $pattern) { + if (preg_match($pattern, $haystack) === 1) { + return true; + } + } + + return false; + } + + private function normalizeSlashes(string $path): string + { + return str_replace('\\', '/', $path); + } + + private function readFile(string $path): string + { + $content = file_get_contents($path); + if ($content === false) { + throw new RuntimeException(sprintf('Could not read file: %s', $path)); + } + + return $content; + } +} diff --git a/src/Assertions/Concerns/ResolvesAntiPatternScanner.php b/src/Assertions/Concerns/ResolvesAntiPatternScanner.php new file mode 100644 index 0000000..f84833b --- /dev/null +++ b/src/Assertions/Concerns/ResolvesAntiPatternScanner.php @@ -0,0 +1,28 @@ + $violations + */ + protected function formatAntiPatternViolations(string $headline, array $violations): string + { + return $headline."\n".implode("\n", $violations); + } +} diff --git a/src/Assertions/ParallelSafetyAssertions.php b/src/Assertions/ParallelSafetyAssertions.php index 6a65867..9d66708 100644 --- a/src/Assertions/ParallelSafetyAssertions.php +++ b/src/Assertions/ParallelSafetyAssertions.php @@ -4,6 +4,9 @@ namespace Henryavila\Codeguard\Assertions; +use Henryavila\Codeguard\Assertions\Concerns\ResolvesAntiPatternScanner; +use PHPUnit\Framework\Assert; + /** * Parallel-safety assertions — catches patterns that break parallel test * execution (Pest's `--parallel`) or cause cross-worker state leaks. @@ -11,35 +14,43 @@ * Intended to be `uses()`d in Pest Arch tests. See * resources/stubs/tests/Arch/TestQualityTest.php.stub for usage. * - * @internal Implementations are scheduled for a future wave — see - * https://github.com/henryavila/codeguard/issues for roadmap. + * Each method fails the surrounding test (via PHPUnit assertions) when it + * finds violations. Scanning logic lives in {@see AntiPatternScanner}. */ trait ParallelSafetyAssertions { + use ResolvesAntiPatternScanner; + /** * Assert no test calls `DB::table(...)->truncate()` or equivalent. * Truncation bypasses transaction rollback and leaks across workers. * - * @param array $allowlist FQCNs or file paths to skip + * @param list $allowlist relative paths to skip */ public function assertNoTruncateInTests(array $allowlist = []): void { - throw new \RuntimeException( - 'Not yet implemented — see https://github.com/henryavila/codeguard/issues for roadmap' - ); + $violations = $this->makeAntiPatternScanner()->truncateInTests($allowlist); + + Assert::assertSame([], $violations, $this->formatAntiPatternViolations( + 'truncate() calls in tests:', + $violations, + )); } /** * Assert no test calls `->forceDelete()`. forceDelete bypasses * soft-delete semantics and commits state outside the test transaction. * - * @param array $allowlist FQCNs or file paths to skip + * @param list $allowlist relative paths to skip */ public function assertNoForceDeleteInTests(array $allowlist = []): void { - throw new \RuntimeException( - 'Not yet implemented — see https://github.com/henryavila/codeguard/issues for roadmap' - ); + $violations = $this->makeAntiPatternScanner()->forceDeleteInTests($allowlist); + + Assert::assertSame([], $violations, $this->formatAntiPatternViolations( + 'forceDelete() calls in tests:', + $violations, + )); } /** @@ -47,25 +58,31 @@ public function assertNoForceDeleteInTests(array $allowlist = []): void * definition() runs on every `make()`/`create()` — queries there * cause N+1 explosions in test setup. * - * @param array $allowlist FQCNs or file paths to skip + * @param list $allowlist relative paths to skip */ public function assertNoDbQueriesInFactoryDefinition(array $allowlist = []): void { - throw new \RuntimeException( - 'Not yet implemented — see https://github.com/henryavila/codeguard/issues for roadmap' - ); + $violations = $this->makeAntiPatternScanner()->dbQueriesInFactoryDefinition($allowlist); + + Assert::assertSame([], $violations, $this->formatAntiPatternViolations( + 'DB queries in factory definition():', + $violations, + )); } /** * Assert no Factory::definition() eagerly calls `->create()` on * nested factories. Use the lazy `Model::factory()` form instead. * - * @param array $allowlist FQCNs or file paths to skip + * @param list $allowlist relative paths to skip */ public function assertNoEagerCreateInFactoryDefinition(array $allowlist = []): void { - throw new \RuntimeException( - 'Not yet implemented — see https://github.com/henryavila/codeguard/issues for roadmap' - ); + $violations = $this->makeAntiPatternScanner()->eagerCreateInFactoryDefinition($allowlist); + + Assert::assertSame([], $violations, $this->formatAntiPatternViolations( + 'Eager create() in factory definition():', + $violations, + )); } } diff --git a/src/Assertions/TestQualityAssertions.php b/src/Assertions/TestQualityAssertions.php index 4664c25..f53f660 100644 --- a/src/Assertions/TestQualityAssertions.php +++ b/src/Assertions/TestQualityAssertions.php @@ -4,6 +4,9 @@ namespace Henryavila\Codeguard\Assertions; +use Henryavila\Codeguard\Assertions\Concerns\ResolvesAntiPatternScanner; +use PHPUnit\Framework\Assert; + /** * Test-quality assertions — catches anti-patterns that silently erode * test value (tautologies, mocked Eloquent, bare null checks). @@ -11,47 +14,59 @@ * Intended to be `uses()`d in Pest Arch tests. See * resources/stubs/tests/Arch/TestQualityTest.php.stub for usage. * - * @internal Implementations are scheduled for a future wave — see - * https://github.com/henryavila/codeguard/issues for roadmap. + * Each method fails the surrounding test (via PHPUnit assertions) when it + * finds violations across the project's `tests/` directory. Scanning logic + * lives in {@see AntiPatternScanner}. */ trait TestQualityAssertions { + use ResolvesAntiPatternScanner; + /** * Assert no test contains assertions that can never fail * (e.g. `expect(true)->toBeTrue()`, `$this->assertTrue(true)`). * - * @param array $allowlist FQCNs or file paths to skip + * @param list $allowlist relative paths to skip */ public function assertNoTautologicalAssertions(array $allowlist = []): void { - throw new \RuntimeException( - 'Not yet implemented — see https://github.com/henryavila/codeguard/issues for roadmap' - ); + $violations = $this->makeAntiPatternScanner()->tautologicalAssertions($allowlist); + + Assert::assertSame([], $violations, $this->formatAntiPatternViolations( + 'Tautological assertions detected (assert real state instead):', + $violations, + )); } /** * Assert no test mocks an Eloquent model class. Mocking Eloquent * couples tests to ORM internals; prefer factories + SQLite in-memory. * - * @param array $allowlist FQCNs or file paths to skip + * @param list $allowlist relative paths to skip */ public function assertNoEloquentModelMocking(array $allowlist = []): void { - throw new \RuntimeException( - 'Not yet implemented — see https://github.com/henryavila/codeguard/issues for roadmap' - ); + $violations = $this->makeAntiPatternScanner()->eloquentModelMocking($allowlist); + + Assert::assertSame([], $violations, $this->formatAntiPatternViolations( + 'Eloquent model mocking detected:', + $violations, + )); } /** * Assert no test uses `assertNotNull($x)` as its ONLY assertion * on `$x`. Null checks should be followed by a behavioural assertion. * - * @param array $allowlist FQCNs or file paths to skip + * @param list $allowlist relative paths to skip */ public function assertNoBareAssertNotNull(array $allowlist = []): void { - throw new \RuntimeException( - 'Not yet implemented — see https://github.com/henryavila/codeguard/issues for roadmap' - ); + $violations = $this->makeAntiPatternScanner()->bareAssertNotNull($allowlist); + + Assert::assertSame([], $violations, $this->formatAntiPatternViolations( + 'Bare assertNotNull() detected:', + $violations, + )); } } diff --git a/tests/Unit/Assertions/AntiPatternScannerTest.php b/tests/Unit/Assertions/AntiPatternScannerTest.php new file mode 100644 index 0000000..6fc2d21 --- /dev/null +++ b/tests/Unit/Assertions/AntiPatternScannerTest.php @@ -0,0 +1,317 @@ +isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($base); +} + +// ── tautological assertions ──────────────────────────────────────── + +it('flags tautological expect() assertions', function (): void { + $base = apsBase(); + + try { + apsWrite($base, 'tests/Unit/FooTest.php', 'expect(true)->toBeTrue();'); + $violations = (new AntiPatternScanner($base))->tautologicalAssertions(); + + expect($violations)->toHaveCount(1) + ->and($violations[0])->toContain('tests/Unit/FooTest.php'); + } finally { + apsCleanup($base); + } +}); + +it('flags tautological PHPUnit assertions', function (): void { + $base = apsBase(); + + try { + apsWrite($base, 'tests/Unit/BarTest.php', '$this->assertTrue(true);'); + + expect((new AntiPatternScanner($base))->tautologicalAssertions())->toHaveCount(1); + } finally { + apsCleanup($base); + } +}); + +it('passes test files with real-state assertions', function (): void { + $base = apsBase(); + + try { + apsWrite($base, 'tests/Unit/FooTest.php', 'expect($user->name)->toBe("Ann");'); + + expect((new AntiPatternScanner($base))->tautologicalAssertions())->toBe([]); + } finally { + apsCleanup($base); + } +}); + +// ── Eloquent model mocking ───────────────────────────────────────── + +it('flags Mockery alias mocking of a model', function (): void { + $base = apsBase(); + + try { + apsWrite($base, 'tests/Unit/FooTest.php', "Mockery::mock('alias:User');"); + + expect((new AntiPatternScanner($base))->eloquentModelMocking())->toHaveCount(1); + } finally { + apsCleanup($base); + } +}); + +it('does not flag mocking a non-aliased service', function (): void { + $base = apsBase(); + + try { + apsWrite($base, 'tests/Unit/FooTest.php', 'Mockery::mock(PaymentService::class);'); + + expect((new AntiPatternScanner($base))->eloquentModelMocking())->toBe([]); + } finally { + apsCleanup($base); + } +}); + +// ── bare assertNotNull ───────────────────────────────────────────── + +it('flags a bare assertNotNull on a variable', function (): void { + $base = apsBase(); + + try { + apsWrite($base, 'tests/Unit/FooTest.php', '$this->assertNotNull($user);'); + + expect((new AntiPatternScanner($base))->bareAssertNotNull())->toHaveCount(1); + } finally { + apsCleanup($base); + } +}); + +it('does not flag assertNotNull on a property access', function (): void { + $base = apsBase(); + + try { + apsWrite($base, 'tests/Unit/FooTest.php', '$this->assertNotNull($user->name);'); + + expect((new AntiPatternScanner($base))->bareAssertNotNull())->toBe([]); + } finally { + apsCleanup($base); + } +}); + +// ── truncate / forceDelete in tests ──────────────────────────────── + +it('flags truncate() in a test', function (): void { + $base = apsBase(); + + try { + apsWrite($base, 'tests/Unit/FooTest.php', "DB::table('users')->truncate();"); + + expect((new AntiPatternScanner($base))->truncateInTests())->toHaveCount(1); + } finally { + apsCleanup($base); + } +}); + +it('flags forceDelete() in a test', function (): void { + $base = apsBase(); + + try { + apsWrite($base, 'tests/Unit/FooTest.php', '$user->forceDelete();'); + + expect((new AntiPatternScanner($base))->forceDeleteInTests())->toHaveCount(1); + } finally { + apsCleanup($base); + } +}); + +// ── factory definition scanning ──────────────────────────────────── + +it('flags a DB query inside Factory::definition()', function (): void { + $base = apsBase(); + + try { + apsWrite($base, 'database/factories/UserFactory.php', <<<'PHP' + class UserFactory + { + public function definition(): array + { + return ['team_id' => Team::query()->first()->id]; + } + } + PHP); + + expect((new AntiPatternScanner($base))->dbQueriesInFactoryDefinition())->toHaveCount(1); + } finally { + apsCleanup($base); + } +}); + +it('ignores a DB reference inside a comment in definition()', function (): void { + $base = apsBase(); + + try { + apsWrite($base, 'database/factories/UserFactory.php', <<<'PHP' + class UserFactory + { + public function definition(): array + { + // legacy used Team::query() — now injected via state + return ['name' => 'Ann']; + } + } + PHP); + + expect((new AntiPatternScanner($base))->dbQueriesInFactoryDefinition())->toBe([]); + } finally { + apsCleanup($base); + } +}); + +it('ignores a DB reference inside a string literal in definition()', function (): void { + $base = apsBase(); + + try { + apsWrite($base, 'database/factories/UserFactory.php', <<<'PHP' + class UserFactory + { + public function definition(): array + { + return ['note' => 'run DB::table() by hand']; + } + } + PHP); + + expect((new AntiPatternScanner($base))->dbQueriesInFactoryDefinition())->toBe([]); + } finally { + apsCleanup($base); + } +}); + +it('flags eager create() inside Factory::definition()', function (): void { + $base = apsBase(); + + try { + apsWrite($base, 'database/factories/PostFactory.php', <<<'PHP' + class PostFactory + { + public function definition(): array + { + return ['user_id' => User::factory()->create()->id]; + } + } + PHP); + + expect((new AntiPatternScanner($base))->eagerCreateInFactoryDefinition())->toHaveCount(1); + } finally { + apsCleanup($base); + } +}); + +it('passes lazy factory references', function (): void { + $base = apsBase(); + + try { + apsWrite($base, 'database/factories/PostFactory.php', <<<'PHP' + class PostFactory + { + public function definition(): array + { + return ['user_id' => User::factory()]; + } + } + PHP); + + expect((new AntiPatternScanner($base))->eagerCreateInFactoryDefinition())->toBe([]); + } finally { + apsCleanup($base); + } +}); + +// ── allowlist + excluded dirs + missing dirs ─────────────────────── + +it('skips files listed in the allowlist', function (): void { + $base = apsBase(); + + try { + apsWrite($base, 'tests/Unit/LegacyTest.php', '$user->forceDelete();'); + + $violations = (new AntiPatternScanner($base)) + ->forceDeleteInTests(['tests/Unit/LegacyTest.php']); + + expect($violations)->toBe([]); + } finally { + apsCleanup($base); + } +}); + +it('excludes the Arch test directory by default to avoid self-matching', function (): void { + $base = apsBase(); + + try { + apsWrite($base, 'tests/Arch/TestQualityTest.php', "DB::table('x')->truncate();"); + + expect((new AntiPatternScanner($base))->truncateInTests())->toBe([]); + } finally { + apsCleanup($base); + } +}); + +it('returns no violations when the factories directory is absent', function (): void { + $base = apsBase(); + + try { + apsCleanup($base.'/database'); + + expect((new AntiPatternScanner($base))->eagerCreateInFactoryDefinition())->toBe([]); + } finally { + apsCleanup($base); + } +}); diff --git a/tests/Unit/Assertions/AssertionTraitsTest.php b/tests/Unit/Assertions/AssertionTraitsTest.php new file mode 100644 index 0000000..e175e16 --- /dev/null +++ b/tests/Unit/Assertions/AssertionTraitsTest.php @@ -0,0 +1,168 @@ +isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($base); +} + +function aptTestQualitySubject(string $base): object +{ + return new class($base) + { + use TestQualityAssertions; + + public function __construct(public string $base) {} + + protected function makeAntiPatternScanner(): AntiPatternScanner + { + return new AntiPatternScanner($this->base); + } + }; +} + +function aptParallelSafetySubject(string $base): object +{ + return new class($base) + { + use ParallelSafetyAssertions; + + public function __construct(public string $base) {} + + protected function makeAntiPatternScanner(): AntiPatternScanner + { + return new AntiPatternScanner($this->base); + } + }; +} + +it('TestQualityAssertions fails on a tautological assertion', function (): void { + $base = aptBase(); + + try { + aptWrite($base, 'tests/Unit/FooTest.php', 'expect(true)->toBeTrue();'); + $subject = aptTestQualitySubject($base); + + $threw = false; + try { + $subject->assertNoTautologicalAssertions(); + } catch (ExpectationFailedException) { + $threw = true; + } + + expect($threw)->toBeTrue(); + } finally { + aptCleanup($base); + } +}); + +it('TestQualityAssertions passes on clean tests', function (): void { + $base = aptBase(); + + try { + aptWrite($base, 'tests/Unit/FooTest.php', 'expect($user->name)->toBe("Ann");'); + $subject = aptTestQualitySubject($base); + + $threw = false; + try { + $subject->assertNoTautologicalAssertions(); + $subject->assertNoEloquentModelMocking(); + $subject->assertNoBareAssertNotNull(); + } catch (ExpectationFailedException) { + $threw = true; + } + + expect($threw)->toBeFalse(); + } finally { + aptCleanup($base); + } +}); + +it('ParallelSafetyAssertions fails on truncate() in a test', function (): void { + $base = aptBase(); + + try { + aptWrite($base, 'tests/Unit/FooTest.php', "DB::table('x')->truncate();"); + $subject = aptParallelSafetySubject($base); + + $threw = false; + try { + $subject->assertNoTruncateInTests(); + } catch (ExpectationFailedException) { + $threw = true; + } + + expect($threw)->toBeTrue(); + } finally { + aptCleanup($base); + } +}); + +it('ParallelSafetyAssertions honours the allowlist', function (): void { + $base = aptBase(); + + try { + aptWrite($base, 'tests/Unit/FooTest.php', '$user->forceDelete();'); + $subject = aptParallelSafetySubject($base); + + $threw = false; + try { + $subject->assertNoForceDeleteInTests(['tests/Unit/FooTest.php']); + } catch (ExpectationFailedException) { + $threw = true; + } + + expect($threw)->toBeFalse(); + } finally { + aptCleanup($base); + } +}); From a118dfce2fd5d386e3ea68de35d790b36099078d Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 09:59:58 -0300 Subject: [PATCH 02/21] docs: fix canonical status drift + add patterns-engine design PROJECT-STATUS.md was frozen 30 days in a pre-release snapshot and wrong on 4 load-bearing facts (audited against git+FS via multi-agent review): - "release: nenhum ainda" -> 0.2.0 IS on Packagist (tag pushed) - "68 commits ahead" -> origin/main == HEAD (0 ahead) - Arch "path repo/dev-main" -> Arch consumes via vcs + ^0.2.0 - Arch "usa codeguard:check produtivamente" -> Arch bypasses the CLI, uses package as a one-shot stub-seeder, no config/codeguard.php Rewrite to truth + new plan: package-side first (Patterns engine), Arch integration deferred by user constraint. Corrected scorecard, risks (R9/R10/R11), 28->30 pattern count. Add docs/specs/2026-06-03-patterns-engine-design.md (Thin Adjudicator, chosen by judge-panel): pattern data contract, src/Analyze/* component map, LlmClient seam, TDD increments (MVP A-C ~4.5d network-free, real driver D +1.5d), telemetry reuse, honesty check. --- .ai/memory/PROJECT-STATUS.md | 313 ++++++------------ .../2026-06-03-patterns-engine-design.md | 276 +++++++++++++++ 2 files changed, 374 insertions(+), 215 deletions(-) create mode 100644 docs/specs/2026-06-03-patterns-engine-design.md diff --git a/.ai/memory/PROJECT-STATUS.md b/.ai/memory/PROJECT-STATUS.md index 714e633..3750645 100644 --- a/.ai/memory/PROJECT-STATUS.md +++ b/.ai/memory/PROJECT-STATUS.md @@ -8,278 +8,161 @@ type: project > **Para Claude**: Este é o documento vivo de estado. Leia na primeira ferramenta-call de toda sessão substantiva. Atualize ao completar qualquer commit que mude escopo, ou ao mudar de sprint/foco. Em caso de conflito com outro arquivo de memória, este ganha (pra resolver drift, corrija o outro arquivo, não aqui). -**Última atualização**: 2026-05-04 (sprint pivot — release `0.2.0` em preparação após pivot do usuário "preciso de versão minimamente estável pra publicar") -**HEAD**: `9f5de93` docs: initial CHANGELOG.md following Keep a Changelog 1.1.0 -**Branch**: `main`, 68 commits ahead de `origin/main` (working tree limpo) -**Suite**: 377 tests / 928 assertions (todos verdes — sem regressão durante pivot) -**Lint/Static**: Pint clean (débito pré-existente em TestCase.php + StagedPhpFilesRunnerTest.php fixado em `a576501`). PHPStan level 5 self-applied com baseline grandfathered de 405 errors (`156b297`) — R8 fechado. `composer ci` roda pint:test + phpstan + test:coverage; CI ativa em PHP 8.3 + 8.4 via `.github/workflows/ci.yml` (`65893ab`). -**Release publicado**: nenhum ainda — `0.2.0` aguarda apenas tag + push + Packagist submission do usuário +**Última atualização**: 2026-06-03 (audit profundo multi-agente + replan colaborativo — corrigiu drift acumulado de 30 dias; foco vira **Patterns engine package-side**; integração Arch ADIADA por constraint do usuário) +**HEAD**: `4b32886` docs(memory): pivot sprint 9 → 0.2.0 release; close R5 + R8 +**Branch**: `main`, **sincronizado com `origin/main`** (0 commits ahead, working tree limpo) +**Suite**: 377 tests / 928 assertions (verde — verificado via `vendor/bin/pest --ci` no audit 2026-06-03) +**Lint/Static**: Pint clean. PHPStan level 5 self-applied com baseline grandfathered (`156b297`) — R8 fechado. `composer ci` roda pint:test + phpstan + test:coverage; CI ativa em PHP 8.3 + 8.4 via `.github/workflows/ci.yml` (`65893ab`). +**Release publicado**: ✅ **`0.2.0` no Packagist desde 2026-05-04** (tag `0.2.0` @ `4b32886`, pushed). Arch consome via repo `vcs` GitHub pinado em `^0.2.0` (lock @ `4b32886`) — **NÃO** via path repo nem `dev-main`. ---- - -## 🎯 Sprint Atual: Release `0.2.0` (2026-05-04 — em curso) - -Pivot estratégico do usuário: "preciso de versão minimamente estável pra publicar". Sprint da sessão 9 mudou de "Patterns engine" para "Release 0.2.0". Patterns engine empurra para sessão 10. Decisão de versão: **0.2.0** (não `1.0.0-alpha.1` como a spec original sugeria) — continua a numeração v0.x do projeto sem prometer estabilidade de v1.0. - -### Itens shippados na sprint (ordem cronológica) - -| Commit | Tipo | Conteúdo | -|---|---|---| -| `18df00f` | feat | `phpunit.xml.stub` enforça `failOnRisky` + `beStrictAboutTestsThatDoNotTestAnything` | -| `76e0074` | feat | `.github/workflows/codeguard-ci.yml.stub` mínimo, delega a `composer codeguard:check` | -| `18da3f1` | docs | Status update intermediário | -| `a576501` | chore | Pint debt cleanup pré-release (TestCase.php + StagedPhpFilesRunnerTest.php) | -| `156b297` | feat | Self-PHPStan level 5 + baseline grandfathered (R8 fechado) | -| `65893ab` | ci | Package CI workflow `.github/workflows/ci.yml` (PHP 8.3 + 8.4) | -| `04c9302` | docs | README accuracy fix — moveu shipped → Available, removeu Vitest/Browser/MongoDB stale | -| `9f5de93` | docs | CHANGELOG.md inicial (Keep-a-Changelog) | - -Suite: 370 → 377 (+7 / +22 assertions, sem regressão). - -### Próxima ação concreta — handoff para o usuário - -Tudo que requer credenciais ou autoriza ação pública é responsabilidade do usuário: - -1. **Smoke test** — rodar em projeto Laravel novo: `composer require --dev henryavila/codeguard:dev-main` (via path repo) + `php artisan codeguard:install` + `composer codeguard:check`. Confirmar comportamento esperado antes de tag. -2. **`git tag 0.2.0`** + **`git push origin main --tags`** — versão prefixada (sem `v`) é o convencional para Packagist; ambos estilos funcionam. -3. **Submeter no [Packagist](https://packagist.org/packages/submit)** — primeiro submit pega URL `https://github.com/henryavila/codeguard.git` e cria a página. Releases subsequentes são auto-discovered via webhook. -4. **Anunciar/divulgar** se for o caso. - -### O que NÃO está em 0.2.0 (intencional, documentado em README "Roadmapped") - -- `codeguard:analyze` + Patterns engine (R7 — sessão 10) -- `codeguard:prepare` + schema dump multi-DB -- AI rules generator -- Companion packages (`codeguard-symfony`, `codeguard-python`) - -### Riscos pós-release identificados - -- **Breakage potencial em consumer existente (Arch)** se path repo apontar pra tag em vez de branch. Arch hoje consome `dev-main` via path; tag 0.2.0 é o primeiro snapshot estável. Recomendado: Arch pinar `^0.2` após release. -- **Patterns engine vazia continua peso morto narrative** (R7 vivo) — README ainda menciona "AI review where AST can't reach" no header. Sprint 10 fecha esse débito. - -## 🎯 Sprint Anterior: Sessão 8 COMPLETA — TestSuiteRunner extract + codeguard:test shipped - -**Sessão 8 (2026-04-23) fechou os 4 blocos do SESSION-8-PROMPT em uma única sessão** (originalmente estimado 6-8h; saiu mais rápido por zero retrabalho entre blocos + zero retests repetidos). - -**Entregáveis shippados** (4 commits): - -- `0d8b27b` — **Bloco 1** port 7 primitivos (CommandExecutor/RunningCommand/AsyncCommandExecutor interfaces + ProcessCommandExecutor/ProcessRunningCommand concretes + TestStageResult/TestRunResult DTOs). 18 tests (5+6+7). Provider aliases bound. -- `1007b49` — **Bloco 2** StageConfig evoluído com 8 campos (label, phase, description, command list, reportType nullable, reportFile, reportArgPrefix, fastFailArguments). 6 novos tests StageConfigTest. Breaking change v0.x aceito. -- `0269021` — **Bloco 3** TestSuiteRunner generalizado (400 LOC vs 522 do Arch). `stages()` hardcoded → ctor-injected `list`. `killStalePlaywrightServers` removido. File facade → Filesystem injetado. 13 tests com FakeCommandExecutor/FakeRunningCommand. Service binding em `registerTestingServices()`. -- `3f3f16a` — **Bloco 4** CodeguardTestCommand + Layer 5 telemetry. Signature `--stage/--mode/--no-coverage/--context`. Emite command.start/test.started/test.ended/command.end. 8 feature tests. Test doubles extraídos pra `tests/Support/` pra reuso. NextStepsReporter agora promove `codeguard:test` como primeiro next-step. - -**Critérios de sucesso do plano**: -- ✅ Suite ≥ 360 tests: 370 verdes (plano previa 325 + ~35; saiu +45) -- ✅ Zero refs a Playwright/MongoDB/Nova em files novos -- ✅ Tests cobrem sequential + parallel + fast-fail + report modes -- ✅ Config default tem stages utilizáveis sem Arch-isms -- ✅ Telemetria Layer 5 emite jsonl válido (FieldAllowlist já tinha schema) - -**Decisão de escopo tomada**: `stage_key` em telemetria não emitido (schema existente do FieldAllowlist é aggregate-por-run, não per-stage). Manter assim — privacy-safe. Se granularidade per-stage virar necessidade, adicionar enum fechado ao allowlist num sprint futuro. - -**Gap conhecido anotado**: `--no-coverage` hoje só flipa telemetria `with_coverage`. Coverage real depende de env do projeto (XDEBUG_MODE). Fazer plumbing de `StageConfig::env` através do executor é um follow-up (exige também `AsyncCommandExecutor` aceitar env per-call; hoje hardcoded `APP_ENV=testing`). - -### Próxima ação concreta (sessão 9 — DECIDIDA 2026-04-23) +> ⚠️ **Correção de drift (2026-06-03)**: este arquivo ficou congelado 30 dias num snapshot pré-release e estava ERRADO em 4 fatos load-bearing (dizia "não publicado", "68 commits ahead", "Arch via path repo/dev-main", "Arch usa codeguard:check produtivamente"). Tudo corrigido abaixo via audit verificado contra git+FS. Detalhe da correção na seção "Drift corrigido". -🔜 **[NEXT] Atacar Patterns engine** (ataque a R7 — fecha o gap entre marketing e código, cria o motor que consome os 28 YAMLs dormentes). - -**Motivação da escolha**: o usuário está usando `codeguard:check` produtivamente no Arch; o pacote é utilizável hoje; o que FALTA pra ele cumprir a promessa "quality gates que sobrevivem ao seu agente" é a feature diferenciadora — pattern review automatizado. Sem Patterns engine, CodeGuard é "mais um wrapper de pint+phpstan+deptrac". Com Patterns engine, é único. - -**Prioridade revisada pós-sessão 8**: - -1. **Sessão 9 (próxima)** — Patterns engine v0 (src/Patterns/* + codeguard:analyze + testes). Estimativa honesta: ~1 semana de sessões, não 1 sessão. Começa por brainstorm/plano antes de codar. -2. **Sessão N+k** — Arch migra Testing inline → package (~2-3h, destravado por sessão 8) -3. **Sessão N+k+1** — README + `1.0.0-alpha.1` release (só faz sentido depois que Patterns engine existe e Arch validou no campo) -4. **Pós-alpha** — Schema dump (`prepare`), AI rules generator, hooks plugin - -**Itens empurrados explicitamente**: -- README/alpha release: NÃO antes de Patterns engine estar rodando no Arch -- Schema dump: alto custo (8-12h real + drivers MySQL/Postgres/sqlsrv), baixo ROI sem 2º consumer que exija sqlsrv ou in-memory SQLite -- AI rules generator: mais útil depois que Patterns engine tem pelo menos um motor de consumo de markdown - -### Itens arrastados da sessão 7 para backlog pós-alpha +--- -Nenhum item do backlog original ficou pendente — os 8 tasks + validação end-to-end + 2 design gaps + 2 bugs de config foram todos fechados. Sessão 7 rodou substancialmente além do planejado (13 commits no total vs 8 previstos) por causa dos descobrimentos na validação interativa. +## 🎯 Sprint Atual: Track A — Patterns engine (package-side) (2026-06-03 — em curso) -**Padrão "design gap" confirmado e fechado**: componentes que mutam arquivos sob raiz do projeto precisam consultar `StubOverrides` antes de gravar (`--refresh-stubs` como escape hatch). Sessão 7 fechou 2 ocorrências desse padrão: +**Decisão estratégica do usuário (2026-06-03)**: construir o **diferencial primeiro** (Patterns/LLM review — o lado *reviewer* da meta G3, "policiar dev terceirizado que não usa IA"). + **constraint dura**: *NÃO tocar no Arch agora* (projeto grande em dev lá). Implementar **tudo que for package-side** antes de tratar integração. -- `fb63ed3` — `maybeSuggestDeptracLayers` (wizard) escrevia `deptrac.yaml` direto -- `e9c1269` — `applyPhpstanExtensionsToStub` tocava sentinels de `phpstan.neon` direto +### Consequência no roadmap -Para futuros componentes similares: seguir o shape desses 2 fixes (check `contains($path)` → short-circuit com mensagem explicativa; force flag ignora lista). +Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. Arch vira **lab read-only**. Foco 100% package-side, nesta ordem: -**Bugs de config de check (fixados na validação interativa)**: +| Fase | Trabalho | Precisa Arch? | Estado | +|:---:|---|:---:|---| +| **0** | Limpeza canônica (este arquivo + docs stale) | ❌ | 🟡 em curso | +| **1** | Bug fixes package-side (ver abaixo) | ❌ (lê Arch só como referência) | 🔜 fila | +| **2** | **Patterns engine** (`src/Patterns/*` + `codeguard:analyze`) | ❌ | 🟡 design rodando | +| **3** | Schema dump (`codeguard:prepare`) + AI rules generator | ❌ (testáveis via fixtures/SQLite) | ⏸️ depois | +| **4** | 🔒 Integração Arch + dogfood real | ✅ | ⛔ **ADIADO** (constraint do usuário) | -- `41ec10c` — infection `--show-mutations=false` é inválido; aceita integer ou "max". Flag removida, `--no-progress` substituindo. PHPStan também ganhou `--memory-limit=2G` (projetos >20k LOC OOMam sem). -- `fc1e777` — infection `testFramework: pest` é rejeitado em 0.32 (aceita phpunit/phpspec/codeception). Mudado pra `phpunit` (Pest instala binário compatível); comment no stub explica que `pest` requer infection/pest-plugin separado. +### Fase 1 — bug fixes package-side (decididos no audit) -**Papercuts menores anotados para pós-alpha**: -- `NextStepsReporter` tem string hardcoded `"Review level in phpstan.neon (currently 5)"` — não lê level real do arquivo. Cosmetic. -- `StubOverrides::save()` sobrescreve arquivo com header canônico — perde comentários per-entry que o user escreva manualmente. -- `codeguard:install:override --detect` sub-comando: compara diff vs stub e sugere paths candidatos a override. UX friction real — usuário esqueceu `tests/Arch/TestQualityTest.php` no pre-seed da primeira iteração. +1. **Assertion traits que lançam** — `TestQualityAssertions` + `ParallelSafetyAssertions`: todos os 7 métodos fazem `throw RuntimeException('Not yet implemented')` (`src/Assertions/*.php`) e estão wired no stub publicado `resources/stubs/tests/Arch/TestQualityTest.php.stub`. **Decisão (Q2): IMPLEMENTAR** portando a lógica grep-based que o Arch escreveu inline em `tests/Arch/TestQualityTest.php` (ler como referência, NÃO modificar Arch). +2. **`coverage_percent` hardcoded `-1`** em `src/Commands/CodeguardTestCommand.php:102` — telemetria de coverage nunca reflete real. +3. **Config morto** — blocos `patterns` / `ai_rules` / `prepare` em `config/codeguard.php` são parsed-into-DTO mas lidos por ninguém. Neutralizar/comentar como roadmap até os engines existirem (ou deixar e ativar junto com cada engine). -### Validação na sessão 7 (o que rodou onde) +### Decisões conscientes (Q3 — "deixar o público como está") -| Alvo | Comando | Resultado | -|------|---------|-----------| -| CodeGuard suite | `vendor/bin/pest` | 325 passed / 787 assertions | -| Arch path repo | `composer update henryavila/codeguard` | OK | -| Arch install NON-interativo | `php artisan codeguard:install --no-interactive --preset=default` | OK mas sobrescreveu deptrac.yaml → descoberta design gap #1 | -| Arch install **interativo** | `php artisan codeguard:install` | OK — validou Peststan pré-selecionado, 4ª opção "Keep + remember", wizard skip, 6 files como `kept custom (remembered)` | -| Arch Pint | `vendor/bin/pint --test` | Roda — reporta formatação pending (débito Arch) | -| Arch PHPStan | `vendor/bin/phpstan analyse` | Após sentinels restaurados + `e9c1269` fix: 1130 errors post-baseline (débito Arch) | -| Arch Deptrac | `vendor/bin/deptrac analyse` | 5804 allowed / 0 violations (30-layer intacto graças ao `fb63ed3`) | +- **SEM hotfix 0.2.1.** README:5 + `composer.json:3` continuam vendendo Patterns/AI-rules/schema sem ressalva. Aposta: Track A torna isso verdade. Risco aceito (R9). +- Bug dos traits é corrigido no código mas **vai no próximo minor (0.3.0)**, não em patch de emergência. Até lá, consumer que copie o stub e chame um método crasha — aceito porque o único consumer (Arch) já contornou inline e não há outro consumer (R10). -### Bugs pré-existentes corrigidos fora do backlog sessão 7 +### Próxima ação concreta -- `cc3e776` — Pint 1.29+ rejeita `_rule_docs` dentro de `rules` (stub fix) -- `d936b43` — shipmonk/dead-code-detector removeu `usageProviders.laravel/eloquent` em 0.14+ (stub fix) +🔜 **Aguardando design doc do Patterns engine** (workflow `patterns-engine-design` rodando: research da data contract dos 30 YAMLs + research externo de LLM-review + judge-panel de 3 arquiteturas → spec + task list TDD). Output salvo em `docs/specs/`. -### Backlog pós-alpha (itens empurrados) +**Em paralelo / assim que o design voltar**: começar Fase 1 (assertion traits via TDD) — independe do design e é package-side puro. -- **#9 — Opção C Laravel-pragmatic Deptrac ruleset** (~2h15min) — sessão 8 candidata -- **#10 — Telemetria Layers 3-7** — bloqueado por commands faltantes -- **TestSuiteRunner extract** (Option A original, ~6-8h) — sessão 8 candidata -- **wizard respeita stub-overrides.yaml** — fechar design gap anotado acima (~30min) -- Pattern engine (`src/Patterns/*` + `codeguard:analyze`) -- AI rules generator (`src/AiRules/*`) -- Schema dump multi-DB (`src/Schema/*` + `codeguard:prepare`) -- Baseline manager (`codeguard:baseline`) -- `henryavila/codeguard-hooks` Claude plugin (repo separado) +**Fork em aberto pro design resolver**: transporte LLM (SDK PHP Anthropic vs shell-out a CLI vs interface pluggable sem driver). Default proposto: **interface pluggable, sem Node** (respeita ADRs). Confirmar quando o design apresentar com recomendação. --- ## 🟢 Implementado hoje (inventário objetivo) -### Comandos Artisan — 6 de 10 +### Comandos Artisan — 7 de 10 | Comando | Status | Arquivo | |---------|:-----:|---------| | `codeguard:install` | ✅ | src/Commands/CodeguardInstallCommand.php | | `codeguard:install:override` | ✅ | src/Commands/CodeguardInstallOverrideCommand.php | | `codeguard:check` | ✅ | src/Commands/CodeguardCheckCommand.php | -| `codeguard:telemetry:enable` | ✅ | src/Commands/Telemetry/EnableCommand.php | -| `codeguard:telemetry:disable` | ✅ | src/Commands/Telemetry/DisableCommand.php | -| `codeguard:telemetry:clear` | ✅ | src/Commands/Telemetry/ClearCommand.php | | `codeguard:test` | ✅ | src/Commands/CodeguardTestCommand.php | -| `codeguard:analyze` | ⏸️ pós-alpha | — | -| `codeguard:baseline` | ⏸️ pós-alpha | — | -| `codeguard:prepare` | ⏸️ pós-alpha | — | +| `codeguard:telemetry:enable\|disable\|clear` | ✅ | src/Commands/Telemetry/*.php | +| `codeguard:analyze` | 🟡 **Track A (sprint atual)** | — | +| `codeguard:prepare` | ⏸️ Fase 3 | — | +| `codeguard:baseline` | ⏸️ pós-engines | — | ### Camadas — estado por namespace | Namespace | Status | Observação | |-----------|:-----:|------------| -| `Install\*` | ✅ completo | Environment + Preset + StubPublisher + StubOverrides + LegacyStubCleaner + DeptracLayerSuggester + DeptracLayerWizard + LayerDecisionStore + CaptainhookInstaller + ComposerAllowPluginsCheck + CodeguardDirectoryInitializer + InstallSummary + PhpstanExtension{Selector,Store,Applier} + NextStepsReporter + GatePlan{,Registry} + InstallTelemetry | -| `Telemetry\*` | ✅ completo | Event + EventName + EventStatus + FieldAllowlist + Recorder + ConfigGate + Rotator + JsonlWriter + StopwatchScope + MeasuredAction + TelemetryStateStore | +| `Install\*` | ✅ completo | ~900 LOC no command + ~40 classes de suporte; testado | +| `Telemetry\*` | ✅ completo | Subsistema mais coberto (Recorder/FieldAllowlist/JsonlWriter/Rotator/MeasuredAction/...) | | `Commands\Telemetry\*` | ✅ completo | 3 commands | -| `Gates\*` | ✅ novo | GateRunner + GateRunResult; consumido pelo CheckCommand e primeira emissão de Layer 3 telemetry | -| `Hooks\*` | 🟡 parcial | StagedPhpFilesRunner existe; outras PHP Actions viriam depois | -| `Testing\*` | ✅ completo | Preset + CodeguardConfig + StageConfig (8 campos) + GateConfig + PrepareConfig + CommandExecutor/RunningCommand/AsyncCommandExecutor interfaces + ProcessCommandExecutor/ProcessRunningCommand concretes + TestStageResult/TestRunResult DTOs + **TestSuiteRunner generalizado** | -| `Assertions\*` | 🟡 parcial | TestQualityAssertions + ParallelSafetyAssertions traits existem. PestExpectations + QualityExpectation faltam. | -| `Patterns\*` | ❌ ausente | 28 YAMLs em resources/patterns/ mas nenhum código pra carregar | -| `AiRules\*` | ❌ ausente | config('codeguard.ai_rules.targets') existe mas sem consumer | -| `Schema\*` | ❌ ausente | killer feature ainda virgem | +| `Gates\*` | ✅ | GateRunner + GateRunResult; consumido pelo CheckCommand + Layer 3 telemetry | +| `Hooks\*` | 🟡 parcial | StagedPhpFilesRunner existe | +| `Testing\*` | ✅ completo | TestSuiteRunner generalizado + StageConfig (8 campos) + executors + DTOs | +| `Assertions\*` | 🔴 **broken** | 2 traits existem mas **lançam `RuntimeException('Not yet implemented')` em 7/7 métodos**. Fase 1 corrige. | +| `Patterns\*` | ❌ ausente | **30** YAMLs em resources/patterns/ (13 core + 6 php + 11 php-laravel incl. preset.yaml) mas ZERO código consumidor. **Track A constrói.** | +| `AiRules\*` | ❌ duplo-morto | config targets existe + `resources/rules/` VAZIA (0/7 markdowns, sem git history). Fase 3. | +| `Schema\*` | ❌ ausente | só `PrepareConfig` DTO (4 campos). Fase 3. | -### Recursos (resources/) — state +### Recursos (resources/) | Caminho | Status | |---------|:-----:| -| `resources/stubs/*.stub` | ✅ 7 stubs (pint, phpstan, phpstan-test-quality, deptrac, infection, captainhook+README, .jscpd) | -| `resources/patterns/**/*.yaml` | ✅ 28/28 (13 core + 6 php + 9 php-laravel) | -| `resources/skills/*/SKILL.md` | ✅ 3/3 (codeguard-{setup,run,health}) | -| `resources/rules/*.md` | ❌ 0/7 canonical markdown (dir existe, vazia) | - -### Tag de rollback -`v0-last-lefthook` válida (pre-Phase-A CaptainHook migration). +| `resources/stubs/*.stub` | ✅ stubs (pint, phpstan, phpstan-test-quality, deptrac, infection, captainhook+README, phpunit, jscpd, CI workflow, TestQualityTest) | +| `resources/patterns/**/*.yaml` | ✅ **30** (data dormente até Patterns engine) | +| `resources/skills/*/SKILL.md` | 🔴 **stale Node-era** — os 3 mandam rodar `npx @henryavila/codeguard`, ler `node_modules`, `codeguard.yaml` (não existe no package PHP). Quebrariam um usuário real. Backlog cleanup. | +| `resources/rules/*.md` | ❌ 0/7 (dir vazia) | --- -## 📚 Fontes documentais (não duplicar aqui) +## 🚦 Scorecard honesto por perspectiva de uso (corrigido no audit 2026-06-03) -- **Spec canônico v5 arquitetural**: [`docs/specs/2026-04-16-codeguard-v2-architecture.md`](../../docs/specs/2026-04-16-codeguard-v2-architecture.md) — 2 packages, presets, install híbrido, roadmap original -- **Spec CaptainHook + Telemetry**: [`docs/specs/2026-04-17-captainhook-migration-and-telemetry.md`](../../docs/specs/2026-04-17-captainhook-migration-and-telemetry.md) — Phases A/B/C, schema telemetria -- **Pivot npm→Composer**: [`docs/specs/2026-04-16-pivot-npm-to-composer.md`](../../docs/specs/2026-04-16-pivot-npm-to-composer.md) -- **ADRs**: [`.ai/memory/architecture-decisions.md`](architecture-decisions.md) — 10 decisões congeladas -- **Open questions**: [`.ai/memory/open-questions.md`](open-questions.md) — decisões pendentes -- **Conversation handoff**: [`.ai/memory/conversation-handoff.md`](conversation-handoff.md) — narrativa cronológica por sessão (este arquivo é snapshot sincrônico; handoff é o log) -- **User goals**: [`.ai/memory/user-goals.md`](user-goals.md) — 3 metas reais +| Perspectiva | Real | Justificativa | +|---|:-:|---| +| "install + rodar gates + rodar tests" | **~80%** | Commands reais, installer ~900 LOC, telemetria completa, 377 tests verdes. Descontado: traits lançam exception, `coverage_percent -1`, e o único consumer **não roda** check/test. | +| "pattern-based LLM review" (o diferencial) | **~15%** | 30 YAMLs + slots de telemetria existem como dado inerte/scaffolding. Zero engine. **Track A ataca.** (status antigo dizia ~30%, super-creditava dado dormente) | +| "AI rules generator" | **~3%** | duplo-morto: `src/AiRules/` ausente + `resources/rules/` vazia | +| "schema dump multi-DB" | **~8%** | só `PrepareConfig` DTO | +| "publicar/distribuir" | **~85%** | genuinamente no Packagist, tagged, lockável, Node-free. Descontado: footprint `.codeguard/` é git-ignored (não cruza máquinas), 0 downloads, único consumer bypassa a CLI | --- -## 🚦 Scorecard vs spec v5 original +## 🔎 Drift corrigido (2026-06-03) — rebuild de confiança no arquivo canônico -Fases do [roadmap do spec v5](../../docs/specs/2026-04-16-codeguard-v2-architecture.md) + extras da sessão 3-5: +Audit multi-agente verificou cada alegação contra git+FS. Corrigido neste arquivo: -| Fase | Entregável | Status | Ref | -|:---:|---|:---:|---| -| 1 | composer.json + config + DTOs + ServiceProvider (foundation) | ✅ | — | -| 2 | `CodeguardInstallCommand` híbrido | ✅ | sessões 2-4 | -| 3 | Stubs 8 gates + Pest tests | ✅ | 7 stubs + 284 tests | -| 4 | README + `1.0.0-alpha.1` | ⏳ | sprint atual #4 | -| 5 | `TestSuiteRunner` extract + `CodeguardTestCommand` | ✅ | sessão 8 (2026-04-23) | -| **+D** (2026-04-22) | `codeguard:check` + `Gates\*` + Layer 3 telemetry | ✅ | sessão 5, sprint Option A #1 | -| 6 | Assertions (PestExpectations + QualityExpectation) | ⏸️ | 2 traits prontas, 2 classes faltam | -| 7 | Schema dump + `CodeguardPrepareCommand` | ⏸️ | pós-alpha | -| 8 | Pattern engine + `CodeguardAnalyzeCommand` | ⏸️ | pós-alpha | -| 9 | AI rules generator | ⏸️ | pós-alpha | -| 10 | `codeguard-hooks` Claude plugin (repo separado) | ⏸️ | pós-alpha | -| 11 | Arch migra do inline → package @dev | 🔜 | sprint atual #3 | -| 12 | 2º projeto + `1.0.0` | ⏸️ | pós-alpha | -| **+A** (2026-04-17) | CaptainHook migration (β) | ✅ | sessão 3 | -| **+C** (2026-04-17) | Install UX (β) | ✅ | sessão 4 | -| **+B** (2026-04-17) | Telemetry (install layer) | ✅ | sessão 5 | - -**~60% do escopo total do spec v5 shipped** (Fases 1-3 + 5 completas, extras A/B/C/D todos shipped). Restam Fases 4 (README + alpha release), 6 (Assertions classes), 7 (Schema dump), 8 (Patterns), 9 (AI rules), 10 (hooks plugin), 11 (Arch migration), 12 (v1.0). - -### Scorecard honesto por perspectiva de uso (2026-04-23) - -O número "~60% shipped" agregado esconde uma bifurcação importante. Medindo por perspectiva real de consumidor: - -| Perspectiva de uso | Estado real | Por quê | -|---|:-:|---| -| "install + rodar gates + rodar tests" (Arch hoje) | **~85%** | install/check/test production-ready; telemetria install+gates em pé; CaptainHook ativo | -| "pattern-based LLM review" (o diferencial marketing) | **~30%** | 28 YAMLs de dados existem em `resources/patterns/`; zero consumer code em `src/Patterns/`; `codeguard:analyze` não existe | -| "AI rules generator" | **~15%** | config targets existe; zero código em `src/AiRules/` | -| "schema dump multi-DB" | **~10%** | `PrepareConfig` DTO existe (4 campos); `src/Schema/` não existe; `codeguard:prepare` não existe | -| "publicar v1.0 no Packagist" | **~60%** | falta README, CHANGELOG, tag, 2º consumer, migration Arch inline | +| Era (errado/stale) | É (verificado) | +|---|---| +| "Release publicado: nenhum ainda" | 0.2.0 no Packagist 2026-05-04 + tag pushed | +| "68 commits ahead de origin" | `origin/main...HEAD` = `0 0` | +| HEAD `9f5de93` | `4b32886` | +| Arch via "path repo / dev-main" | Arch via `vcs` + `^0.2.0`, lock `4b32886` | +| "Arch usa codeguard:check produtivamente" | Arch NUNCA chama check/test; roda pint/phpstan/deptrac direto + `tests:run` inline; **sem `config/codeguard.php`** | +| "28 patterns" | **30** YAMLs no disco | -**Diagnóstico honesto**: o projeto é produtivo pro uso imediato (Arch consumindo quality gates), mas vende uma narrativa (pattern review LLM) que ainda não existe em código. Isso NÃO é falso — é incompleto. O risco concreto é publicar alpha antes de ter a feature diferenciadora. +**Ainda stale (backlog, não corrigido neste arquivo)**: `conversation-handoff.md` congelado na sessão 7 (325-passed, não registra release nem suite 377); specs marcam `codeguard:prepare` "✅" enquanto código ausente; CLAUDE.md diz "28 YAMLs"; CHANGELOG:43-44 vende assertions como funcionando + :51 diz Rotator "daily/.codeguard/telemetry/" (é size-based, flat file). README:5↔:7 se contradizem (decisão Q3: deixar). --- ## ⚠️ Riscos e blockers ativos -| # | Risco | Mitigação em curso | -|---|-------|--------------------| -| R1 | Arch ainda consome Testing inline; falta migração pro package — M1 não 100% validada em uso real | Sessão 9 Opção A resolve | -| R2 | Spec v5 não previa CaptainHook+Telemetry (adicionado via ADR-010 e Q14) — roadmap original está sub-estimado | Aceitar: ajustar expectativa de timeline (ver ADR-008) | -| R3 | ~~TestSuiteRunner extract pode surfar edge cases Arch-specific~~ **MITIGADO sessão 8** — extract limpo, 13 tests cobrem modes, Arch-isms (Playwright/MongoDB/Nova) todos removidos | ✅ fechado | -| R4 | Telemetria CaptainHook Actions requer bootstrap Laravel dentro do processo do hook — não-trivial | Adiado: Layer 4 de telemetria fica pós-alpha | -| ~~R5~~ | ~~Release alpha precisa de README mínimo (hoje não existe)~~ | **FECHADO 2026-05-04**: README existe (218 linhas) e foi corrigido para alinhar com 0.2.0 (`04c9302`); CHANGELOG criado (`9f5de93`) | -| R6 | `--no-coverage` hoje só flipa telemetria; coverage real via XDEBUG_MODE depende do projeto. StageConfig::env não plumbed através do executor | Follow-up: exige AsyncCommandExecutor aceitar env per-call | -| R7 | **28 YAMLs em `resources/patterns/` são peso morto até Patterns engine shippar** — marketing vende "pattern-based LLM review" que não existe em código | **Sessão 9 ataca essa dívida** (ver sprint abaixo) | -| ~~R8~~ | ~~CodeGuard não tem `phpstan.neon` na raiz~~ | **FECHADO 2026-05-04** (`156b297`): level 5 + baseline grandfathered de 405 errors + `reportUnmatchedIgnoredErrors: true`. Pacote agora se autoanalisa. | +| # | Risco | Estado | +|---|-------|--------| +| R1 | Arch consome o package como **stub-seeder one-shot, não runtime**; dogfood real do check/test nunca rodou em campo | **ADIADO conscientemente** — constraint do usuário (não tocar Arch). Integração = Fase 4, quando liberado | +| R7 | 30 YAMLs eram peso morto até Patterns engine | **SENDO ATACADO** — Track A é a sprint atual | +| R9 | Marketing público (README:5, composer.json:3) vende features ausentes | **aceito (Q3)** — aposta que Track A torna verdade; reavaliar se Track A atrasar | +| R10 | Assertion traits lançam exception num release PUBLICADO + wired no stub → consumer que use crasha | **mitigando** — Fase 1 implementa; SEM hotfix 0.2.1 (Q3), vai no 0.3.0. Único consumer (Arch) já contornou inline | +| R11 | Skills `resources/skills/*` são Node-era e quebrariam um usuário real | backlog cleanup | +| ~~R5~~ | ~~README mínimo~~ | ✅ FECHADO (README existe + alinhado a 0.2.0) | +| ~~R8~~ | ~~package não se autoanalisa~~ | ✅ FECHADO (`156b297`: phpstan level 5 + baseline) | --- -## 📝 Protocolo de atualização (instrução pra Claude) +## 📚 Fontes documentais (não duplicar aqui) -**Ao iniciar sessão**: ler este arquivo *primeiro*, antes de qualquer outro arquivo de memória ou spec. Use-o como single source of truth do "onde estou e pra onde vou". +- **Spec canônico v5 arquitetural**: [`docs/specs/2026-04-16-codeguard-v2-architecture.md`](../../docs/specs/2026-04-16-codeguard-v2-architecture.md) +- **Spec CaptainHook + Telemetry**: [`docs/specs/2026-04-17-captainhook-migration-and-telemetry.md`](../../docs/specs/2026-04-17-captainhook-migration-and-telemetry.md) +- **Pivot npm→Composer**: [`docs/specs/2026-04-16-pivot-npm-to-composer.md`](../../docs/specs/2026-04-16-pivot-npm-to-composer.md) +- **Design doc Patterns engine**: [`docs/specs/2026-06-03-patterns-engine-design.md`](../../docs/specs/2026-06-03-patterns-engine-design.md) — Thin Adjudicator (MVP 4.5d + driver 1.5d); Fork 4 (transporte LLM) aberto +- **ADRs**: [`.ai/memory/architecture-decisions.md`](architecture-decisions.md) +- **Open questions**: [`.ai/memory/open-questions.md`](open-questions.md) +- **Conversation handoff**: [`.ai/memory/conversation-handoff.md`](conversation-handoff.md) — ⚠️ stale (sessão 7) +- **User goals**: [`.ai/memory/user-goals.md`](user-goals.md) + +--- + +## 📝 Protocolo de atualização (instrução pra Claude) -**Ao terminar uma unidade de trabalho** (tipicamente um commit que muda escopo): -1. Atualizar `Última atualização` + `HEAD` + contadores (tests, commits ahead, branch) -2. Mover items do backlog da sprint para `Implementado hoje` quando completados -3. Atualizar a tabela "Comandos Artisan" e "Camadas" -4. Se a sprint terminou, começar nova sessão "Sprint Atual" e mover a antiga pra histórico compacto -5. Atualizar scorecard de fases (✅/🔜/⏸️) -6. Se algum risco foi mitigado ou surgiu novo, ajustar tabela +**Ao iniciar sessão**: ler este arquivo *primeiro*. Single source of truth do "onde estou e pra onde vou". -**Ao mudar de sprint/foco**: reescrever a seção `🎯 Sprint Atual` inteira. Itens descartados viram nota curta no histórico. +**Ao terminar unidade de trabalho** (commit que muda escopo): +1. Atualizar `Última atualização` + `HEAD` + contadores (tests, commits ahead/synced, branch) +2. Mover items completados pro inventário; atualizar tabelas de comandos/camadas +3. Atualizar scorecard + riscos +4. Se mudou sprint/foco, reescrever a seção `🎯 Sprint Atual` -**NÃO atualizar este arquivo para**: -- Progresso intra-commit (isso é TaskList/plan) -- Discussões de design (isso vira ADR ou entra em open-questions) -- Narrativa de sessão (isso vai pro conversation-handoff) +**NÃO atualizar pra**: progresso intra-commit (é TaskList), design (vira ADR/open-question), narrativa de sessão (vai pro conversation-handoff). -**EM CASO DE CONFLITO** entre este arquivo e outros docs de memória: este ganha. Corrija o outro arquivo pra alinhar — não edite aqui pra "concordar com" dado stale. +**EM CASO DE CONFLITO** com outros docs: este ganha. Corrija o outro arquivo — não edite aqui pra concordar com dado stale. diff --git a/docs/specs/2026-06-03-patterns-engine-design.md b/docs/specs/2026-06-03-patterns-engine-design.md new file mode 100644 index 0000000..4bd5f65 --- /dev/null +++ b/docs/specs/2026-06-03-patterns-engine-design.md @@ -0,0 +1,276 @@ +# CodeGuard Patterns Engine — Design (`codeguard:analyze`) + +**Status:** Proposed · **Date:** 2026-06-03 · **Author:** synthesis of 3 design tracks + 3 judge reviews (workflow `patterns-engine-design`) +**Chosen architecture:** **Thin Adjudicator** (unanimous judge winner, ranked #1 by all three) with grafted hardening from the Correctness-First and Seamed tracks. +**Supersedes:** the analyze sketch in `docs/specs/2026-04-16-codeguard-v2-architecture.md:575-597` for the package-side engine. + +> **Scope constraint (user directive 2026-06-03):** package-side only. `/home/henry/arch` is read-only reference; nothing in it changes. Everything here is testable with fixtures + a mocked LLM (no network in CI). + +--- + +## 1. Goal & scope + +`codeguard:analyze` is CodeGuard's differentiator: a PHP-native command that loads the curated pattern corpus (`resources/patterns/{core,php,php-laravel}/*.yaml`), decides which source files to review, and asks an LLM to judge each file against the patterns whose `detection.signals` match it — reaching smells **AST tools cannot** ("god object", "logic in blade", "service-layer discipline"). It returns structured findings, gates the exit code on severity, and emits telemetry through the existing `Recorder`. + +**One sentence:** load matching patterns → select files (git-changed by default) → batch each file's matching patterns into ONE structured-JSON LLM call → validate findings into immutable DTOs → format + gate exit on `--fail-on` severity. + +### Why "thin" +The v0 (npm) ancestor had **zero programmatic LLM code** — the host IDE *was* the LLM, driven by a 13-step markdown procedure (`git grep -liE 'anthropic|openai|llm' v0-last-npm -- src/**` → NONE). We are adding the thinnest correct PHP-native adjudication path that fits existing conventions 1:1, not building a framework. All three judges independently rejected the heavier tracks as speculative generality. + +### Explicitly deferred (NOT this engine) +| Deferred | Why | Where it lands | +|---|---|---| +| Real network driver (`AnthropicLlmClient`) | Engine is fully exercisable with `Null`/`Fake`; real value is a one-file follow-up behind the seam | +1.5d follow-up (Increment D) | +| `schema-dump` / `prepare` command | Sibling — `EventName::PrepareStepEnded` + `PrepareConfig.php` already exist | Reuses the `PatternRepository` seam | +| `ai-rules` generation | Sibling; consumes pattern data via the same repository | Reuses `PatternRepository` | +| AI-finding baseline/suppression | Ported v0 rule: **AI findings are never baselined** (report-only every run) | Post-MVP; `--fail-on` is the only noise control | +| Result caching (`sha1` skip) | Not needed for the changed-only path | Named seam in front of the runner loop | +| `llm_cost_usd` telemetry | Privacy gate forbids it without a `FieldAllowlist::SCHEMA` edit + sync-test change | Privacy-gated follow-up | +| AST-delegation (skip ~12 AST-replaceable patterns) | Cost optimization; spec:595 says 12/28 better served by phpstan/PHPMD | Documented roadmap note | + +--- + +## 2. The pattern data contract + +Verified across the 30 YAMLs in `resources/patterns/{core,php,php-laravel}/`. **28 are patterns; 2 are outliers** the loader MUST skip. + +### Discriminator (loader invariant) +A real pattern has **both** `verification:` and `examples:`. `preset.yaml` (carries `tools:`/`install_commands:`) and `module.yaml` (carries `label/language/framework/capabilities`) have neither. **Loader rule:** require non-empty `name` + non-empty `verification.rules` (the ported v0 `loader.ts` invariant), and skip any file missing `verification`+`examples`. + +### Canonical schema (28 files, 100% consistent — verified) +| Field | Type | Req | Values | Role | +|---|---|---|---|---| +| `name` | string (kebab) | yes | == filename stem | METADATA / id | +| `description` | string | yes | one line | **LLM PROMPT** | +| `category` | enum | yes | `solid` `clean-code` `architecture` `ddd` `framework` `php` | METADATA | +| `layer` | enum | yes | `core`(13) `php`(6) `laravel`(9) — == subdir == `enabled_presets` | METADATA / selection | +| `severity` | enum | yes | `critical`(10) `warning`(14) `suggestion`(4) | **LLM PROMPT + gate threshold** | +| `classification` | enum | yes | `mvp` (only value) | METADATA | +| `detection.signals[]` | list | yes | each `{type, value}` | **SCOPING (pre-filter)** | +| `detection.signals[].type` | enum | yes | `file`(21) `import`(7) `directory`(7) | SCOPING | +| `detection.signals[].value` | string | yes | glob (`**/*.php`), namespace glob (`App\Services\*`), or dir (`app/Services`) | SCOPING | +| `detection.confidence` | enum | yes | `high`(19) `medium`(10) | METADATA | +| `verification.rules[]` | list of string | yes | NL review criteria | **LLM PROMPT (the checklist)** | +| `examples.correct` / `examples.violation` | string block | yes | always exactly these two keys (never `good`/`bad`) | **LLM PROMPT (few-shot)** | +| `related_patterns[]` | list of string | optional (11/28) | refs to other `name`s | METADATA | + +**No pattern uses `enabled`, `glob`, `scope`, `prompt`, or `id`.** `id`=`name`; `glob`=`detection.signals[].value`; `scope`=`detection.signals`. + +**Field routing (decisive for the prompt):** +- **→ LLM:** `description`, `verification.rules`, `examples.correct`, `examples.violation`, `severity` (optionally `category`/`layer` for framing). +- **Pre-filter (never sent to LLM):** `detection.signals` → resolved to a concrete file set. +- **Pure metadata:** `name`, `category`, `layer`, `classification`, `confidence`, `related_patterns`. + +--- + +## 3. Architecture + +New namespace `Henryavila\Codeguard\Analyze\` under `src/Analyze/`, mirroring `src/Gates/`. Greenfield. + +### Component breakdown +| Class | Kind | Responsibility | Mirrors | +|---|---|---|---| +| `Analyze/Severity` | enum (string) | `critical\|warning\|suggestion` — prompt weighting + `--fail-on` threshold `compare()`. The one value-object worth day-1. | new | +| `Analyze/Pattern` | `final readonly` DTO | `fromArray()`; all schema fields, `severity:Severity`. | `GateConfig.php` | +| `Analyze/PatternRepository` | interface | `forPresets(list): list`, `has(string): bool`. Interfaced because the prepare/schema-dump sibling is real → grounded reuse. Only this seam is interfaced. | `CommandExecutor` | +| `Analyze/YamlPatternLoader` | `final` | `implements PatternRepository`. Finder+Yaml over the **package's own** `resources/patterns/*` filtered by `enabledPresets`, + `customPaths` + auto-discovered `base_path('.codeguard/patterns')`. Applies the §2 discriminator. | `LayerDecisionStore` | +| `Analyze/FileScopeResolver` | `final` | `--changed-only` (git `diff --name-only HEAD` + `--cached`), `--path=` subtree, or `--all` Finder walk → `list` abs paths. | git usage in hooks | +| `Analyze/PatternMatcher` | `final` | For each scoped file, returns the `list` whose `detection.signals` match (glob via `fnmatch`; `import`/`directory` approximated as path/namespace globs in MVP) → `AnalysisUnit{file, patterns}`. | new | +| `Analyze/FindingSchema` | `final` | **Single source of truth** for the finding contract: the JSON schema sent to constrain the LLM call **and** the rules `PatternMatch::fromArray` validates — request + validation cannot drift. | new | +| `Analyze/LlmClient` | interface | `review(AnalysisUnit, string $systemPrompt): list`. The Node-free transport seam — exact `CommandExecutor`→`ProcessCommandExecutor` precedent. | `CommandExecutor` | +| `Analyze/Drivers/NullLlmClient` | `final` | `implements LlmClient`; returns `[]`. Bound default when no driver configured. | `ProcessCommandExecutor` | +| `Analyze/PatternMatch` | `final readonly` DTO | `fromArray()` is the **trust boundary** (§3.1). Fields `patternKey, file, line, message, severity, confidence`. | `GateRunResult` | +| `Analyze/AnalyzeResult` | `final readonly` | `patternsChecked, matches, durationMs`; `passed(Severity)`/`failed()`. | `GateRunResult.php` | +| `Analyze/AnalyzeRunner` | `final` | Orchestrator. ctor `(Recorder, PatternRepository, FileScopeResolver, PatternMatcher, LlmClient, string $workingDirectory)`. Loads system prompt, loops units, calls `LlmClient`, validates, emits `AnalyzeEnded`. | `GateRunner.php` | +| `Commands/CodeguardAnalyzeCommand` | `final` Command | §5. | `CodeguardCheckCommand` | +| `resources/prompts/system.md` (+ ported `core/php/laravel` rubric) | versioned asset | Frozen system prompt (role + JSON output contract + v0 false-positive/severity rubric). External, not inline → reviewable diffs + future `prompt_version` hashing. | new | + +`tests/Support/FakeLlmClient.php` (Closure handler + `public array $calls = []`) mirrors `FakeCommandExecutor.php`. + +### 3.1 Trust boundary (anti-hallucination) +`PatternMatch::fromArray()` **drops** any raw finding where: +1. `patternKey` not in the set dispatched for that file **and** not resolvable via `PatternRepository::has()`; +2. `file` ≠ the analyzed file (the file-mismatch check); +3. `severity` outside `critical|warning|suggestion`; +4. `confidence` not numeric / not in `[0,1]`. + +The cheapest highest-value correctness graft (all three judges named it). A method, not a separate class. + +### 3.2 Prompt assembly +One **frozen system prompt** (cacheable across every file): role + JSON output contract from `FindingSchema` + the v0 false-positive + severity rubric ported verbatim into `resources/prompts/`. Per-unit user block = **file contents + appended `Pattern` blocks** (each = `description` + `verification.rules` + `examples.correct` + `examples.violation` + `severity`; never metadata, never `detection.signals`). **Whole-file, not diff** — structural smells aren't visible in a hunk. **One call per file** (file = expensive shared context, patterns = cheap appended blocks). + +### 3.3 Graceful degradation (the most important honesty fix) +With `NullLlmClient` bound (no driver), the command must NOT let empty-list ⇒ SUCCESS masquerade as a clean repo. It prints: +> `LLM driver not configured — set ANTHROPIC_API_KEY or config('codeguard.patterns.driver'). No patterns adjudicated.` + +then exits `SUCCESS` (CI never breaks for a missing key) and emits `analyze.ended` with status `Skip`. Notice wording only — NOT a second dual code-path. + +### ASCII flow +``` +codeguard:analyze --changed-only --fail-on=critical --context=ci + │ emit command.start{command:'analyze'} + ┌─────────────────────── AnalyzeRunner.run() ───────────────────────┐ + │ PatternRepository.forPresets(enabledPresets) ── skip 2 outliers │ + │ FileScopeResolver (git diff HEAD + --cached | --path | --all) │ + │ PatternMatcher (detection.signals ∩ files) → list │ + │ for each unit (ONE call/file): │ + │ systemPrompt + Pattern blocks ──► LlmClient ──► raw findings │ + │ PatternMatch::fromArray() ── TRUST BOUNDARY (§3.1) drops bad │ + │ emit analyze.ended{patterns_checked_count, matches_count} │ + └─► AnalyzeResult{patternsChecked, matches, durationMs} ─┘ + ▼ ConsoleFormatter: ✗ critical / ⚠ warning / → suggestion + ▼ exit = FAILURE iff matches at/above --fail-on else SUCCESS + ▼ emit command.end{command:'analyze', exit_code} +``` + +--- + +## 4. LLM transport (Fork 4) — RECOMMENDATION (user must confirm) + +**Recommendation:** Ship the `LlmClient` **interface** + `NullLlmClient` default **now** (MVP, no network, no credentials). Ship the official `anthropic-ai/sdk` adapter (`AnthropicLlmClient`) as a **+1.5d follow-up** behind the seam, declared `suggest` + `require-dev` only (NOT a hard `require`). + +### SDK vs CLI +| | `anthropic-ai/sdk` (recommended) | `claude -p` CLI shell-out | +|---|---|---| +| Structured output | native JSON-schema → deterministic shape | `--json-schema` | +| Prompt caching | `CacheControlEphemeral(ttl:'5m')` on system prompt | n/a | +| Determinism | `temperature:0` | `temperature:0` | +| Fragility | none | user's `claude` is a zsh function → must invoke via absolute path/Process | +| Billing risk | per-API-key (`ANTHROPIC_API_KEY`) | from 2026-06-15 subscription `claude -p` draws from a separate Agent SDK credit | + +**Honest cost:** `anthropic-ai/sdk` is **v0.x** (pre-1.0 churn). Mitigation: pin it, wrap behind `LlmClient` so a breaking change touches **one adapter file**, declare it `suggest`-only so the package core installs with **zero LLM dependency**. + +### Mocked in tests +`FakeLlmClient implements LlmClient` — Closure returns canned raw-finding arrays; records every call in `public array $calls = []` (mirror of `FakeCommandExecutor`). Feature tests swap it via the container + point `Recorder` at a temp `.jsonl`. Assert on **schema + presence + severity**, never exact prose. + +--- + +## 5. `codeguard:analyze` command + +``` +codeguard:analyze + {--changed-only : Analyze only git-changed + staged files (DEFAULT scope).} + {--path= : Narrow scope to a file or subtree.} + {--all : Full scan — CI/manual.} + {--fail-on=critical : Exit non-zero when a finding at/above this severity exists — + critical|warning|suggestion|never.} + {--context=manual : Telemetry context — pre-commit|pre-push|ci|manual.} +``` + +- `final class`, `declare(strict_types=1)`, constructor-less — deps via `handle(CodeguardConfig, AnalyzeRunner, Recorder): int`. +- `resolveContext()` copied verbatim from `CodeguardCheckCommand:93-98`. `--fail-on` parsed into `Severity` (`never` ⇒ report-only). +- Output: findings grouped by severity, glyphs `✗`/`⚠`/`→`, each line `file:line · pattern · message · confidence`. + +**Exit semantics** +| Condition | Exit | +|---|---| +| ≥1 match at/above `--fail-on` (default `critical`) | FAILURE (1) | +| Only matches below `--fail-on` | SUCCESS (0), printed | +| `--fail-on=never` | always SUCCESS (observe-first rollout) | +| `NullLlmClient` (no driver) | SUCCESS + degradation notice (§3.3) | + +No `--pattern`/`--preset` runtime knobs in MVP — selection stays config-driven; `--fail-on` is the single runtime knob. + +--- + +## 6. Config schema + +**No config changes for MVP.** The `patterns` block already exists and is correct (`config/codeguard.php:199-207`, parsed into `CodeguardConfig`): + +```php +'patterns' => [ + 'enabled_presets' => ['core', 'php', 'php-laravel'], // == layer == subdir + 'custom_paths' => [], // + auto-discovers base_path('.codeguard/patterns') + 'baseline_path' => base_path('.codeguard/baseline.json'), +], +``` + +The **only** new key, added when the real driver lands: +```php + 'driver' => env('CODEGUARD_PATTERNS_DRIVER'), // null|'anthropic' → bound LlmClient; null ⇒ NullLlmClient +``` + +> **Loader path precedence (verified gotcha):** the publish tag `codeguard-patterns` maps `resources/patterns` → `.codeguard/patterns-vendor` (`CodeguardServiceProvider.php:321`), but config auto-discovery + `custom_paths` point at `.codeguard/patterns`. `YamlPatternLoader` must read the PACKAGE's own `resources/patterns/{core,php,php-laravel}` as the primary source, + `customPaths` + auto-discovered `base_path('.codeguard/patterns')` — **never the `-vendor` publish path.** `baseline_path` is unused by this engine (AI findings are never baselined). + +--- + +## 7. Telemetry + +Reuses reserved slots with **zero schema change**: +- `EventName::AnalyzeEnded = 'analyze.ended'` (`EventName.php:42`), allowlist schema = exactly `{patterns_checked_count:int, matches_count:int}` (`FieldAllowlist.php:123-126`). +- `'analyze'` already in the `command.start`/`command.end` enum (`FieldAllowlist.php:50,54`). + +| Event | Status | Extras | +|---|---|---| +| `command.start` | Ok | `{command:'analyze', preset_flag:null}` | +| `analyze.ended` | Ok\|Fail\|Skip | `{patterns_checked_count:int, matches_count:int}` | +| `command.end` | Ok\|Fail | `{command:'analyze', exit_code:int}` | + +`EventStatus::Skip` is emitted for the no-driver degradation path so the dashboard distinguishes "ran clean" from "didn't adjudicate." No free-form strings (privacy gate). `llm_cost_usd` is a deliberate privacy-gated follow-up. + +--- + +## 8. TDD task list (RED → GREEN, shippable increments) + +Estimates are **focused days**, grounded in verified 1:1 mirrors (each class has an existing template to copy). + +### Increment A — Walking skeleton (load patterns + analyze one file w/ mocked LLM + print one finding) — ~1.5d +1. RED `SeverityTest`: `from('critical')`, `compare()` ordering. GREEN `Severity` enum. +2. RED `PatternTest`: `Pattern::fromArray()` maps a fixture YAML array. GREEN `Pattern`. +3. RED `YamlPatternLoaderTest`: loads N fixture patterns, **skips the 2 outliers**, respects `enabledPresets`, reads package `resources/patterns` not `-vendor`. GREEN `YamlPatternLoader`. +4. RED `tests/Support/FakeLlmClient.php` + `CodeguardAnalyzeCommandTest`: one fixture file + one pattern + Fake returning one canned finding → `artisan('codeguard:analyze',['--path'=>fixture,'--context'=>'ci'])` prints it, exits 0. GREEN minimal `AnalyzeRunner`, `PatternMatch`, `AnalyzeResult`, `LlmClient`, `NullLlmClient`, `FileScopeResolver` (path-only), `PatternMatcher` (glob-only), `CodeguardAnalyzeCommand`, `FindingSchema`, `registerAnalyzeServices()`, registration. + +### Increment B — Scope + matching + trust boundary — ~1.5d +5. RED `FileScopeResolverTest`: `--changed-only`, `--path`, `--all`. GREEN. +6. RED `PatternMatcherTest`: file globs match; `import`/`directory` approximated; non-matching excluded. GREEN. +7. RED `PatternMatchTest` (trust boundary §3.1): drop unknown `patternKey`, bad severity, `file` mismatch, bad confidence. GREEN. + +### Increment C — Exit semantics + telemetry + degradation — ~1.5d +8. RED `AnalyzeResultTest`: `passed(Severity)`/`failed()` per threshold. GREEN. +9. RED Feature: `--fail-on=critical` → FAILURE on critical; warning-only → SUCCESS; `never` → SUCCESS. GREEN. +10. RED Feature: emits `command.start` → `analyze.ended{...}` → `command.end` (read temp `.jsonl`); asserts **one LLM call per file** (`FakeLlmClient::$calls`); asserts **no baseline written**. GREEN. +11. RED Feature: `NullLlmClient` → degradation notice + exit 0 + `analyze.ended` status `Skip`. GREEN §3.3. +12. Coverage gate: `composer test:coverage --min=80` green. + +### Increment D — Real driver (follow-up, behind the seam) — ~1.5d +13. Add `anthropic-ai/sdk` pinned, `suggest` + `require-dev`. RED `AnthropicLlmClientTest` (Mockery on the SDK). GREEN `Drivers/AnthropicLlmClient`: `AnalysisUnit`→native JSON-schema request, `temperature:0`, `CacheControlEphemeral`; bound when `driver='anthropic'`. + +| Increment | Days | +|---|---| +| A — walking skeleton | 1.5 | +| B — scope/matching/trust | 1.5 | +| C — exit/telemetry/degradation | 1.5 | +| **MVP total (A–C)** | **4.5** | +| D — real driver (follow-up) | +1.5 | +| **Total incl. transport** | **6.0** | + +> **Estimate basis:** every class has a verified 1:1 template (`AnalyzeRunner`←`GateRunner`, `LlmClient`/`Null`/`Fake`←`CommandExecutor`/`Process`/`Fake`, command←`CodeguardCheckCommand`, telemetry slots pre-reserved). Copy-and-adapt + test-first, not net design. Risk buffer is in D (SDK v0.x structured-output mapping). + +--- + +## 9. Open decisions (need the user) + +1. **Transport (§4):** official `anthropic-ai/sdk` as default real driver? Or `claude -p` CLI despite shell-function/PATH fragility + June-15 billing caveat? *(Rec: SDK.)* +2. **Bundle a default driver?** *(Rec: no — `NullLlmClient` default + SDK `suggest`-only, core installs with zero LLM dependency.)* +3. **Severity-gate default:** `--fail-on=critical` (block only highest-confidence smells) or `--fail-on=never` (pure observe) for first releases? *(Rec: critical.)* +4. **v0-faithful context-emit driver** (writes `{patterns,context}` JSON for an IDE agent) behind `LlmClient`, or notice-only degradation? *(Rec: notice-only now.)* +5. **`--changed-only` as implicit default** vs explicit scope flag? *(Rec: implicit.)* + +--- + +## 10. Honesty check — which claims become TRUE on ship + +After **Increments A–C (MVP, no network):** +- ✅ `codeguard:analyze` exists and runs (wired command, telemetry, exit codes). +- ✅ Loads curated patterns / pre-filters files by `detection.signals`. +- ✅ Pluggable, Node-free LLM transport (interface + Null default; mockable in CI). +- ✅ Gates CI on severity (`--fail-on`). +- ✅ Privacy-safe telemetry for analyze. +- ⚠️ "Reviews code with an LLM / finds god-objects" — **NOT YET TRUE** until a real driver is configured. With `NullLlmClient` the command honestly prints the degradation notice — it does **not** fake a clean repo. **Do not market self-adjudication as shipped until Increment D + a configured key.** + +After **Increment D + configured key:** +- ✅ AI-powered review reaching smells AST tools can't. +- ✅ Structured, validated, anti-hallucination findings (trust boundary §3.1). +- ✅ Prompt-cached, deterministic-shape (`temperature:0`). + +**Still FALSE / unclaimed after all increments** (be blunt in README): no AI-finding suppression/baseline (report-only by design), no result caching, no per-call cost telemetry, no AST-delegation of the ~12 AST-replaceable patterns, and `import`/`directory` detection signals are glob-approximated. From 0dfb95344aaefd2a7a717b2555a59c1056106cf2 Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 10:17:32 -0300 Subject: [PATCH 03/21] =?UTF-8?q?feat(analyze):=20pattern=20engine=20MVP?= =?UTF-8?q?=20=E2=80=94=20codeguard:analyze=20with=20pluggable=20LLM=20sea?= =?UTF-8?q?m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Thin Adjudicator design (docs/specs/2026-06-03-patterns-engine-design.md), package-side only and network-free for the MVP (Increments A–C). src/Analyze/*: - Severity enum (+ weight/meets for --fail-on threshold) - Pattern + DetectionSignal DTOs; YamlPatternLoader (implements PatternRepository) loads the package's own resources/patterns/{core,php,php-laravel}, applies the verification+examples discriminator (skips preset.yaml/module.yaml outliers) - FileScopeResolver: --changed-only (git via CommandExecutor), --path, --all - PatternMatcher: detection.signals → AnalysisUnit (glob/{}/** + dir + import PSR-4) - LlmClient seam + NullLlmClient default; FindingSchema as request/validation SSOT - PatternMatch::fromArray trust boundary (drops hallucinated/file-mismatched/ out-of-range findings); AnalyzeResult (passed/failed per threshold) - AnalyzeRunner: one LLM call per file, emits analyze.ended (reused telemetry slot) CodeguardAnalyzeCommand: --changed-only|--path|--all, --fail-on=critical|...|never, --context. With no driver it prints an honest degradation notice + telemetry status Skip — it never fakes a clean repo. Tests: 33 new (unit per class + FakeLlmClient + command feature with temp telemetry). Suite 398 -> 433 green. PHPStan level 5 clean. Real driver (Anthropic/subscription) is Increment D, deferred pending the transport decision. --- resources/prompts/system.md | 20 ++ src/Analyze/AnalysisUnit.php | 30 +++ src/Analyze/AnalyzeResult.php | 54 +++++ src/Analyze/AnalyzeRunner.php | 105 ++++++++ src/Analyze/DetectionSignal.php | 20 ++ src/Analyze/Drivers/NullLlmClient.php | 26 ++ src/Analyze/FileScopeResolver.php | 126 ++++++++++ src/Analyze/FindingSchema.php | 56 +++++ src/Analyze/LlmClient.php | 30 +++ src/Analyze/Pattern.php | 89 +++++++ src/Analyze/PatternMatch.php | 72 ++++++ src/Analyze/PatternMatcher.php | 108 +++++++++ src/Analyze/PatternRepository.php | 26 ++ src/Analyze/Severity.php | 36 +++ src/Analyze/YamlPatternLoader.php | 119 +++++++++ src/CodeguardServiceProvider.php | 62 +++++ src/Commands/CodeguardAnalyzeCommand.php | 156 ++++++++++++ tests/Feature/CodeguardAnalyzeCommandTest.php | 226 ++++++++++++++++++ tests/Support/FakeLlmClient.php | 39 +++ tests/Unit/Analyze/AnalyzeResultTest.php | 40 ++++ tests/Unit/Analyze/FileScopeResolverTest.php | 125 ++++++++++ tests/Unit/Analyze/LlmContractTest.php | 27 +++ tests/Unit/Analyze/PatternMatchTest.php | 93 +++++++ tests/Unit/Analyze/PatternMatcherTest.php | 66 +++++ tests/Unit/Analyze/PatternTest.php | 47 ++++ tests/Unit/Analyze/SeverityTest.php | 21 ++ tests/Unit/Analyze/YamlPatternLoaderTest.php | 99 ++++++++ 27 files changed, 1918 insertions(+) create mode 100644 resources/prompts/system.md create mode 100644 src/Analyze/AnalysisUnit.php create mode 100644 src/Analyze/AnalyzeResult.php create mode 100644 src/Analyze/AnalyzeRunner.php create mode 100644 src/Analyze/DetectionSignal.php create mode 100644 src/Analyze/Drivers/NullLlmClient.php create mode 100644 src/Analyze/FileScopeResolver.php create mode 100644 src/Analyze/FindingSchema.php create mode 100644 src/Analyze/LlmClient.php create mode 100644 src/Analyze/Pattern.php create mode 100644 src/Analyze/PatternMatch.php create mode 100644 src/Analyze/PatternMatcher.php create mode 100644 src/Analyze/PatternRepository.php create mode 100644 src/Analyze/Severity.php create mode 100644 src/Analyze/YamlPatternLoader.php create mode 100644 src/Commands/CodeguardAnalyzeCommand.php create mode 100644 tests/Feature/CodeguardAnalyzeCommandTest.php create mode 100644 tests/Support/FakeLlmClient.php create mode 100644 tests/Unit/Analyze/AnalyzeResultTest.php create mode 100644 tests/Unit/Analyze/FileScopeResolverTest.php create mode 100644 tests/Unit/Analyze/LlmContractTest.php create mode 100644 tests/Unit/Analyze/PatternMatchTest.php create mode 100644 tests/Unit/Analyze/PatternMatcherTest.php create mode 100644 tests/Unit/Analyze/PatternTest.php create mode 100644 tests/Unit/Analyze/SeverityTest.php create mode 100644 tests/Unit/Analyze/YamlPatternLoaderTest.php diff --git a/resources/prompts/system.md b/resources/prompts/system.md new file mode 100644 index 0000000..aa6752a --- /dev/null +++ b/resources/prompts/system.md @@ -0,0 +1,20 @@ +You are a senior code reviewer performing pattern-based review. You receive one +source file and a list of quality patterns that may apply to it. For each +pattern, judge ONLY whether the file violates it, using the pattern's +verification rules and its correct/violation examples as the rubric. + +Rules: +- Report a finding ONLY for a real violation you can point to a specific line for. +- Do NOT invent issues. When unsure, omit. False positives erode trust. +- Framework base classes naturally have many methods — do not flag them. +- Judge the file as written; do not speculate about code you cannot see. + +Output a JSON array. Each finding is an object with exactly: + - pattern_key: the exact key of the violated pattern + - file: the exact file path you were given + - line: the 1-based line number of the violation + - message: one concrete sentence naming what violates the rule + - severity: the pattern's severity (critical | warning | suggestion) + - confidence: a number 0.0–1.0, your confidence this is a true violation + +Return [] when the file violates none of the patterns. diff --git a/src/Analyze/AnalysisUnit.php b/src/Analyze/AnalysisUnit.php new file mode 100644 index 0000000..e23d324 --- /dev/null +++ b/src/Analyze/AnalysisUnit.php @@ -0,0 +1,30 @@ + $patterns + */ + public function __construct( + public string $file, + public array $patterns, + ) {} + + /** + * @return list + */ + public function patternKeys(): array + { + return array_map(static fn (Pattern $p): string => $p->key, $this->patterns); + } +} diff --git a/src/Analyze/AnalyzeResult.php b/src/Analyze/AnalyzeResult.php new file mode 100644 index 0000000..0e6b563 --- /dev/null +++ b/src/Analyze/AnalyzeResult.php @@ -0,0 +1,54 @@ + $matches + */ + public function __construct( + public int $patternsChecked, + public array $matches, + public int $durationMs, + public bool $adjudicated = true, + ) {} + + /** + * Passes when no finding reaches the $failOn threshold. A null threshold + * (`--fail-on=never`) always passes. + */ + public function passed(?Severity $failOn): bool + { + if ($failOn === null) { + return true; + } + + foreach ($this->matches as $match) { + if ($match->severity->meets($failOn)) { + return false; + } + } + + return true; + } + + public function failed(?Severity $failOn): bool + { + return ! $this->passed($failOn); + } + + public function matchesCount(): int + { + return count($this->matches); + } +} diff --git a/src/Analyze/AnalyzeRunner.php b/src/Analyze/AnalyzeRunner.php new file mode 100644 index 0000000..2bc0ed2 --- /dev/null +++ b/src/Analyze/AnalyzeRunner.php @@ -0,0 +1,105 @@ + $files Absolute paths (already scoped by the command). + * @param list $presets + */ + public function run(array $files, array $presets, ?Severity $failOn, string $context): AnalyzeResult + { + $start = hrtime(true); + + $patterns = $this->patterns->forPresets($presets); + $units = $this->matcher->match($files, $patterns); + + $adjudicated = $this->llm->isConfigured(); + $matches = []; + $checks = 0; + + if ($adjudicated) { + $systemPrompt = $this->systemPrompt(); + + foreach ($units as $unit) { + $checks += count($unit->patterns); + + foreach ($this->llm->review($unit, $systemPrompt) as $raw) { + if (! is_array($raw)) { + continue; + } + + $match = PatternMatch::fromArray($raw, $unit, $this->patterns); + if ($match !== null) { + $matches[] = $match; + } + } + } + } + + $durationMs = (int) round((hrtime(true) - $start) / 1_000_000); + + $result = new AnalyzeResult( + patternsChecked: $checks, + matches: $matches, + durationMs: $durationMs, + adjudicated: $adjudicated, + ); + + $this->recorder->record( + event: EventName::AnalyzeEnded, + status: $this->status($result, $failOn), + durationMs: $durationMs, + extras: [ + 'patterns_checked_count' => $checks, + 'matches_count' => count($matches), + ], + ); + + return $result; + } + + private function status(AnalyzeResult $result, ?Severity $failOn): EventStatus + { + if (! $result->adjudicated) { + return EventStatus::Skip; + } + + return $result->failed($failOn) ? EventStatus::Fail : EventStatus::Ok; + } + + private function systemPrompt(): string + { + if (is_file($this->systemPromptPath)) { + $contents = file_get_contents($this->systemPromptPath); + if ($contents !== false) { + return $contents; + } + } + + return 'You are a senior code reviewer. Return only real violations as a JSON array of findings.'; + } +} diff --git a/src/Analyze/DetectionSignal.php b/src/Analyze/DetectionSignal.php new file mode 100644 index 0000000..9034364 --- /dev/null +++ b/src/Analyze/DetectionSignal.php @@ -0,0 +1,20 @@ + + */ + public function changedOnly(): array + { + $files = array_merge( + $this->gitFiles(['git', 'diff', '--name-only', '--diff-filter=ACMR', 'HEAD']), + $this->gitFiles(['git', 'diff', '--name-only', '--diff-filter=ACMR', '--cached']), + ); + + return $this->toExistingPhpFiles($files); + } + + /** + * @return list + */ + public function path(string $path): array + { + $absolute = $this->absolute($path); + + if (is_file($absolute)) { + return str_ends_with($absolute, '.php') ? [$absolute] : []; + } + + if (! is_dir($absolute)) { + return []; + } + + return $this->phpFilesIn($absolute); + } + + /** + * @return list + */ + public function all(): array + { + if (! is_dir($this->workingDirectory)) { + return []; + } + + return $this->phpFilesIn($this->workingDirectory); + } + + /** + * @param list $command + * @return list + */ + private function gitFiles(array $command): array + { + $buffer = ''; + $this->executor->run($command, function (string $chunk) use (&$buffer): void { + $buffer .= $chunk; + }); + + $lines = preg_split('/\R/', trim($buffer)) ?: []; + + return array_values(array_filter($lines, static fn (string $line): bool => $line !== '')); + } + + /** + * @param list $files + * @return list + */ + private function toExistingPhpFiles(array $files): array + { + $unique = []; + foreach ($files as $file) { + if (! str_ends_with($file, '.php')) { + continue; + } + + $absolute = $this->absolute($file); + if (is_file($absolute)) { + $unique[$absolute] = true; + } + } + + return array_keys($unique); + } + + /** + * @return list + */ + private function phpFilesIn(string $dir): array + { + $files = []; + foreach (Finder::create()->files()->in($dir)->name('*.php')->sortByName() as $file) { + $files[] = $file->getRealPath() ?: $file->getPathname(); + } + + return $files; + } + + private function absolute(string $path): string + { + return str_starts_with($path, DIRECTORY_SEPARATOR) + ? $path + : $this->workingDirectory.DIRECTORY_SEPARATOR.$path; + } +} diff --git a/src/Analyze/FindingSchema.php b/src/Analyze/FindingSchema.php new file mode 100644 index 0000000..c82df0b --- /dev/null +++ b/src/Analyze/FindingSchema.php @@ -0,0 +1,56 @@ + + */ + public static function jsonSchema(): array + { + return [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'required' => [ + self::KEY_PATTERN, + self::KEY_FILE, + self::KEY_LINE, + self::KEY_MESSAGE, + self::KEY_SEVERITY, + self::KEY_CONFIDENCE, + ], + 'properties' => [ + self::KEY_PATTERN => ['type' => 'string'], + self::KEY_FILE => ['type' => 'string'], + self::KEY_LINE => ['type' => 'integer'], + self::KEY_MESSAGE => ['type' => 'string'], + self::KEY_SEVERITY => ['type' => 'string', 'enum' => ['critical', 'warning', 'suggestion']], + self::KEY_CONFIDENCE => ['type' => 'number'], + ], + ], + ]; + } +} diff --git a/src/Analyze/LlmClient.php b/src/Analyze/LlmClient.php new file mode 100644 index 0000000..5ed2155 --- /dev/null +++ b/src/Analyze/LlmClient.php @@ -0,0 +1,30 @@ +> + */ + public function review(AnalysisUnit $unit, string $systemPrompt): array; + + /** + * Whether a real adjudicating driver is configured. False for the null + * driver, which lets the command print an honest degradation notice + * instead of reporting an unadjudicated repo as clean. + */ + public function isConfigured(): bool; +} diff --git a/src/Analyze/Pattern.php b/src/Analyze/Pattern.php new file mode 100644 index 0000000..73273b0 --- /dev/null +++ b/src/Analyze/Pattern.php @@ -0,0 +1,89 @@ + $detectionSignals + * @param list $verificationRules + * @param list $relatedPatterns + */ + public function __construct( + public string $key, + public string $name, + public string $description, + public string $category, + public string $layer, + public Severity $severity, + public string $classification, + public array $detectionSignals, + public string $confidence, + public array $verificationRules, + public string $examplesCorrect, + public string $examplesViolation, + public array $relatedPatterns, + ) {} + + /** + * @param array $data + */ + public static function fromArray(string $key, array $data): self + { + $detection = self::asArray($data['detection'] ?? null); + + $signals = []; + foreach (self::asArray($detection['signals'] ?? null) as $signal) { + $signal = self::asArray($signal); + if (isset($signal['type'], $signal['value'])) { + $signals[] = new DetectionSignal((string) $signal['type'], (string) $signal['value']); + } + } + + $verification = self::asArray($data['verification'] ?? null); + $rules = array_values(array_map( + static fn (mixed $rule): string => (string) (is_scalar($rule) ? $rule : ''), + self::asArray($verification['rules'] ?? null), + )); + + $examples = self::asArray($data['examples'] ?? null); + $related = array_values(array_map( + static fn (mixed $name): string => (string) (is_scalar($name) ? $name : ''), + self::asArray($data['related_patterns'] ?? null), + )); + + return new self( + key: $key, + name: (string) ($data['name'] ?? $key), + description: (string) ($data['description'] ?? ''), + category: (string) ($data['category'] ?? ''), + layer: (string) ($data['layer'] ?? ''), + severity: Severity::tryFrom((string) ($data['severity'] ?? '')) ?? Severity::Warning, + classification: (string) ($data['classification'] ?? 'mvp'), + detectionSignals: $signals, + confidence: (string) ($detection['confidence'] ?? 'medium'), + verificationRules: $rules, + examplesCorrect: (string) ($examples['correct'] ?? ''), + examplesViolation: (string) ($examples['violation'] ?? ''), + relatedPatterns: $related, + ); + } + + /** + * @return array + */ + private static function asArray(mixed $value): array + { + return is_array($value) ? $value : []; + } +} diff --git a/src/Analyze/PatternMatch.php b/src/Analyze/PatternMatch.php new file mode 100644 index 0000000..6c553f7 --- /dev/null +++ b/src/Analyze/PatternMatch.php @@ -0,0 +1,72 @@ + $raw + */ + public static function fromArray(array $raw, AnalysisUnit $unit, PatternRepository $patterns): ?self + { + $key = $raw[FindingSchema::KEY_PATTERN] ?? null; + $file = $raw[FindingSchema::KEY_FILE] ?? null; + $line = $raw[FindingSchema::KEY_LINE] ?? null; + $message = $raw[FindingSchema::KEY_MESSAGE] ?? null; + $severityRaw = $raw[FindingSchema::KEY_SEVERITY] ?? null; + $confidence = $raw[FindingSchema::KEY_CONFIDENCE] ?? null; + + if (! is_string($key) || ! is_string($file) || ! is_string($message) || ! is_string($severityRaw)) { + return null; + } + + if (! is_numeric($line) || ! is_numeric($confidence)) { + return null; + } + + $severity = Severity::tryFrom($severityRaw); + if ($severity === null) { + return null; + } + + $confidenceValue = (float) $confidence; + if ($confidenceValue < 0.0 || $confidenceValue > 1.0) { + return null; + } + + // patternKey must be one dispatched for this unit, or a real corpus key. + if (! in_array($key, $unit->patternKeys(), true) && ! $patterns->has($key)) { + return null; + } + + // The finding must point at the file we actually analyzed. + if (basename($file) !== basename($unit->file)) { + return null; + } + + return new self( + patternKey: $key, + file: $unit->file, + line: max(1, (int) $line), + message: $message, + severity: $severity, + confidence: $confidenceValue, + ); + } +} diff --git a/src/Analyze/PatternMatcher.php b/src/Analyze/PatternMatcher.php new file mode 100644 index 0000000..f635f20 --- /dev/null +++ b/src/Analyze/PatternMatcher.php @@ -0,0 +1,108 @@ + $files Absolute paths. + * @param list $patterns + * @return list + */ + public function match(array $files, array $patterns): array + { + $units = []; + + foreach ($files as $file) { + $relative = $this->relative($file); + + $matched = array_values(array_filter( + $patterns, + fn (Pattern $pattern): bool => $this->patternApplies($pattern, $relative), + )); + + if ($matched !== []) { + $units[] = new AnalysisUnit($file, $matched); + } + } + + return $units; + } + + private function patternApplies(Pattern $pattern, string $relativePath): bool + { + foreach ($pattern->detectionSignals as $signal) { + if ($this->signalMatches($signal, $relativePath)) { + return true; + } + } + + return false; + } + + private function signalMatches(DetectionSignal $signal, string $relativePath): bool + { + return match ($signal->type) { + 'file' => preg_match($this->globToRegex($signal->value), $relativePath) === 1, + 'directory' => str_starts_with($relativePath, rtrim($signal->value, '/').'/'), + 'import' => str_starts_with($relativePath, $this->importToPathPrefix($signal->value)), + default => false, + }; + } + + private function globToRegex(string $glob): string + { + // `**/` matches zero or more directories. + $glob = str_replace('**/', "\x01", $glob); + + $regex = ''; + $length = strlen($glob); + for ($i = 0; $i < $length; $i++) { + $char = $glob[$i]; + $regex .= match (true) { + $char === "\x01" => '(?:.*/)?', + $char === '*' => '[^/]*', + $char === '?' => '[^/]', + $char === '{' => '(?:', + $char === '}' => ')', + $char === ',' => '|', + default => preg_quote($char, '#'), + }; + } + + return '#^'.$regex.'$#'; + } + + private function importToPathPrefix(string $namespaceGlob): string + { + $path = str_replace('\\', '/', rtrim($namespaceGlob, '\\*')); + $segments = explode('/', $path, 2); + $segments[0] = lcfirst($segments[0]); + + return rtrim(implode('/', $segments), '/').'/'; + } + + private function relative(string $absolute): string + { + $prefix = rtrim($this->workingDirectory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + $relative = str_starts_with($absolute, $prefix) + ? substr($absolute, strlen($prefix)) + : $absolute; + + return str_replace('\\', '/', $relative); + } +} diff --git a/src/Analyze/PatternRepository.php b/src/Analyze/PatternRepository.php new file mode 100644 index 0000000..0dab816 --- /dev/null +++ b/src/Analyze/PatternRepository.php @@ -0,0 +1,26 @@ + $presets + * @return list + */ + public function forPresets(array $presets): array; + + /** + * Whether a pattern with this key exists anywhere in the corpus + * (used by the {@see PatternMatch} trust boundary). + */ + public function has(string $key): bool; +} diff --git a/src/Analyze/Severity.php b/src/Analyze/Severity.php new file mode 100644 index 0000000..666d02e --- /dev/null +++ b/src/Analyze/Severity.php @@ -0,0 +1,36 @@ + 3, + self::Warning => 2, + self::Suggestion => 1, + }; + } + + /** + * True when this severity is at least as severe as $threshold. + */ + public function meets(self $threshold): bool + { + return $this->weight() >= $threshold->weight(); + } +} diff --git a/src/Analyze/YamlPatternLoader.php b/src/Analyze/YamlPatternLoader.php new file mode 100644 index 0000000..3e1253c --- /dev/null +++ b/src/Analyze/YamlPatternLoader.php @@ -0,0 +1,119 @@ +|null */ + private ?array $allCache = null; + + /** + * @param list $customPaths + */ + public function __construct( + private readonly Filesystem $filesystem, + private readonly string $packagePatternsPath, + private readonly array $customPaths = [], + ) {} + + public function forPresets(array $presets): array + { + $patterns = []; + + foreach ($presets as $preset) { + foreach ($this->loadDir($this->packagePatternsPath.DIRECTORY_SEPARATOR.$preset) as $pattern) { + $patterns[] = $pattern; + } + } + + foreach ($this->customPaths as $path) { + foreach ($this->loadDir($path) as $pattern) { + $patterns[] = $pattern; + } + } + + return $patterns; + } + + public function has(string $key): bool + { + foreach ($this->all() as $pattern) { + if ($pattern->key === $key) { + return true; + } + } + + return false; + } + + /** + * @return list + */ + private function all(): array + { + if ($this->allCache !== null) { + return $this->allCache; + } + + $patterns = $this->loadDir($this->packagePatternsPath); + foreach ($this->customPaths as $path) { + foreach ($this->loadDir($path) as $pattern) { + $patterns[] = $pattern; + } + } + + return $this->allCache = $patterns; + } + + /** + * @return list + */ + private function loadDir(string $dir): array + { + if (! $this->filesystem->isDirectory($dir)) { + return []; + } + + $patterns = []; + $finder = Finder::create()->files()->in($dir)->name('*.yaml')->sortByName(); + + foreach ($finder as $file) { + $parsed = Yaml::parseFile($file->getPathname()); + if (! is_array($parsed) || ! $this->isPattern($parsed)) { + continue; + } + + $patterns[] = Pattern::fromArray($file->getBasename('.yaml'), $parsed); + } + + return $patterns; + } + + /** + * @param array $data + */ + private function isPattern(array $data): bool + { + $verification = $data['verification'] ?? null; + $examples = $data['examples'] ?? null; + + return is_array($verification) + && is_array($verification['rules'] ?? null) + && ($verification['rules'] !== []) + && is_array($examples); + } +} diff --git a/src/CodeguardServiceProvider.php b/src/CodeguardServiceProvider.php index 8b65d98..aea25cc 100644 --- a/src/CodeguardServiceProvider.php +++ b/src/CodeguardServiceProvider.php @@ -4,6 +4,14 @@ namespace Henryavila\Codeguard; +use Henryavila\Codeguard\Analyze\AnalyzeRunner; +use Henryavila\Codeguard\Analyze\Drivers\NullLlmClient; +use Henryavila\Codeguard\Analyze\FileScopeResolver; +use Henryavila\Codeguard\Analyze\LlmClient; +use Henryavila\Codeguard\Analyze\PatternMatcher; +use Henryavila\Codeguard\Analyze\PatternRepository; +use Henryavila\Codeguard\Analyze\YamlPatternLoader; +use Henryavila\Codeguard\Commands\CodeguardAnalyzeCommand; use Henryavila\Codeguard\Commands\CodeguardCheckCommand; use Henryavila\Codeguard\Commands\CodeguardInstallCommand; use Henryavila\Codeguard\Commands\CodeguardInstallOverrideCommand; @@ -66,6 +74,7 @@ public function register(): void $this->registerInstallServices(); $this->registerTelemetryServices(); $this->registerTestingServices(); + $this->registerAnalyzeServices(); } public function boot(): void @@ -297,6 +306,58 @@ private function registerTestingServices(): void }); } + private function registerAnalyzeServices(): void + { + $patternsSourcePath = realpath(__DIR__.'/../resources/patterns') ?: __DIR__.'/../resources/patterns'; + $systemPromptPath = realpath(__DIR__.'/../resources/prompts/system.md') ?: __DIR__.'/../resources/prompts/system.md'; + + $this->app->singleton(PatternRepository::class, static function (Application $app) use ($patternsSourcePath): PatternRepository { + /** @var Filesystem $filesystem */ + $filesystem = $app->make(Filesystem::class); + + /** @var CodeguardConfig $config */ + $config = $app->make(CodeguardConfig::class); + + $customPaths = $config->customPatternPaths; + $autoDiscovered = $app->basePath('.codeguard'.DIRECTORY_SEPARATOR.'patterns'); + if ($filesystem->isDirectory($autoDiscovered)) { + $customPaths[] = $autoDiscovered; + } + + return new YamlPatternLoader( + filesystem: $filesystem, + packagePatternsPath: $patternsSourcePath, + customPaths: array_values($customPaths), + ); + }); + + // Default driver: NullLlmClient adjudicates nothing — the command then + // prints an honest degradation notice. A real driver lands in a later + // increment behind this same LlmClient seam. + $this->app->singleton(LlmClient::class, NullLlmClient::class); + + $this->app->singleton(FileScopeResolver::class, static function (Application $app): FileScopeResolver { + return new FileScopeResolver( + executor: $app->make(CommandExecutor::class), + workingDirectory: $app->basePath(), + ); + }); + + $this->app->singleton(PatternMatcher::class, static function (Application $app): PatternMatcher { + return new PatternMatcher(workingDirectory: $app->basePath()); + }); + + $this->app->singleton(AnalyzeRunner::class, static function (Application $app) use ($systemPromptPath): AnalyzeRunner { + return new AnalyzeRunner( + recorder: $app->make(Recorder::class), + patterns: $app->make(PatternRepository::class), + matcher: $app->make(PatternMatcher::class), + llm: $app->make(LlmClient::class), + systemPromptPath: $systemPromptPath, + ); + }); + } + private function bootConsole(): void { $this->commands([ @@ -304,6 +365,7 @@ private function bootConsole(): void CodeguardInstallOverrideCommand::class, CodeguardCheckCommand::class, CodeguardTestCommand::class, + CodeguardAnalyzeCommand::class, TelemetryEnableCommand::class, TelemetryDisableCommand::class, TelemetryClearCommand::class, diff --git a/src/Commands/CodeguardAnalyzeCommand.php b/src/Commands/CodeguardAnalyzeCommand.php new file mode 100644 index 0000000..f6eb4e4 --- /dev/null +++ b/src/Commands/CodeguardAnalyzeCommand.php @@ -0,0 +1,156 @@ +resolveContext(); + $failOn = $this->resolveFailOn(); + + $startHrtime = hrtime(true); + $recorder->record( + event: EventName::CommandStart, + status: EventStatus::Ok, + durationMs: 0, + extras: [ + 'command' => 'analyze', + 'preset_flag' => null, + ], + ); + + $files = $this->resolveFiles($scope); + $result = $runner->run($files, $config->enabledPresets, $failOn, $context); + + if (! $result->adjudicated) { + $this->components->warn( + 'LLM driver not configured — set config(\'codeguard.patterns.driver\'). No patterns adjudicated.', + ); + $this->emitCommandEnd($recorder, self::SUCCESS, $startHrtime); + + return self::SUCCESS; + } + + $this->renderFindings($result); + + $exitCode = $result->failed($failOn) ? self::FAILURE : self::SUCCESS; + $this->emitCommandEnd($recorder, $exitCode, $startHrtime); + + return $exitCode; + } + + /** + * @return list + */ + private function resolveFiles(FileScopeResolver $scope): array + { + $path = $this->option('path'); + if (is_string($path) && $path !== '') { + return $scope->path($path); + } + + if ((bool) $this->option('all')) { + return $scope->all(); + } + + return $scope->changedOnly(); + } + + private function resolveFailOn(): ?Severity + { + $raw = (string) ($this->option('fail-on') ?: 'critical'); + + if ($raw === 'never') { + return null; + } + + return Severity::tryFrom($raw) ?? Severity::Critical; + } + + private function renderFindings(AnalyzeResult $result): void + { + if ($result->matches === []) { + $this->components->info(sprintf('No pattern findings (%d checks).', $result->patternsChecked)); + + return; + } + + foreach ($result->matches as $match) { + $this->line(sprintf( + ' %s %s:%d · %s · %s (%.2f)', + $this->glyph($match->severity), + $match->file, + $match->line, + $match->patternKey, + $match->message, + $match->confidence, + )); + } + + $this->line(''); + $this->components->info(sprintf( + '%d finding(s) across %d checks.', + count($result->matches), + $result->patternsChecked, + )); + } + + private function glyph(Severity $severity): string + { + return match ($severity) { + Severity::Critical => '✗', + Severity::Warning => '⚠', + Severity::Suggestion => '→', + }; + } + + private function resolveContext(): string + { + $raw = (string) ($this->option('context') ?: 'manual'); + + return in_array($raw, self::ALLOWED_CONTEXTS, true) ? $raw : 'manual'; + } + + private function emitCommandEnd(Recorder $recorder, int $exitCode, int $startHrtime): void + { + $durationMs = (int) round((hrtime(true) - $startHrtime) / 1_000_000); + + $recorder->record( + event: EventName::CommandEnd, + status: $exitCode === 0 ? EventStatus::Ok : EventStatus::Fail, + durationMs: $durationMs, + extras: [ + 'command' => 'analyze', + 'exit_code' => max(0, min(255, $exitCode)), + ], + ); + } +} diff --git a/tests/Feature/CodeguardAnalyzeCommandTest.php b/tests/Feature/CodeguardAnalyzeCommandTest.php new file mode 100644 index 0000000..7bef064 --- /dev/null +++ b/tests/Feature/CodeguardAnalyzeCommandTest.php @@ -0,0 +1,226 @@ + so the file +| stays PHPStan-clean without baseline entries. +| +*/ + +function analyzeTelemetryPath(): string +{ + return sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-analyze-feature-'.uniqid().'.jsonl'; +} + +function analyzeFixtureFile(): string +{ + $dir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-analyze-fixture-'.uniqid(); + mkdir($dir, 0o755, true); + $file = $dir.DIRECTORY_SEPARATOR.'Sample.php'; + file_put_contents($file, "forgetInstance(Recorder::class); + app()->singleton(Recorder::class, fn (): Recorder => new Recorder( + gate: new ConfigGate(enabled: true), + allowlist: new FieldAllowlist(strictMode: true), + rotator: new Rotator, + writer: new JsonlWriter, + activePath: $telemetryPath, + )); + + app()->forgetInstance(LlmClient::class); + app()->singleton(LlmClient::class, fn (): FakeLlmClient => $fake); + + // Force the runner to rebuild with the rebound Recorder + LlmClient. + app()->forgetInstance(AnalyzeRunner::class); +} + +/** + * @return Closure(AnalysisUnit): list> + */ +function analyzeFindingHandler(string $severity): Closure +{ + return function (AnalysisUnit $unit) use ($severity): array { + $key = $unit->patternKeys()[0] ?? 'no-god-object'; + + return [[ + 'pattern_key' => $key, + 'file' => $unit->file, + 'line' => 3, + 'message' => 'fixture violation', + 'severity' => $severity, + 'confidence' => 0.8, + ]]; + }; +} + +/** + * @return list> + */ +function analyzeReadEvents(string $path): array +{ + if (! is_file($path)) { + return []; + } + + $events = []; + foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $raw) { + $decoded = json_decode((string) $raw, true); + if (is_array($decoded)) { + $events[] = $decoded; + } + } + + return $events; +} + +it('prints a warning finding and exits 0 under default fail-on=critical', function (): void { + $telemetry = analyzeTelemetryPath(); + $file = analyzeFixtureFile(); + $fake = new FakeLlmClient(analyzeFindingHandler('warning')); + analyzeBind($telemetry, $fake); + + try { + $exit = Artisan::call('codeguard:analyze', ['--path' => $file, '--context' => 'ci']); + + expect($exit)->toBe(0) + ->and(Artisan::output())->toContain('fixture violation') + ->and($fake->calls)->toHaveCount(1); + } finally { + analyzeCleanup($file, $telemetry); + } +}); + +it('exits 1 when a critical finding meets the default threshold', function (): void { + $telemetry = analyzeTelemetryPath(); + $file = analyzeFixtureFile(); + $fake = new FakeLlmClient(analyzeFindingHandler('critical')); + analyzeBind($telemetry, $fake); + + try { + $exit = Artisan::call('codeguard:analyze', ['--path' => $file, '--context' => 'ci']); + + expect($exit)->toBe(1); + } finally { + analyzeCleanup($file, $telemetry); + } +}); + +it('exits 0 with --fail-on=never even on a critical finding', function (): void { + $telemetry = analyzeTelemetryPath(); + $file = analyzeFixtureFile(); + $fake = new FakeLlmClient(analyzeFindingHandler('critical')); + analyzeBind($telemetry, $fake); + + try { + $exit = Artisan::call('codeguard:analyze', ['--path' => $file, '--fail-on' => 'never']); + + expect($exit)->toBe(0); + } finally { + analyzeCleanup($file, $telemetry); + } +}); + +it('emits command.start, analyze.ended, command.end and one LLM call per file', function (): void { + $telemetry = analyzeTelemetryPath(); + $file = analyzeFixtureFile(); + $fake = new FakeLlmClient(analyzeFindingHandler('warning')); + analyzeBind($telemetry, $fake); + + try { + Artisan::call('codeguard:analyze', ['--path' => $file, '--context' => 'ci']); + + $events = analyzeReadEvents($telemetry); + $names = array_map(static fn (array $event): mixed => $event['event'] ?? null, $events); + + expect($names[0] ?? null)->toBe('command.start') + ->and(end($names))->toBe('command.end') + ->and(in_array('analyze.ended', $names, true))->toBeTrue() + ->and($fake->calls)->toHaveCount(1); + } finally { + analyzeCleanup($file, $telemetry); + } +}); + +it('does not adjudicate or fake a clean repo when no driver is configured', function (): void { + $telemetry = analyzeTelemetryPath(); + $file = analyzeFixtureFile(); + $fake = new FakeLlmClient(analyzeFindingHandler('critical'), configured: false); + analyzeBind($telemetry, $fake); + + try { + $exit = Artisan::call('codeguard:analyze', ['--path' => $file, '--context' => 'ci']); + + $events = analyzeReadEvents($telemetry); + $analyzeEnded = array_values(array_filter( + $events, + static fn (array $event): bool => ($event['event'] ?? '') === 'analyze.ended', + )); + + expect($exit)->toBe(0) + ->and($fake->calls)->toHaveCount(0) + ->and($analyzeEnded[0]['status'] ?? null)->toBe('skip'); + } finally { + analyzeCleanup($file, $telemetry); + } +}); + +it('reports a clean result and exits 0 when the driver returns no findings', function (): void { + $telemetry = analyzeTelemetryPath(); + $file = analyzeFixtureFile(); + $fake = new FakeLlmClient(fn (AnalysisUnit $unit): array => []); + analyzeBind($telemetry, $fake); + + try { + $exit = Artisan::call('codeguard:analyze', ['--path' => $file, '--context' => 'ci']); + + $events = analyzeReadEvents($telemetry); + $analyzeEnded = array_values(array_filter( + $events, + static fn (array $event): bool => ($event['event'] ?? '') === 'analyze.ended', + )); + + expect($exit)->toBe(0) + ->and($fake->calls)->toHaveCount(1) + ->and($analyzeEnded[0]['status'] ?? null)->toBe('ok') + ->and($analyzeEnded[0]['matches_count'] ?? null)->toBe(0); + } finally { + analyzeCleanup($file, $telemetry); + } +}); diff --git a/tests/Support/FakeLlmClient.php b/tests/Support/FakeLlmClient.php new file mode 100644 index 0000000..a5de892 --- /dev/null +++ b/tests/Support/FakeLlmClient.php @@ -0,0 +1,39 @@ + */ + public array $calls = []; + + /** + * @param Closure(AnalysisUnit): list> $handler + */ + public function __construct( + private readonly Closure $handler, + private readonly bool $configured = true, + ) {} + + public function review(AnalysisUnit $unit, string $systemPrompt): array + { + $this->calls[] = $unit; + + return ($this->handler)($unit); + } + + public function isConfigured(): bool + { + return $this->configured; + } +} diff --git a/tests/Unit/Analyze/AnalyzeResultTest.php b/tests/Unit/Analyze/AnalyzeResultTest.php new file mode 100644 index 0000000..4f70ecc --- /dev/null +++ b/tests/Unit/Analyze/AnalyzeResultTest.php @@ -0,0 +1,40 @@ +passed(Severity::Critical))->toBeTrue() + ->and($result->failed(Severity::Critical))->toBeFalse(); +}); + +it('fails when a finding meets the fail-on threshold', function (): void { + $result = new AnalyzeResult(2, [arMatch(Severity::Warning)], 10); + + expect($result->passed(Severity::Warning))->toBeFalse() + ->and($result->failed(Severity::Warning))->toBeTrue(); +}); + +it('always passes when fail-on is never (null threshold)', function (): void { + $result = new AnalyzeResult(2, [arMatch(Severity::Critical)], 10); + + expect($result->passed(null))->toBeTrue() + ->and($result->matchesCount())->toBe(1); +}); diff --git a/tests/Unit/Analyze/FileScopeResolverTest.php b/tests/Unit/Analyze/FileScopeResolverTest.php new file mode 100644 index 0000000..6d2e24b --- /dev/null +++ b/tests/Unit/Analyze/FileScopeResolverTest.php @@ -0,0 +1,125 @@ +isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($base); +} + +function fsrExecutor(string $gitOutput): FakeCommandExecutor +{ + return new FakeCommandExecutor(fn (array $command): array => [0, $gitOutput]); +} + +it('returns changed + staged php files that exist on disk', function (): void { + $base = fsrBase(); + + try { + fsrWrite($base, 'src/Foo.php'); + fsrWrite($base, 'src/Bar.php'); + + $resolver = new FileScopeResolver( + fsrExecutor("src/Foo.php\nsrc/Bar.php\nREADME.md\nsrc/Gone.php\n"), + $base, + ); + + $files = $resolver->changedOnly(); + + expect($files)->toHaveCount(2) + ->and($files)->toContain($base.DIRECTORY_SEPARATOR.'src/Foo.php'); + } finally { + fsrCleanup($base); + } +}); + +it('resolves a single file path', function (): void { + $base = fsrBase(); + + try { + fsrWrite($base, 'src/Foo.php'); + $resolver = new FileScopeResolver(fsrExecutor(''), $base); + + $files = $resolver->path('src/Foo.php'); + + expect($files)->toHaveCount(1) + ->and($files[0])->toContain('Foo.php'); + } finally { + fsrCleanup($base); + } +}); + +it('resolves a directory subtree to its php files only', function (): void { + $base = fsrBase(); + + try { + fsrWrite($base, 'src/Foo.php'); + fsrWrite($base, 'src/Bar.php'); + fsrWrite($base, 'src/notes.txt'); + $resolver = new FileScopeResolver(fsrExecutor(''), $base); + + expect($resolver->path('src'))->toHaveCount(2); + } finally { + fsrCleanup($base); + } +}); + +it('returns empty for a non-existent path', function (): void { + $base = fsrBase(); + + try { + $resolver = new FileScopeResolver(fsrExecutor(''), $base); + + expect($resolver->path('does/not/exist'))->toBe([]); + } finally { + fsrCleanup($base); + } +}); + +it('returns every php file under the working directory for --all', function (): void { + $base = fsrBase(); + + try { + fsrWrite($base, 'src/Foo.php'); + fsrWrite($base, 'app/Bar.php'); + $resolver = new FileScopeResolver(fsrExecutor(''), $base); + + expect($resolver->all())->toHaveCount(2); + } finally { + fsrCleanup($base); + } +}); diff --git a/tests/Unit/Analyze/LlmContractTest.php b/tests/Unit/Analyze/LlmContractTest.php new file mode 100644 index 0000000..18efdcb --- /dev/null +++ b/tests/Unit/Analyze/LlmContractTest.php @@ -0,0 +1,27 @@ +isConfigured())->toBeFalse() + ->and($client->review($unit, 'prompt'))->toBe([]); +}); + +it('exposes a finding json schema carrying the required keys', function (): void { + $schema = FindingSchema::jsonSchema(); + + expect($schema['type'])->toBe('array') + ->and($schema['items']['required'])->toContain( + FindingSchema::KEY_PATTERN, + FindingSchema::KEY_FILE, + FindingSchema::KEY_SEVERITY, + FindingSchema::KEY_CONFIDENCE, + ); +}); diff --git a/tests/Unit/Analyze/PatternMatchTest.php b/tests/Unit/Analyze/PatternMatchTest.php new file mode 100644 index 0000000..210802a --- /dev/null +++ b/tests/Unit/Analyze/PatternMatchTest.php @@ -0,0 +1,93 @@ + ['signals' => [['type' => 'file', 'value' => '**/*.php']]], + 'verification' => ['rules' => ['r']], + 'examples' => ['correct' => '', 'violation' => ''], + 'severity' => 'critical', + ]); + + return new AnalysisUnit('/work/app/Foo.php', [$pattern]); +} + +function pmtRepo(): PatternRepository +{ + return new class implements PatternRepository + { + /** + * @param list $presets + * @return list + */ + public function forPresets(array $presets): array + { + return []; + } + + public function has(string $key): bool + { + return $key === 'extra-known'; + } + }; +} + +/** + * @param array $overrides + * @return array + */ +function pmtRaw(array $overrides = []): array +{ + return array_merge([ + 'pattern_key' => 'no-god-object', + 'file' => '/work/app/Foo.php', + 'line' => 12, + 'message' => 'too many public methods', + 'severity' => 'critical', + 'confidence' => 0.9, + ], $overrides); +} + +it('accepts a well-formed finding for a dispatched pattern', function (): void { + $match = PatternMatch::fromArray(pmtRaw(), pmtUnit(), pmtRepo()); + + expect($match)->toBeInstanceOf(PatternMatch::class) + ->and($match?->patternKey)->toBe('no-god-object') + ->and($match?->line)->toBe(12) + ->and($match?->severity)->toBe(Severity::Critical) + ->and($match?->file)->toBe('/work/app/Foo.php'); +}); + +it('accepts a finding whose pattern is known to the repository', function (): void { + $match = PatternMatch::fromArray(pmtRaw(['pattern_key' => 'extra-known']), pmtUnit(), pmtRepo()); + + expect($match)->toBeInstanceOf(PatternMatch::class); +}); + +it('drops a finding with an unknown pattern key', function (): void { + expect(PatternMatch::fromArray(pmtRaw(['pattern_key' => 'ghost']), pmtUnit(), pmtRepo()))->toBeNull(); +}); + +it('drops a finding pointing at a different file (anti-hallucination)', function (): void { + expect(PatternMatch::fromArray(pmtRaw(['file' => '/work/app/Other.php']), pmtUnit(), pmtRepo()))->toBeNull(); +}); + +it('drops a finding with an invalid severity', function (): void { + expect(PatternMatch::fromArray(pmtRaw(['severity' => 'blocker']), pmtUnit(), pmtRepo()))->toBeNull(); +}); + +it('drops a finding with out-of-range confidence', function (): void { + expect(PatternMatch::fromArray(pmtRaw(['confidence' => 1.5]), pmtUnit(), pmtRepo()))->toBeNull(); +}); + +it('drops a finding with a non-numeric line', function (): void { + expect(PatternMatch::fromArray(pmtRaw(['line' => 'abc']), pmtUnit(), pmtRepo()))->toBeNull(); +}); diff --git a/tests/Unit/Analyze/PatternMatcherTest.php b/tests/Unit/Analyze/PatternMatcherTest.php new file mode 100644 index 0000000..8db4153 --- /dev/null +++ b/tests/Unit/Analyze/PatternMatcherTest.php @@ -0,0 +1,66 @@ + $signals + */ +function pmPattern(string $key, array $signals): Pattern +{ + return Pattern::fromArray($key, [ + 'detection' => ['signals' => $signals], + 'verification' => ['rules' => ['r']], + 'examples' => ['correct' => '', 'violation' => ''], + 'severity' => 'warning', + ]); +} + +it('matches a file-glob signal and skips non-matching files', function (): void { + $matcher = new PatternMatcher('/work'); + $pattern = pmPattern('p', [['type' => 'file', 'value' => '**/*.php']]); + + $units = $matcher->match(['/work/app/Foo.php', '/work/app/Bar.txt'], [$pattern]); + + expect($units)->toHaveCount(1) + ->and($units[0]->file)->toBe('/work/app/Foo.php') + ->and($units[0]->patternKeys())->toBe(['p']); +}); + +it('matches brace-expansion globs at the repo root too', function (): void { + $matcher = new PatternMatcher('/work'); + $pattern = pmPattern('p', [['type' => 'file', 'value' => '**/*.{php,ts}']]); + + $units = $matcher->match(['/work/x/Foo.ts', '/work/Root.php', '/work/x/Foo.py'], [$pattern]); + + expect($units)->toHaveCount(2); +}); + +it('matches a directory signal by path prefix', function (): void { + $matcher = new PatternMatcher('/work'); + $pattern = pmPattern('p', [['type' => 'directory', 'value' => 'app/Services']]); + + $units = $matcher->match(['/work/app/Services/OrderService.php', '/work/app/Models/User.php'], [$pattern]); + + expect($units)->toHaveCount(1) + ->and($units[0]->file)->toBe('/work/app/Services/OrderService.php'); +}); + +it('matches an import signal as a PSR-4 path prefix', function (): void { + $matcher = new PatternMatcher('/work'); + $pattern = pmPattern('p', [['type' => 'import', 'value' => 'App\\Services\\*']]); + + $units = $matcher->match(['/work/app/Services/Foo.php', '/work/app/Http/Bar.php'], [$pattern]); + + expect($units)->toHaveCount(1) + ->and($units[0]->file)->toBe('/work/app/Services/Foo.php'); +}); + +it('produces no unit when nothing matches', function (): void { + $matcher = new PatternMatcher('/work'); + $pattern = pmPattern('p', [['type' => 'directory', 'value' => 'app/Nope']]); + + expect($matcher->match(['/work/app/Foo.php'], [$pattern]))->toBe([]); +}); diff --git a/tests/Unit/Analyze/PatternTest.php b/tests/Unit/Analyze/PatternTest.php new file mode 100644 index 0000000..e91fe72 --- /dev/null +++ b/tests/Unit/Analyze/PatternTest.php @@ -0,0 +1,47 @@ + 'no-god-object', + 'description' => 'Classes should not do everything', + 'category' => 'solid', + 'layer' => 'core', + 'severity' => 'critical', + 'classification' => 'mvp', + 'detection' => [ + 'signals' => [ + ['type' => 'file', 'value' => '**/*.php'], + ['type' => 'directory', 'value' => 'app/Services'], + ], + 'confidence' => 'medium', + ], + 'verification' => ['rules' => ['rule one', 'rule two']], + 'examples' => ['correct' => 'GOOD', 'violation' => 'BAD'], + 'related_patterns' => ['single-responsibility'], + ]); + + expect($pattern->key)->toBe('no-god-object') + ->and($pattern->severity)->toBe(Severity::Critical) + ->and($pattern->confidence)->toBe('medium') + ->and($pattern->detectionSignals)->toHaveCount(2) + ->and($pattern->detectionSignals[0]->type)->toBe('file') + ->and($pattern->detectionSignals[0]->value)->toBe('**/*.php') + ->and($pattern->verificationRules)->toBe(['rule one', 'rule two']) + ->and($pattern->examplesCorrect)->toBe('GOOD') + ->and($pattern->examplesViolation)->toBe('BAD') + ->and($pattern->relatedPatterns)->toBe(['single-responsibility']); +}); + +it('defaults severity to warning and confidence to medium when absent', function (): void { + $pattern = Pattern::fromArray('x', ['severity' => 'bogus']); + + expect($pattern->severity)->toBe(Severity::Warning) + ->and($pattern->confidence)->toBe('medium') + ->and($pattern->detectionSignals)->toBe([]) + ->and($pattern->verificationRules)->toBe([]); +}); diff --git a/tests/Unit/Analyze/SeverityTest.php b/tests/Unit/Analyze/SeverityTest.php new file mode 100644 index 0000000..5ba907a --- /dev/null +++ b/tests/Unit/Analyze/SeverityTest.php @@ -0,0 +1,21 @@ +toBe(Severity::Critical) + ->and(Severity::tryFrom('bogus'))->toBeNull(); +}); + +it('orders severities by weight', function (): void { + expect(Severity::Critical->weight())->toBeGreaterThan(Severity::Warning->weight()) + ->and(Severity::Warning->weight())->toBeGreaterThan(Severity::Suggestion->weight()); +}); + +it('meets a threshold only when at least as severe', function (): void { + expect(Severity::Critical->meets(Severity::Warning))->toBeTrue() + ->and(Severity::Warning->meets(Severity::Warning))->toBeTrue() + ->and(Severity::Suggestion->meets(Severity::Warning))->toBeFalse(); +}); diff --git a/tests/Unit/Analyze/YamlPatternLoaderTest.php b/tests/Unit/Analyze/YamlPatternLoaderTest.php new file mode 100644 index 0000000..ebd0a15 --- /dev/null +++ b/tests/Unit/Analyze/YamlPatternLoaderTest.php @@ -0,0 +1,99 @@ +isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($base); +} + +it('loads real patterns, skips outliers, and filters by preset', function (): void { + $base = aplBase(); + + try { + aplWrite($base, 'core/no-god-object.yaml', aplValidPattern('no-god-object', 'core')); + aplWrite($base, 'core/preset.yaml', "name: php-laravel\ntools:\n - pint\n"); + aplWrite($base, 'php/strict-typing.yaml', aplValidPattern('strict-typing', 'php')); + + $loader = new YamlPatternLoader(new Filesystem, $base); + + expect($loader->forPresets(['core']))->toHaveCount(1) + ->and($loader->forPresets(['core', 'php']))->toHaveCount(2) + ->and($loader->has('no-god-object'))->toBeTrue() + ->and($loader->has('strict-typing'))->toBeTrue() + ->and($loader->has('preset'))->toBeFalse() + ->and($loader->has('ghost'))->toBeFalse(); + } finally { + aplCleanup($base); + } +}); + +it('returns nothing for a missing preset directory', function (): void { + $base = aplBase(); + + try { + $loader = new YamlPatternLoader(new Filesystem, $base); + + expect($loader->forPresets(['php-laravel']))->toBe([]); + } finally { + aplCleanup($base); + } +}); From b73be8448bfc72244793931352db29179505af02 Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 10:19:02 -0300 Subject: [PATCH 04/21] =?UTF-8?q?docs(memory):=20status=20=E2=80=94=20Fase?= =?UTF-8?q?=201=20traits=20+=20Fase=202=20Patterns=20MVP=20shipped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ai/memory/PROJECT-STATUS.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/.ai/memory/PROJECT-STATUS.md b/.ai/memory/PROJECT-STATUS.md index 3750645..9022e52 100644 --- a/.ai/memory/PROJECT-STATUS.md +++ b/.ai/memory/PROJECT-STATUS.md @@ -8,10 +8,10 @@ type: project > **Para Claude**: Este é o documento vivo de estado. Leia na primeira ferramenta-call de toda sessão substantiva. Atualize ao completar qualquer commit que mude escopo, ou ao mudar de sprint/foco. Em caso de conflito com outro arquivo de memória, este ganha (pra resolver drift, corrija o outro arquivo, não aqui). -**Última atualização**: 2026-06-03 (audit profundo multi-agente + replan colaborativo — corrigiu drift acumulado de 30 dias; foco vira **Patterns engine package-side**; integração Arch ADIADA por constraint do usuário) -**HEAD**: `4b32886` docs(memory): pivot sprint 9 → 0.2.0 release; close R5 + R8 -**Branch**: `main`, **sincronizado com `origin/main`** (0 commits ahead, working tree limpo) -**Suite**: 377 tests / 928 assertions (verde — verificado via `vendor/bin/pest --ci` no audit 2026-06-03) +**Última atualização**: 2026-06-03 (audit + replan + **Fase 1 traits** e **Fase 2 Patterns engine MVP** shippados na mesma sessão) +**HEAD**: `0dfb953` feat(analyze): pattern engine MVP — codeguard:analyze with pluggable LLM seam +**Branch**: `feat/patterns-engine-foundation` (3 commits ahead de `main`, **não pushed**; working tree limpo). `origin/main` == `4b32886`. +**Suite**: 433 tests / 1040 assertions (verde). Pint clean, PHPStan level 5 No errors. Coverage gate (≥80%) só roda no CI (sem driver Xdebug/pcov local). **Lint/Static**: Pint clean. PHPStan level 5 self-applied com baseline grandfathered (`156b297`) — R8 fechado. `composer ci` roda pint:test + phpstan + test:coverage; CI ativa em PHP 8.3 + 8.4 via `.github/workflows/ci.yml` (`65893ab`). **Release publicado**: ✅ **`0.2.0` no Packagist desde 2026-05-04** (tag `0.2.0` @ `4b32886`, pushed). Arch consome via repo `vcs` GitHub pinado em `^0.2.0` (lock @ `4b32886`) — **NÃO** via path repo nem `dev-main`. @@ -29,9 +29,9 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. | Fase | Trabalho | Precisa Arch? | Estado | |:---:|---|:---:|---| -| **0** | Limpeza canônica (este arquivo + docs stale) | ❌ | 🟡 em curso | -| **1** | Bug fixes package-side (ver abaixo) | ❌ (lê Arch só como referência) | 🔜 fila | -| **2** | **Patterns engine** (`src/Patterns/*` + `codeguard:analyze`) | ❌ | 🟡 design rodando | +| **0** | Limpeza canônica (este arquivo + docs stale) | ❌ | ✅ feito (status reescrito; handoff/specs ainda stale — backlog) | +| **1** | Bug fixes package-side | ❌ (lê Arch só como ref) | 🟡 traits ✅; `coverage_percent -1` + dead config ainda fila | +| **2** | **Patterns engine MVP** (`src/Analyze/*` + `codeguard:analyze`, Increments A–C) | ❌ | ✅ shippado (`0dfb953`); driver real = Increment D (pende decisão de transporte) | | **3** | Schema dump (`codeguard:prepare`) + AI rules generator | ❌ (testáveis via fixtures/SQLite) | ⏸️ depois | | **4** | 🔒 Integração Arch + dogfood real | ✅ | ⛔ **ADIADO** (constraint do usuário) | @@ -48,11 +48,11 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. ### Próxima ação concreta -🔜 **Aguardando design doc do Patterns engine** (workflow `patterns-engine-design` rodando: research da data contract dos 30 YAMLs + research externo de LLM-review + judge-panel de 3 arquiteturas → spec + task list TDD). Output salvo em `docs/specs/`. +🔜 **DECISÃO DO USUÁRIO PENDENTE — transporte LLM do Increment D** (driver real). Usuário descartou `claude -p` e pediu "usar Claude Code com assinatura, sem API externa". Realidade técnica: assinatura só via CLI `claude` headless OU modelo context-emit (package prepara, sessão Claude Code revisa). API metered (anthropic-ai/sdk) está fora. Ajudar a fechar isso antes de codar o driver. -**Em paralelo / assim que o design voltar**: começar Fase 1 (assertion traits via TDD) — independe do design e é package-side puro. +**Sem bloquear**: itens menores da Fase 1 (`coverage_percent -1` em `CodeguardTestCommand.php:102`; decidir o que fazer com config morto ai_rules/prepare — o bloco `patterns` agora É consumido pelo Analyze). -**Fork em aberto pro design resolver**: transporte LLM (SDK PHP Anthropic vs shell-out a CLI vs interface pluggable sem driver). Default proposto: **interface pluggable, sem Node** (respeita ADRs). Confirmar quando o design apresentar com recomendação. +**Decisão pendente do usuário**: pushar o branch `feat/patterns-engine-foundation` / abrir PR / quando mergear em main. --- @@ -67,7 +67,7 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. | `codeguard:check` | ✅ | src/Commands/CodeguardCheckCommand.php | | `codeguard:test` | ✅ | src/Commands/CodeguardTestCommand.php | | `codeguard:telemetry:enable\|disable\|clear` | ✅ | src/Commands/Telemetry/*.php | -| `codeguard:analyze` | 🟡 **Track A (sprint atual)** | — | +| `codeguard:analyze` | ✅ MVP | src/Commands/CodeguardAnalyzeCommand.php (NullLlmClient default; driver real = Increment D) | | `codeguard:prepare` | ⏸️ Fase 3 | — | | `codeguard:baseline` | ⏸️ pós-engines | — | @@ -81,8 +81,8 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. | `Gates\*` | ✅ | GateRunner + GateRunResult; consumido pelo CheckCommand + Layer 3 telemetry | | `Hooks\*` | 🟡 parcial | StagedPhpFilesRunner existe | | `Testing\*` | ✅ completo | TestSuiteRunner generalizado + StageConfig (8 campos) + executors + DTOs | -| `Assertions\*` | 🔴 **broken** | 2 traits existem mas **lançam `RuntimeException('Not yet implemented')` em 7/7 métodos**. Fase 1 corrige. | -| `Patterns\*` | ❌ ausente | **30** YAMLs em resources/patterns/ (13 core + 6 php + 11 php-laravel incl. preset.yaml) mas ZERO código consumidor. **Track A constrói.** | +| `Assertions\*` | ✅ | AntiPatternScanner + 2 traits implementados (7 checks reais, 21 tests). `0dfb953`/`4c662a0`. | +| `Analyze\*` | ✅ MVP | Severity/Pattern/DetectionSignal/PatternRepository/YamlPatternLoader/FileScopeResolver/PatternMatcher/FindingSchema/PatternMatch/AnalyzeResult/AnalyzeRunner/LlmClient/NullLlmClient. Consome os 28 patterns (de 30 YAMLs; 2 outliers pulados). Driver real pendente. | | `AiRules\*` | ❌ duplo-morto | config targets existe + `resources/rules/` VAZIA (0/7 markdowns, sem git history). Fase 3. | | `Schema\*` | ❌ ausente | só `PrepareConfig` DTO (4 campos). Fase 3. | @@ -102,7 +102,7 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. | Perspectiva | Real | Justificativa | |---|:-:|---| | "install + rodar gates + rodar tests" | **~80%** | Commands reais, installer ~900 LOC, telemetria completa, 377 tests verdes. Descontado: traits lançam exception, `coverage_percent -1`, e o único consumer **não roda** check/test. | -| "pattern-based LLM review" (o diferencial) | **~15%** | 30 YAMLs + slots de telemetria existem como dado inerte/scaffolding. Zero engine. **Track A ataca.** (status antigo dizia ~30%, super-creditava dado dormente) | +| "pattern-based LLM review" (o diferencial) | **~55%** | Engine MVP shippado (loader/scope/matcher/trust-boundary/comando/telemetria, 33 tests). Falta o driver real (adjudicação LLM via assinatura) = Increment D. Até lá o comando roda mas imprime aviso de degradação honesto. | | "AI rules generator" | **~3%** | duplo-morto: `src/AiRules/` ausente + `resources/rules/` vazia | | "schema dump multi-DB" | **~8%** | só `PrepareConfig` DTO | | "publicar/distribuir" | **~85%** | genuinamente no Packagist, tagged, lockável, Node-free. Descontado: footprint `.codeguard/` é git-ignored (não cruza máquinas), 0 downloads, único consumer bypassa a CLI | @@ -131,9 +131,9 @@ Audit multi-agente verificou cada alegação contra git+FS. Corrigido neste arqu | # | Risco | Estado | |---|-------|--------| | R1 | Arch consome o package como **stub-seeder one-shot, não runtime**; dogfood real do check/test nunca rodou em campo | **ADIADO conscientemente** — constraint do usuário (não tocar Arch). Integração = Fase 4, quando liberado | -| R7 | 30 YAMLs eram peso morto até Patterns engine | **SENDO ATACADO** — Track A é a sprint atual | +| R7 | 30 YAMLs eram peso morto até Patterns engine | **QUASE FECHADO** — engine MVP consome os 28 patterns; só falta o driver LLM real (Increment D) pra adjudicar de verdade | | R9 | Marketing público (README:5, composer.json:3) vende features ausentes | **aceito (Q3)** — aposta que Track A torna verdade; reavaliar se Track A atrasar | -| R10 | Assertion traits lançam exception num release PUBLICADO + wired no stub → consumer que use crasha | **mitigando** — Fase 1 implementa; SEM hotfix 0.2.1 (Q3), vai no 0.3.0. Único consumer (Arch) já contornou inline | +| ~~R10~~ | ~~Assertion traits lançam exception num release PUBLICADO~~ | **FECHADO no código** (`4c662a0`) — traits implementados via AntiPatternScanner. Ainda no branch; chega ao público só no 0.3.0 (Q3: sem hotfix 0.2.1) | | R11 | Skills `resources/skills/*` são Node-era e quebrariam um usuário real | backlog cleanup | | ~~R5~~ | ~~README mínimo~~ | ✅ FECHADO (README existe + alinhado a 0.2.0) | | ~~R8~~ | ~~package não se autoanalisa~~ | ✅ FECHADO (`156b297`: phpstan level 5 + baseline) | From 18c4492ab158d83b5cf8ca14890212d0de30e922 Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 12:51:46 -0300 Subject: [PATCH 05/21] =?UTF-8?q?feat(analyze):=20context-emit=20driver=20?= =?UTF-8?q?=E2=80=94=20--emit/--ingest=20+=20codeguard-review=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increment D in the subscription-friendly shape the user chose: no metered API. The LLM judgement runs inside a Claude Code session; the package stays the deterministic harness. - AnalyzeRunner: extract units() core; add buildWorkOrder() (serialize units + prompt-ready patterns + finding schema) and ingest() (validate out-of-band findings through the SAME PatternMatch trust boundary, then gate + telemeter). - Pattern::toPromptArray() — LLM-facing projection (no metadata/signals). - codeguard:analyze --emit[/--out] writes a work order JSON (no LLM call); --ingest= validates findings + gates exit by --fail-on. - New codeguard-review skill: emit → fan out batched subagents (one per N files) to judge each file against its patterns on the subscription → merge → ingest. Published via the codeguard-skills tag to .claude/skills. - Remove the 3 stale Node-era skills (codeguard-setup/run/health) — they referenced npx/node_modules/codeguard.yaml/hook-runner.js that the PHP package never produces; codeguard:install owns setup now. Closes R11. Suite 433 -> 435 green (+ --emit/--ingest feature tests). PHPStan level 5 clean. --- resources/skills/codeguard-health/SKILL.md | 278 --------- resources/skills/codeguard-review/SKILL.md | 151 +++++ resources/skills/codeguard-run/SKILL.md | 473 ---------------- resources/skills/codeguard-setup/SKILL.md | 527 ------------------ src/Analyze/AnalyzeRunner.php | 116 +++- src/Analyze/Pattern.php | 20 + src/CodeguardServiceProvider.php | 4 + src/Commands/CodeguardAnalyzeCommand.php | 98 +++- tests/Feature/CodeguardAnalyzeCommandTest.php | 60 ++ 9 files changed, 439 insertions(+), 1288 deletions(-) delete mode 100644 resources/skills/codeguard-health/SKILL.md create mode 100644 resources/skills/codeguard-review/SKILL.md delete mode 100644 resources/skills/codeguard-run/SKILL.md delete mode 100644 resources/skills/codeguard-setup/SKILL.md diff --git a/resources/skills/codeguard-health/SKILL.md b/resources/skills/codeguard-health/SKILL.md deleted file mode 100644 index 0795d32..0000000 --- a/resources/skills/codeguard-health/SKILL.md +++ /dev/null @@ -1,278 +0,0 @@ ---- -name: codeguard-health -description: Show project health overview — configuration, tools, baseline, patterns, hooks, drift, and recommendations ---- - -# /codeguard-health - -You are an AI agent performing a read-only health assessment of a project's CodeGuard setup. You will inspect configuration files, check tool availability, evaluate baseline freshness, count active patterns, verify hook installation, detect drift, and present a concise scannable report with actionable recommendations. - -**This skill makes NO changes to the project.** It only reads files and runs version-check commands. - -## IDE Invocation - -| IDE | How to invoke | -|---|---| -| Claude Code | `/codeguard-health` | -| OpenCode | `/codeguard-health` | -| Cursor | Mention `codeguard-health` in chat | -| Codex CLI | To be validated | -| Gemini CLI | To be validated | -| Copilot CLI | To be validated | -| Windsurf | To be validated | - ---- - -## Prerequisites - -Before starting, confirm these paths exist relative to project root: - -| File | Required | Purpose | -|---|---|---| -| `codeguard.yaml` | Yes | Main configuration | -| `.codeguard/baseline.json` | No | Violation baseline | -| `.codeguard/hook-runner.js` | No | Pre-commit hook runner | -| `.git/hooks/pre-commit` | No | Git hook shim | -| `CODEGUARD.md` | No | AI context document | - -If `codeguard.yaml` does not exist, stop immediately and report: - -``` -CodeGuard Health Report -======================= - - Configuration ! codeguard.yaml not found - - Run /codeguard-setup to initialize CodeGuard for this project. -``` - ---- - -## Step 1: Read Configuration - -1. Read `codeguard.yaml` from project root. -2. Parse it as YAML. Extract: - - `version` — config schema version - - `project.module` — detected module name (e.g., `php-laravel`) - - `project.language` — language (e.g., `php`) - - `project.framework` — framework (e.g., `laravel`) - - `capabilities` — map of capability name to config (`enabled`, `enforcement`, `level`, `presets`) - - `patterns.catalog` — list of framework-specific pattern names - - `patterns.discovered` — list of discovered pattern names - - `patterns.custom` — list of custom pattern names - - `hooks.pre-commit.enabled` — whether hook is active - - `baseline.path` — path to baseline file (default: `.codeguard/baseline.json`) -3. Validate the YAML parsed successfully. If malformed, report the error in the Configuration line and continue with remaining checks where possible. - ---- - -## Step 2: Read Baseline - -1. Check if the baseline file exists at the path from `baseline.path` (default `.codeguard/baseline.json`). -2. If it exists, read and parse it as JSON. Extract: - - `generated` — ISO 8601 timestamp of when the baseline was created - - `generatedBy` — what generated it (should be `codeguard-run`) - - `module` — module name at generation time - - `entries` — array of baseline entries -3. Calculate **baseline age** in days: difference between current date and the `generated` timestamp. -4. Count total entries: `entries.length`. -5. Identify **stale entries**: entries whose `file` path no longer exists on disk. Count them. - -If the baseline file does not exist, record: "No baseline found." - ---- - -## Step 3: Check Tool Status - -For each capability in `codeguard.yaml` where `enabled: true`, check whether the corresponding tool binary is installed and get its version. - -Use these binary paths based on the module. For `php-laravel`, read `.codeguard/modules/php-laravel/preset.yaml` if it exists, otherwise use these defaults from the module's `preset.yaml`: - -| Capability | Tool | Binary | Version command | -|---|---|---|---| -| `static-analysis` | Larastan | `vendor/bin/phpstan` | `vendor/bin/phpstan --version` | -| `formatting` | Pint | `vendor/bin/pint` | `vendor/bin/pint --version` | -| `mess-detection` | PHPMD | `vendor/bin/phpmd` | `vendor/bin/phpmd --version` | -| `arch-testing` | Pest | `vendor/bin/pest` | `vendor/bin/pest --version` | - -For each enabled tool: - -1. Check if the binary file exists at the expected path. -2. If it exists, run the version command and capture stdout. Parse out the version number (e.g., `v2.1.0`, `1.21.0`). -3. If the binary does not exist, record it as missing with the install command from `preset.yaml`. -4. If the binary exists but the version command fails, record it as "installed but version check failed." - -Also check for the Larastan level configured in `codeguard.yaml` — show it as `(L{level})` next to the tool name. - ---- - -## Step 4: Check Hook Status - -1. **Hook runner**: Check if `.codeguard/hook-runner.js` exists. -2. **Pre-commit hook**: Check if `.git/hooks/pre-commit` exists. - - If it exists, read its contents and verify it references `.codeguard/hook-runner.js` (look for the string `hook-runner.js` or `.codeguard/hook-runner`). - - If it exists but does NOT reference the CodeGuard hook runner, report it as "exists but not CodeGuard" (another tool may own it). -3. **Hook config**: Check `hooks.pre-commit.enabled` in `codeguard.yaml`. If `false`, note "disabled in config." - ---- - -## Step 5: Count Active Patterns - -Active patterns come from three layers, loaded based on the module hierarchy. For module `php-laravel` with `language: php`: - -1. **Core patterns**: List YAML files in `.codeguard/modules/core/patterns/` (always loaded). Count them. -2. **Language patterns**: List YAML files in `.codeguard/modules/php/patterns/` (loaded for PHP projects). Count them. -3. **Framework patterns**: Count entries in `codeguard.yaml` `patterns.catalog` list. -4. **Discovered patterns**: Count entries in `patterns.discovered`. These are YAML files in `.codeguard/patterns/`. -5. **Custom patterns**: Count entries in `patterns.custom`. These are also YAML files in `.codeguard/patterns/`. - -If `.codeguard/modules/` does not exist (modules not yet copied), fall back to counting pattern files from the CodeGuard npm package's `node_modules/@henryavila/codeguard/modules/` path if accessible, or report "Module data not installed." - -Total active patterns = core + language + catalog + discovered + custom. - ---- - -## Step 6: Check Arch Tests - -1. Check if `tests/Architecture/CodeGuardArchTest.php` exists. -2. If it exists, read it and count the number of `arch()` or `test(` calls to estimate the assertion count. -3. Report the file path and assertion count. - -If it does not exist, record: "No arch tests generated yet." - ---- - -## Step 7: Drift Detection - -Compare what `codeguard.yaml` declares with what `CODEGUARD.md` describes. - -1. Read `CODEGUARD.md` from project root (if it exists). -2. For each enabled capability in `codeguard.yaml`, check if `CODEGUARD.md` mentions the tool name or capability name. Flag any enabled capability not mentioned in `CODEGUARD.md`. -3. Check if `CODEGUARD.md` mentions tools or capabilities that are NOT in `codeguard.yaml` or are disabled. Flag these as potential drift. -4. Compare the pattern names in `codeguard.yaml` `patterns.catalog` with patterns referenced in `CODEGUARD.md`. Flag mismatches. - -If `CODEGUARD.md` does not exist, report: "CODEGUARD.md not found — run /codeguard-setup to generate." - -**Drift is informational, not blocking.** Small drift is normal between setup runs. - ---- - -## Step 8: Generate Recommendations - -Based on findings from Steps 1-7, generate specific actionable recommendations. Each recommendation must include a concrete command or action. Priority order: - -1. **Missing config** (critical): "Run /codeguard-setup to initialize CodeGuard" -2. **Missing tools** (high): "Install {tool}: {install_command}" — use the `install_commands` from `preset.yaml` -3. **Missing hook runner** (high): "Run /codeguard-setup to install hook runner" -4. **Missing pre-commit hook** (high): "Run /codeguard-setup to install git hook" -5. **Stale baseline** (medium): "Baseline is {N} days old — run /codeguard-run to refresh" -6. **No baseline** (medium): "No baseline found — run /codeguard-run to establish baseline" -7. **Stale baseline entries** (low): "{N} baseline entries reference files that no longer exist" -8. **Missing CODEGUARD.md** (medium): "Run /codeguard-setup to generate CODEGUARD.md" -9. **Drift detected** (low): "CODEGUARD.md and codeguard.yaml are out of sync — re-run /codeguard-setup" -10. **Missing arch tests** (medium): "Run /codeguard-setup to generate Pest arch tests" -11. **Hook disabled** (info): "Pre-commit hook is disabled in codeguard.yaml" - -Limit to the 5 most important recommendations. If everything is healthy, say "No issues found." - ---- - -## Step 9: Present the Report - -Format the health report as a single clear output. Use these symbols: - -- `[ok]` — healthy, no action needed -- `[!!]` — warning, action recommended -- `[FAIL]` — missing or broken, action required -- `[info]` — informational, no action needed - -### Report Template - -Follow this exact structure. Omit sections that have zero findings (e.g., if no drift, omit the Drift section). Adjust content per the actual findings. - -``` -CodeGuard Health Report -======================= - -Configuration [ok] codeguard.yaml found (module: php-laravel, v1.0) -Hook Runner [ok] .codeguard/hook-runner.js installed -Pre-commit Hook [ok] .git/hooks/pre-commit active -Baseline [!!] 45 entries, last updated 15 days ago - 3 stale entries (files no longer exist) - -Tools - Larastan (L6) [ok] vendor/bin/phpstan v2.1.0 - Pint [ok] vendor/bin/pint v1.21.0 - PHPMD [FAIL] vendor/bin/phpmd not found - Pest [ok] vendor/bin/pest v3.7.0 - -Patterns 28 active (13 core + 6 PHP + 9 Laravel) - 2 discovered, 1 custom -Arch Tests [ok] tests/Architecture/CodeGuardArchTest.php (12 assertions) - -Drift - [!!] CODEGUARD.md mentions "rector" but it is not in codeguard.yaml - -Recommendations - 1. Install PHPMD: composer require --dev phpmd/phpmd - 2. Baseline aging (15 days) — run /codeguard-run to refresh - 3. 3 stale baseline entries — consider refreshing baseline -``` - -### Fully Healthy Example (no recommendations) - -``` -CodeGuard Health Report -======================= - -Configuration [ok] codeguard.yaml found (module: php-laravel, v1.0) -Hook Runner [ok] .codeguard/hook-runner.js installed -Pre-commit Hook [ok] .git/hooks/pre-commit active -Baseline [ok] 12 entries, last updated 2 days ago - -Tools - Larastan (L6) [ok] vendor/bin/phpstan v2.1.0 - Pint [ok] vendor/bin/pint v1.21.0 - PHPMD [ok] vendor/bin/phpmd v2.15.0 - Pest [ok] vendor/bin/pest v3.7.0 - -Patterns 28 active (13 core + 6 PHP + 9 Laravel) -Arch Tests [ok] tests/Architecture/CodeGuardArchTest.php (12 assertions) - -No issues found. -``` - -### Minimal Setup Example (just after install, before first run) - -``` -CodeGuard Health Report -======================= - -Configuration [ok] codeguard.yaml found (module: php-laravel, v1.0) -Hook Runner [ok] .codeguard/hook-runner.js installed -Pre-commit Hook [ok] .git/hooks/pre-commit active -Baseline [info] No baseline found - -Tools - Larastan (L6) [ok] vendor/bin/phpstan v2.1.0 - Pint [ok] vendor/bin/pint v1.21.0 - PHPMD [ok] vendor/bin/phpmd v2.15.0 - Pest [ok] vendor/bin/pest v3.7.0 - -Patterns 28 active (13 core + 6 PHP + 9 Laravel) -Arch Tests [ok] tests/Architecture/CodeGuardArchTest.php (12 assertions) - -Recommendations - 1. No baseline found — run /codeguard-run to establish baseline -``` - ---- - -## Important Notes - -- **Read-only**: This skill never modifies any file. It only reads files and runs `--version` commands. -- **Fast**: The entire health check should complete in a few seconds. No heavy analysis. -- **Baseline age thresholds**: Consider a baseline "fresh" if under 7 days, "aging" if 7-30 days, "stale" if over 30 days. -- **Tool version parsing**: Different tools output versions differently. `phpstan --version` outputs `PHPStan - PHP Static Analysis Tool 2.1.0`, `pint --version` outputs `Laravel Pint 1.21.0`, `phpmd --version` outputs `PHPMD 2.15.0`, `pest --version` outputs `Pest 3.7.0`. Extract just the semver portion. -- **Graceful degradation**: If any single check fails (file unreadable, command times out), report that specific check as failed and continue with the remaining checks. Never let one failure prevent the full report. -- **No codeguard.yaml is the only hard stop**: Every other missing file is reported as a finding, not an error. diff --git a/resources/skills/codeguard-review/SKILL.md b/resources/skills/codeguard-review/SKILL.md new file mode 100644 index 0000000..cb90b94 --- /dev/null +++ b/resources/skills/codeguard-review/SKILL.md @@ -0,0 +1,151 @@ +--- +name: codeguard-review +description: Run CodeGuard's pattern-based review — emit a work order, fan out batched subagents to judge each file against its patterns using your Claude subscription, then ingest + gate the validated findings. No external API, no metered tokens. +--- + +# /codeguard-review + +| IDE | How to invoke | +|---|---| +| Claude Code | `/codeguard-review` | +| Cursor | Mention "codeguard-review" in chat | + +## Overview + +`codeguard:analyze` reviews code against the curated pattern corpus for smells AST +tools (PHPStan/Deptrac) cannot reach — "god object", "logic in blade", +"service-layer discipline". The LLM judgement runs **inside this Claude Code +session** (your subscription), so there is **no external/metered API**. + +Division of labour: +- **The package (deterministic, in PHP)** scopes files, matches patterns, emits a + work order, and later validates + gates the findings through its trust boundary. +- **You (this skill, via subagents)** do the actual reviewing — one subagent per + batch of files — and hand the findings back. + +The package's `PatternMatch` trust boundary re-validates everything you return, so +a hallucinated pattern key, a wrong file, or an out-of-range severity is dropped. + +## Prerequisites + +- The project depends on `henryavila/codeguard` (`composer show henryavila/codeguard`). +- It is a git repository (for the default `--changed-only` scope). + +## Instructions + +### Step 1 — Choose the scope + +Default to changed + staged files. Honor the user's request if they name a path or +ask for a full scan: + +| User intent | Scope flag | +|---|---| +| (default) review my current work | `--changed-only` | +| review `app/Services` | `--path=app/Services` | +| review a single file | `--path=app/Foo.php` | +| review the whole project | `--all` | + +Use the SAME scope flag in Step 2 and Step 6 — the package re-derives the file set +on ingest, so they must match. + +### Step 2 — Emit the work order + +```bash +php artisan codeguard:analyze --emit --out=.codeguard/analyze-request.json +``` + +This calls no LLM. It writes JSON: + +```json +{ + "system_prompt": "You are a senior code reviewer ...", + "finding_schema": { "type": "array", "items": { "...": "..." } }, + "units": [ + { + "file": "/abs/path/app/Services/OrderService.php", + "patterns": [ + { + "key": "service-layer", + "description": "Controllers delegate business logic to Services", + "severity": "critical", + "verification_rules": ["services must not return HTTP responses", "..."], + "examples": { "correct": "...", "violation": "..." } + } + ] + } + ] +} +``` + +Read the file. If `units` is empty, tell the user nothing matched the scope and stop. + +### Step 3 — Batch the units + +Group `units` into **batches of 5 files** (default). For a large diff, a bigger +batch trades parallelism for fewer subagent tokens; for a tiny diff, one batch is +fine. Never split a single unit across batches — a file and its patterns stay +together. + +### Step 4 — Fan out one subagent per batch + +For each batch, dispatch a subagent (Task/Agent tool, or Workflow `parallel` for +deterministic fan-out). Give each subagent: + +- The `system_prompt` verbatim from the work order. +- Its batch of units (each unit's `file` + `patterns`). +- This instruction: + + > For each unit, READ the file at its `file` path. Judge it ONLY against that + > unit's `patterns`, using each pattern's `verification_rules` and + > `examples.correct`/`examples.violation` as the rubric. Report a finding ONLY + > for a real violation you can point to a specific line for — do not invent + > issues; when unsure, omit. Return a JSON array of findings, each: + > `{ "pattern_key", "file", "line", "message", "severity", "confidence" }`, + > where `pattern_key` is the exact key of the violated pattern, `file` is the + > exact path you were given, `severity` is that pattern's severity, and + > `confidence` is 0.0–1.0. Return `[]` when the batch is clean. + +Subagents are independent — they cannot see each other's files. That isolation is +intended (one file's review never bleeds into another's). + +### Step 5 — Merge findings + +Concatenate every subagent's findings into one array and write it: + +```bash +# write the merged array to .codeguard/analyze-findings.json +``` + +```json +{ "findings": [ { "pattern_key": "service-layer", "file": "/abs/.../OrderService.php", "line": 42, "message": "...", "severity": "critical", "confidence": 0.9 } ] } +``` + +A bare top-level array is also accepted. + +### Step 6 — Ingest, validate, and gate + +```bash +php artisan codeguard:analyze --ingest=.codeguard/analyze-findings.json --fail-on=critical +``` + +The package re-scopes + re-matches, runs every finding through the trust boundary +(dropping anything that references an unknown pattern, the wrong file, or an +invalid severity/confidence), prints the surviving findings grouped by severity +(`✗` critical / `⚠` warning / `→` suggestion), emits `analyze.ended` telemetry, and +exits non-zero when any finding meets `--fail-on`. + +### Step 7 — Report + +Summarize for the user: how many files reviewed, how many findings survived +validation (and how many raw findings were dropped, if notable), and the exit +status. Offer to open the flagged files at the cited lines. + +## Notes + +- **Subscription, not API.** Nothing here calls a metered endpoint. The review is + your Claude Code session doing the judging. +- **Not an autonomous CI gate.** This runs when invoked. For unattended CI, the + AST gates (`composer codeguard:check` → Pint/PHPStan/Deptrac) remain the + autonomous enforcement; `analyze` is the deeper assisted review. +- **`--fail-on`** accepts `critical` (default), `warning`, `suggestion`, or `never` + (report-only). diff --git a/resources/skills/codeguard-run/SKILL.md b/resources/skills/codeguard-run/SKILL.md deleted file mode 100644 index f1c170c..0000000 --- a/resources/skills/codeguard-run/SKILL.md +++ /dev/null @@ -1,473 +0,0 @@ ---- -name: codeguard-run -description: Run static analysis and AI pattern analysis against project standards ---- - -# /codeguard-run - -You are executing the `/codeguard-run` skill. This runs static analysis tools and AI semantic analysis against the project's configured patterns and standards. Follow every step precisely. - -**Golden Rule: NEVER infer or fabricate tool output.** When tools fail or produce errors, report the exact output verbatim. Do not guess causes, diagnose issues, or add interpretations. Relay what the tool said, nothing more. - -## IDE Invocation - -| IDE | Syntax | -|---|---| -| Claude Code | `/codeguard-run` | -| OpenCode | `/codeguard-run` | -| Cursor | Mention "codeguard-run" in chat | -| Codex CLI | To be validated | -| Gemini CLI | To be validated | -| Copilot CLI | To be validated | -| Windsurf | To be validated | - ---- - -## Step 1: Load Configuration - -Read these files from the project root: - -1. **`codeguard.yaml`** — project configuration (capabilities, patterns, thresholds, baseline path) -2. **`CODEGUARD.md`** — project-specific architecture context written during setup - -If `codeguard.yaml` does not exist, stop and tell the user: -> codeguard.yaml not found. Run `/codeguard-setup` first to configure your project. - -Extract from `codeguard.yaml`: -- `project.module` — the active module (e.g., `php-laravel`) -- `project.language` — the language (e.g., `php`) -- `capabilities` — which tools are enabled and their enforcement levels -- `patterns.catalog` — framework-specific pattern names selected during setup -- `patterns.discovered` — discovered patterns stored in `.codeguard/patterns/` -- `patterns.custom` — custom patterns stored in `.codeguard/patterns/` -- `thresholds` — limits like `max_method_lines`, `max_indentation_levels` -- `baseline.path` — path to baseline file (default: `.codeguard/baseline.json`) - -## Step 2: Load AI Rules - -Load the `ai-rules/*.md` files from all applicable module layers. The module hierarchy is resolved from `project.language` and `project.module` in `codeguard.yaml`. Read these files from `.codeguard/modules/` (or fall back to `node_modules/@henryavila/codeguard/modules/`): - -1. **`.codeguard/modules/core/ai-rules/core.md`** — universal analysis rules (always loaded) -2. **`.codeguard/modules/{language}/ai-rules/{language}.md`** — language-specific (e.g., `.codeguard/modules/php/ai-rules/php.md`) -3. **`.codeguard/modules/{module}/ai-rules/*.md`** — framework-specific (e.g., `.codeguard/modules/php-laravel/ai-rules/laravel.md`) - -Read all three and internalize the instructions. These govern how you analyze code: priority order, false positive prevention, severity classification, and detection heuristics. - -## Step 3: Load Module Data - -Read the module preset for tool binary paths and configurations: - -- **`.codeguard/modules/{module}/preset.yaml`** (e.g., `.codeguard/modules/php-laravel/preset.yaml`) - -This gives you: -- `tools.larastan.binary` — path to PHPStan binary (e.g., `vendor/bin/phpstan`) -- `tools.larastan.config` — config file name (e.g., `phpstan.neon`) -- `tools.larastan.level` — default analysis level -- `tools.pint.binary` — path to Pint binary -- `tools.phpmd.binary` — path to PHPMD binary -- `tools.phpmd.rulesets` — default rulesets -- `tools.pest.binary` — path to Pest binary -- `tools.pest.directory` — arch test directory (e.g., `tests/Architecture`) - -When `codeguard.yaml` specifies overrides (e.g., `capabilities.static-analysis.level: 9`), use the override instead of the preset default. - -## Step 4: Determine Scope - -Ask the user what scope to analyze, or accept it from their initial message. Valid scopes: - -| Scope | Meaning | Example user input | -|---|---|---| -| **Full project** | All PHP files in the project | "run on everything", "full project", "analyze all" | -| **Directory** | All PHP files in a specific directory | "analyze app/Services", "check the controllers" | -| **File** | A single file | "check app/Services/OrderService.php" | -| **Staged changes** | Files in `git diff --cached --name-only` | "check staged", "what I'm about to commit" | - -For **staged changes**, run: -```bash -git diff --cached --name-only --diff-filter=ACMR -``` -Filter the result to only `.php` files (or the relevant extension for the project language). - -If the user does not specify a scope, default to **full project**. - -Store the list of files in scope for use in subsequent steps. - ---- - -## Step 5: Preflight — Check Tool Availability - -Before running any tool, verify that all enabled tools are installed. For each enabled capability in `codeguard.yaml`, check if the binary exists: - -```bash -test -f vendor/bin/phpstan && echo "OK" || echo "MISSING" # static-analysis -test -f vendor/bin/phpmd && echo "OK" || echo "MISSING" # mess-detection -test -f vendor/bin/pest && echo "OK" || echo "MISSING" # arch-testing -test -f vendor/bin/pint && echo "OK" || echo "MISSING" # formatting -``` - -Use the binary paths from `preset.yaml` (loaded in Step 3). - -If **any** enabled tool is missing: - -1. List the missing tools and their install commands (from `preset.yaml` `install_commands`) -2. Ask the user: "Install missing tools now? [Y/n]" -3. If yes, run each install command (e.g., `composer require --dev phpmd/phpmd`) -4. If install fails, disable the capability for this run and warn the user -5. If user says no, disable the capability for this run and note it in the report - -**Do NOT skip this step.** A tool reported as "not installed" in the final report is a failure of this preflight check. - ---- - -## Step 6: Run Static Analysis Tools - -Run each enabled capability's tool against the scope. Check `codeguard.yaml` capabilities — only run tools where `enabled: true` and that passed the preflight check in Step 5. - -### 6a. Larastan (static-analysis) - -Larastan always runs on the **full project** regardless of scope (PHPStan needs full context for type inference). Run: - -```bash -vendor/bin/phpstan analyse --error-format=json --no-progress --level={level} -``` - -Add `--configuration={config}` only if the config file exists in the project root (e.g., `phpstan.neon`). If it does not exist, omit the flag — PHPStan auto-discovers `phpstan.neon` or `phpstan.neon.dist`. - -Where: -- `{level}` = `capabilities.static-analysis.level` from codeguard.yaml (falls back to preset default) -- `{config}` = `tools.larastan.config` from preset.yaml (e.g., `phpstan.neon`) — only if the file exists - -After getting results, **filter output to scope** — only keep findings in files that are within the analysis scope determined in Step 4. PHPStan exit code 1 means there are findings (this is normal, not an error). Only treat exit codes >= 2 as tool errors. - -Parse the JSON output. PHPStan JSON format: -```json -{ - "totals": { "errors": 0, "file_errors": 5 }, - "files": { - "app/Services/OrderService.php": { - "errors": 2, - "messages": [ - { "message": "Call to undefined method ...", "line": 45, "ignorable": true } - ] - } - } -} -``` - -### 6b. PHPMD (mess-detection) - -PHPMD runs on **scoped files only**. Build a comma-separated file list from the scope: - -```bash -vendor/bin/phpmd {file1},{file2},{file3} json {rulesets} -``` - -Where: -- `{rulesets}` = comma-separated list from `tools.phpmd.rulesets` in preset.yaml (e.g., `unusedcode,codesize`) -- If a project-level `phpmd.xml` exists, use it instead: `vendor/bin/phpmd {files} json phpmd.xml` - -Parse the JSON output. PHPMD JSON format: -```json -{ - "version": "...", - "package": "phpmd", - "violations": [ - { - "rule": "CyclomaticComplexity", - "description": "The method ... has a cyclomatic complexity of 15.", - "file": "app/Http/Controllers/OrderController.php", - "beginLine": 23, - "endLine": 80, - "priority": 1 - } - ] -} -``` - -### 6c. Pest Arch Tests (arch-testing) - -Pest runs the **architecture test directory** (not scoped files): - -```bash -vendor/bin/pest tests/Architecture/ --colors=never -``` - -Parse the text output. Look for FAIL lines: -``` -FAIL Tests\Architecture\CodeGuardArchTest > ... -``` - -Each failed arch test produces one finding. - -### Tool errors - -If a tool binary is not found, this should have been caught by the preflight check (Step 5). If it still happens: -- Report: "{Tool} not found at {binary_path}. Run `{install_command}` to install." -- Continue with remaining tools. - -If a tool command fails (non-zero exit): -- **Report the actual stderr/stdout output verbatim.** Include the literal error text the tool produced. -- **NEVER diagnose or interpret the error.** Do not guess the cause (e.g., do not say "missing APP_KEY" or "environment error" unless those exact words appear in the tool's output). Your job is to relay the error, not to play detective. -- Format: "{Tool} failed (exit code {N}). Output: {literal output}" -- Continue with remaining tools. - ---- - -## Step 7: Parse Tool Output - -Normalize all tool findings into a unified structure: - -For each finding, record: -- **tool** — which tool produced it (e.g., `larastan`, `phpmd`, `pest`) -- **rule** — the rule identifier (e.g., `method.notFound`, `CyclomaticComplexity`, arch test name) -- **file** — relative file path from project root -- **line** — line number (if available) -- **message** — the tool's message -- **enforcement** — from `codeguard.yaml` capability config (`block`, `warn`, or `autofix`) - ---- - -## Step 8: Load Active Patterns - -Load pattern YAML files from all layers in the module hierarchy: - -1. **Core patterns** — all `.yaml` files in `.codeguard/modules/core/patterns/` -2. **Language patterns** — all `.yaml` files in `.codeguard/modules/{language}/patterns/` (e.g., `php`) -3. **Framework patterns** — only patterns listed in `codeguard.yaml` `patterns.catalog` from `.codeguard/modules/{module}/patterns/` -4. **Discovered patterns** — patterns listed in `patterns.discovered` from `.codeguard/patterns/` -5. **Custom patterns** — patterns listed in `patterns.custom` from `.codeguard/patterns/` - -Core and language patterns are always active (they represent fundamental quality standards). Framework, discovered, and custom patterns are controlled by `codeguard.yaml`. - -For each pattern YAML, extract: -- `name` — pattern identifier -- `description` — what the pattern enforces -- `severity` — `critical`, `warning`, or `suggestion` -- `verification.rules` — list of plain-English rules to check -- `examples.correct` — correct code example -- `examples.violation` — violation code example - ---- - -## Step 9: AI Semantic Analysis - -This is the **core value** of CodeGuard. You analyze the code in scope against every active pattern's verification rules, guided by the ai-rules loaded in Step 2. - -### How to analyze - -For each active pattern: -1. Read its `verification.rules` list -2. For each rule, scan the code in scope looking for violations -3. Apply the detection heuristics from the ai-rules (e.g., laravel.md describes exactly what to look for when checking "controllers must not access Eloquent models directly") -4. Apply false positive prevention rules from the ai-rules (e.g., route model binding is NOT a violation) -5. Consider the project context from CODEGUARD.md - -### What to produce for each AI finding - -- **pattern** — which pattern was violated (name + description) -- **rule** — which specific verification rule was broken -- **file** — the file path -- **line** — the line number or range -- **violation** — what was found (be specific: quote the offending code) -- **severity** — from the pattern's `severity` field, adjusted per ai-rules severity classification: - - **Critical**: Core architecture broken, structural integrity undermined - - **Warning**: Pattern partially followed, significant deviation - - **Suggestion**: Improvement opportunity, code works but could be cleaner -- **remediation** — how to fix it (specific, actionable, with code example when helpful) - -### Thresholds - -Check thresholds from `codeguard.yaml`: -- `max_method_lines` — flag methods exceeding this line count (Warning severity) -- `max_indentation_levels` — flag nesting exceeding this depth (Warning severity) - -These are AI-only checks. No deterministic tool enforces them. - ---- - -## Step 10: Classify All Findings - -Merge tool findings (Step 7) and AI findings (Step 9) into a single list. Classify each: - -| Source | Symbol | Color | Meaning | -|---|---|---|---| -| Tool finding with `enforcement: block` | `✗` | Red | Blocking violation | -| Tool finding with `enforcement: warn` | `⚠` | Yellow | Warning, non-blocking | -| AI finding with severity `critical` | `✗` | Red | Critical pattern violation | -| AI finding with severity `warning` | `⚠` | Yellow | Pattern deviation | -| AI finding with severity `suggestion` | `→` | Blue | Improvement opportunity | - ---- - -## Step 11: Generate Report - -Present findings grouped by pattern/tool, ordered by severity (critical first, then warning, then suggestion). - -### Report format - -``` -codeguard · analysis report -Scope: {scope description} - -━━━ TOOL FINDINGS ━━━ - - ✗ app/Services/OrderService.php:45 - Larastan: Call to undefined method calculateTotal() - - ⚠ app/Http/Controllers/OrderController.php:23 - PHPMD: CyclomaticComplexity — method has complexity of 15 - -━━━ PATTERN ANALYSIS ━━━ - - service-layer — Controllers delegate business logic to Services - - ✗ app/Http/Controllers/OrderController.php:31 - Rule: controllers must not access Eloquent models directly - Found: Order::create($request->all()) called directly in controller - Fix: Move to OrderService. Inject OrderService and call $this->orderService->create(OrderData::from($request)) - - ⚠ app/Http/Controllers/UserController.php:18 - Rule: controllers must not contain business logic - Found: Complex discount calculation (if/else chain, lines 18-42) - Fix: Extract to CalculateDiscountAction or UserService::calculateDiscount() - - dto — Use typed DTOs between layers - - → app/Services/PaymentService.php:55 - Rule: DTOs required between layers - Found: Raw array returned from processPayment() to controller - Fix: Create PaymentResult DTO with status, transactionId, amount fields - -━━━ SUMMARY ━━━ - - {total} findings · {critical_count} critical · {warning_count} warnings · {suggestion_count} suggestions - Tool findings: {tool_count} ({baselined_count} baselined, suppressed) - AI findings: {ai_count} (not baselined) -``` - -If there are no findings at all, report: -``` -codeguard · analysis report -Scope: {scope description} - - All clear. No violations found. -``` - ---- - -## Step 12: Offer Corrections - -After presenting the report, offer to fix violations: - -1. **"Fix this"** — fix a specific finding (user points to one) -2. **"Fix all X violations"** — fix all findings for a specific pattern (e.g., "fix all service-layer violations") -3. **"Fix all"** — fix everything the AI can fix - -When fixing: -- Show the proposed change as a diff before applying -- Apply changes file by file -- After each fix, briefly confirm what was changed -- Do NOT fix tool findings (those require the user to address config or code issues that tools flag) — only fix AI pattern findings where you can generate correct code - ---- - -## Step 13: Baseline Management - -The baseline tracks **deterministic tool findings only**. AI semantic findings are never baselined — they are report-only. - -### Baseline file format - -Path: the value from `codeguard.yaml` `baseline.path` (default: `.codeguard/baseline.json`) - -```json -{ - "version": "1.0", - "generated": "2026-03-21T14:30:00Z", - "generatedBy": "codeguard-run", - "module": "php-laravel", - "entries": [ - { - "tool": "larastan", - "rule": "method.notFound", - "file": "app/Services/OrderService.php", - "message_normalized": "Call to undefined method * on *", - "hash": "a1b2c3d4" - } - ] -} -``` - -### Hash computation - -Each baseline entry's `hash` is a truncated SHA-256 of four fields concatenated with `|`: - -``` -sha256("larastan|method.notFound|app/Services/OrderService.php|Call to undefined method * on *") -``` - -Truncate to the first 8 hex characters. - -### Message normalization - -Before hashing, normalize the message to strip volatile content: -- Remove line numbers and column numbers (e.g., "on line 45" becomes "on line *") -- Replace specific type names with wildcards where they include generated or variable content -- The goal: the same conceptual violation produces the same hash even if the code moves within the file - -### First run (no baseline exists) - -After analysis completes: -1. Tell the user: "No baseline found. Would you like to create one from the current tool findings?" -2. If confirmed, generate `.codeguard/baseline.json` with all current **tool** findings as entries -3. Report: "Baseline created with {count} entries. These findings will be suppressed in future hook runs." - -### Subsequent runs (baseline exists) - -1. Load the existing baseline -2. For each tool finding, compute its hash and check against baseline entries -3. Separate findings into: - - **Baselined** — hash matches an existing entry (suppress from report, show count in summary) - - **New** — hash does not match any baseline entry (show in report as new violations) -4. After analysis, if there are new tool findings, ask the user: - - "There are {count} new tool findings not in the baseline. Add them to the baseline, or keep as violations?" - - If the user chooses to add: append new entries to the baseline, update the `generated` timestamp - - If the user chooses to keep: leave the baseline unchanged. These will continue to appear as violations in hook runs. - -### What the baseline does NOT cover - -- AI semantic findings are **never** baselined. They appear in every run. -- The baseline is consumed by the hook runner during `git commit`. Baselined entries are suppressed from the pre-commit output. - ---- - -## Error Handling - -| Situation | Action | -|---|---| -| `codeguard.yaml` not found | Stop. Tell user to run `/codeguard-setup` | -| `CODEGUARD.md` not found | Continue without project context. Warn the user. | -| `.codeguard/modules/` not found | Stop. Tell user to run `npx @henryavila/codeguard install` | -| Tool binary not found | Should be caught by preflight (Step 5). If not, report with install command. Continue. | -| Tool crashes (non-zero exit with stderr) | Report **verbatim** output. Continue with other tools. | -| No patterns loaded | Warn but continue — tool analysis still runs | -| Baseline file corrupt/unparseable | Treat as empty baseline. Warn the user. | - -### CRITICAL: No inference on tool errors - -When any tool fails, crashes, or produces unexpected output: - -1. **Report the literal output** — copy the exact stdout/stderr the tool produced -2. **NEVER infer, diagnose, or speculate about the cause** — do not say "probably because of X" or "likely caused by Y" -3. **NEVER fabricate error details** — if the tool says "segfault", report "segfault", not "missing configuration file" -4. **If the output is empty**, say: "{Tool} failed with exit code {N} and produced no output" - -The user is an experienced developer. They can read error messages. Your job is to **relay**, not **interpret**. - ---- - -## Notes - -- The `ai-review` capability has no tool adapter and no hook involvement. It is always available through this skill — the AI performs semantic analysis directly using loaded patterns and ai-rules. -- Pint (formatting) is not run during `/codeguard-run`. Pint is an autofix tool that runs in the pre-commit hook. If the user wants to format, they run `vendor/bin/pint` directly. -- Output symbols match the hook runner output for consistency: `✗` for blocking, `⚠` for warning, `→` for suggestion. -- When running on staged changes, the scope may be empty (no staged PHP files). Report this and exit gracefully. diff --git a/resources/skills/codeguard-setup/SKILL.md b/resources/skills/codeguard-setup/SKILL.md deleted file mode 100644 index fec57e6..0000000 --- a/resources/skills/codeguard-setup/SKILL.md +++ /dev/null @@ -1,527 +0,0 @@ ---- -name: codeguard-setup -description: Configure project standards by analyzing codebase and generating governance configuration ---- - -# /codeguard-setup - -| IDE | How to invoke | -|---|---| -| Claude Code | `/codeguard-setup` | -| OpenCode | `/codeguard-setup` | -| Cursor | Mention "codeguard-setup" in chat | -| Codex CLI | To be validated | -| Gemini CLI | To be validated | -| Copilot CLI | To be validated | -| Windsurf | To be validated | - -## Overview - -This skill analyzes a project's codebase, detects the technology stack, identifies architectural patterns in use, and generates the governance configuration files that CodeGuard uses for enforcement. It produces: `codeguard.yaml`, `CODEGUARD.md`, Pest arch tests, and a git pre-commit hook. - -If the project already has a `codeguard.yaml`, this skill runs in **update mode** -- it detects drift between the config and the codebase, and regenerates affected files. - -## Prerequisites - -- The project must be a git repository (`git rev-parse --git-dir` succeeds) -- `npx @henryavila/codeguard install` must have been run already, which creates: - - `.codeguard/hook-runner.js` (copied from the npm package) - - `.codeguard/modules/` (module data copied from the npm package) -- PHP projects: `composer` must be available on PATH - -## Instructions - -### Step 1 -- Detect existing configuration - -Check whether `codeguard.yaml` exists in the project root. - -- If it exists: switch to **Update Mode** (see section below) after completing Steps 2-4 -- If it does not exist: continue with first-time setup (Steps 2-15) - -### Step 2 -- Scan available modules - -Read all `module.yaml` files from `.codeguard/modules/`. If `.codeguard/modules/` does not exist or is incomplete, fall back to `node_modules/@henryavila/codeguard/modules/`: - -```bash -# Primary location (installed by npx @henryavila/codeguard install) -find .codeguard/modules -name "module.yaml" -type f 2>/dev/null - -# Fallback (npm package source) -find node_modules/@henryavila/codeguard/modules -name "module.yaml" -type f 2>/dev/null -``` - -**Important:** Intermediate layers (`core/`, `php/`) do not have `module.yaml` — they only contain `patterns/` and `ai-rules/`. Only leaf modules (e.g., `php-laravel/`) have `module.yaml`. Verify that `core/` and the language layer directory exist at whichever base path you are using. If missing from `.codeguard/modules/`, copy them from `node_modules/@henryavila/codeguard/modules/`. - -For each `module.yaml`, parse the YAML and store: `name`, `label`, `language`, `framework`, `detection`, `capabilities`. - -### Step 3 -- Detect stack - -For each discovered module, run its detection heuristics against the project: - -1. **File check**: verify each file in `detection.files` exists in the project root -2. **Dependency check**: verify each entry in `detection.dependencies` appears in the project's dependency manifest (e.g., `composer.json` `require` or `require-dev`) -3. **Confidence**: if ALL file checks AND at least one dependency match, detection confidence is `high`. If only files match, confidence is `medium`. - -If multiple modules match, prefer the one with `confidence: high`. If still tied, ask the developer to choose. - -Present the detection result to the developer: - -``` -Detected stack: Laravel (php-laravel) - Evidence: - - composer.json exists - - artisan exists - - laravel/framework found in composer.json - Confidence: high - -Is this correct? [Y/n] -``` - -If the developer says no, list all available modules and let them choose. - -### Step 4 -- Resolve module hierarchy - -Using the detected module's `language` field, resolve the full pattern chain: - -1. `core/` -- always loaded (universal patterns) -2. `{language}/` -- loaded if `.codeguard/modules/{language}/` directory exists (e.g., `php/`) -3. `{language}-{framework}/` -- the detected leaf module (e.g., `php-laravel/`) - -Load all pattern YAML files from each layer's `patterns/` directory, in order. If a pattern name exists at multiple layers, the most specific layer wins (leaf > language > core). - -Categorize loaded patterns into: -- **Core patterns**: from `core/` -- always active, not user-configurable -- **Language patterns**: from `{language}/` -- always active, not user-configurable -- **Framework patterns**: from `{language}-{framework}/` -- user selects which to activate - -### Step 5 -- Phase 1: Recognition - -Present all detected patterns to the developer, grouped by layer. - -For each framework-layer pattern, check its `detection.signals` against the project: -- `type: directory` -- check if directory exists -- `type: file` -- check if matching files exist (glob) -- `type: import` -- search PHP files for matching use/import statements - -Present findings: - -``` -=== Active Patterns === - -Core (always active, 13 patterns): - - single-responsibility, dry, small-functions, few-arguments, - consistent-error-handling, separation-of-concerns, no-long-switch, - no-constructor-many-params, no-god-object, no-deep-inheritance, - layer-dependency-direction, no-circular-dependencies, bounded-contexts - -PHP (always active, 6 patterns): - - strict-typing, no-html-in-php, no-debug-functions, type-declarations, - exception-handling, no-superglobals - -Laravel (detected with evidence): - [x] service-layer -- app/Services/ directory found - [x] dto -- Spatie\LaravelData\Data import found - [ ] form-requests -- no app/Http/Requests/ directory found - [x] action-classes -- app/Actions/ directory found - ... -``` - -Patterns with detection evidence are pre-selected. Patterns without evidence are unchecked but available. - -### Step 6 -- Phase 2: Discovery - -Scan the codebase for architectural patterns NOT in the catalog. Look for: - -- Recurring structural conventions (e.g., `app/Repositories/`, `app/Enums/`, `app/Events/`) -- Naming patterns (e.g., all services end with `Service`, all actions end with `Action`) -- Custom architectural rules the project follows - -For each discovered pattern, create a pattern YAML file at `.codeguard/patterns/{name}.yaml` using the standard schema: - -```yaml -name: result-objects -description: Service methods return Result objects instead of throwing exceptions -category: custom -layer: laravel -severity: warning -classification: discovered - -detection: - signals: - - type: directory - value: app/Results - - type: import - value: "App\\Results\\*" - confidence: medium - -verification: - rules: - - service methods should return Result objects for operations that can fail - - exceptions should be reserved for truly exceptional circumstances - -examples: - correct: | - public function processOrder(OrderData $data): OrderResult - { - // returns Result with success/failure - } - violation: | - public function processOrder(OrderData $data): Order - { - // throws exception on business rule failure - } -``` - -Present discovered patterns to the developer for approval. - -### Step 7 -- Phase 3: Control - -Let the developer adjust the pattern selection: - -1. **Framework patterns**: add or remove from the detected list -2. **Discovered patterns**: confirm, edit, or discard -3. **Custom patterns**: developer can describe a pattern they want enforced -- create the YAML for it in `.codeguard/patterns/` - -Core and language patterns cannot be removed -- they represent fundamental quality standards. - -### Step 8 -- Configure capabilities - -Read `capabilities` from the detected module's `module.yaml`. For each capability, ask the developer to configure: - -- **enabled**: true/false (default: true for all) -- **enforcement**: block, warn, or autofix -- **level** (static-analysis only): integer level (default from `module.yaml`). PHPStan docs recommend level 5 as the minimum for all projects; Spatie packages default to 5. Level 5 checks argument types — the biggest bang for the buck. Level 8-9 is recommended for new/greenfield projects. - -Enforcement constraints: -- `autofix` is only valid for `formatting` (Pint). If the developer tries to set autofix on another capability, warn them and default to `block`. -- Recommend: `static-analysis: block`, `formatting: autofix`, `mess-detection: warn`, `arch-testing: block` - -Present defaults and let the developer accept or customize: - -``` -Capabilities: - static-analysis (Larastan): enabled, level 5, enforcement: block - formatting (Pint): enabled, preset: laravel, enforcement: autofix - mess-detection (PHPMD): enabled, rulesets: codesize,design,unusedcode, enforcement: warn - arch-testing (Pest): enabled, enforcement: block - -Accept defaults? [Y/n] or specify changes: -``` - -Also configure thresholds. Present each threshold with its evidence basis so the developer can make an informed choice: - -``` -Thresholds (AI semantic analysis): - max_method_lines: 30 # PHPMD/PMD default=100, CodeClimate=25, Rule of 30 (Lippert/Roock). Suggested: 30 - max_indentation_levels: 4 # ESLint max-depth=4, CodeClimate=4. Suggested: 4 - -Accept defaults? [Y/n] or specify values: -``` - -### Step 9 -- Install tools - -Read `install_commands` from the detected module's `preset.yaml` at `.codeguard/modules/{module}/preset.yaml`. - -For each tool, check if already installed (check `composer.json` require-dev). Only install missing tools: - -```bash -composer require --dev larastan/larastan -composer require --dev laravel/pint -composer require --dev phpmd/phpmd -composer require --dev pestphp/pest -``` - -If a tool install fails, warn the developer but continue. Record which tools are installed. - -### Step 10 -- Generate codeguard.yaml - -Write `codeguard.yaml` to the project root. Only framework-specific patterns appear in `patterns.catalog`. Core and language patterns are loaded automatically by the module hierarchy and are NOT listed. - -```yaml -version: "1.0" - -project: - language: php - framework: laravel - module: php-laravel - -capabilities: - static-analysis: - enabled: true - level: 5 - enforcement: block - formatting: - enabled: true - enforcement: autofix - mess-detection: - enabled: true - enforcement: warn - arch-testing: - enabled: true - enforcement: block - presets: [php, laravel] - -patterns: - catalog: - - service-layer - - dto - - form-requests - - action-classes - - value-objects - - resource-controllers - - policies - - no-env-outside-config - - no-logic-in-blade - discovered: - # names of patterns in .codeguard/patterns/ confirmed during discovery - custom: - # names of custom patterns in .codeguard/patterns/ added by developer - -thresholds: - max_method_lines: 30 - max_indentation_levels: 4 - -hooks: - pre-commit: - enabled: true - scope: staged-files - -baseline: - path: .codeguard/baseline.json -``` - -Fields: -- `patterns.catalog` -- only framework-layer patterns the developer activated in Step 7 -- `patterns.discovered` -- names of discovered patterns confirmed in Step 6 (YAML files in `.codeguard/patterns/`) -- `patterns.custom` -- names of custom patterns added in Step 7 (YAML files in `.codeguard/patterns/`) -- `capabilities.arch-testing.presets` -- from `module.yaml` `capabilities.arch-testing.presets` (e.g., `[php, laravel]`). This field is used only at setup time to generate Pest arch tests; the hook runner ignores it. - -### Step 11 -- Generate CODEGUARD.md - -Write `CODEGUARD.md` to the project root. This file is NOT templated -- write project-specific content based on what you learned during the setup conversation. AI IDEs read this file automatically as context during code generation. - -The file MUST include these sections: - -1. **Project Architecture** -- describe the project's layered structure based on detected patterns (e.g., "Controllers delegate to Services, Services contain business logic, DTOs transfer data between layers") -2. **Active Patterns** -- list all active patterns (core + language + framework + discovered + custom) with a one-line description of each -3. **Naming Conventions** -- document naming conventions observed in the codebase (e.g., "Services in `app/Services/`, suffixed with `Service`") -4. **File Organization** -- describe directory structure and where different types of code live -5. **Tool Configuration** -- summarize which tools are active, their enforcement levels, and key settings -6. **Code Generation Guidelines** -- concrete rules the AI must follow when generating code for this project (derived from active patterns) - -Write in second person ("You must...") since the audience is an AI generating code. - -### Step 12 -- Generate Pest arch tests - -Write `tests/Architecture/CodeGuardArchTest.php`. Create the `tests/Architecture/` directory if it does not exist. - -The file contains two kinds of tests: - -**1. Preset calls** from `capabilities.arch-testing.presets`: - -For each preset in the array (e.g., `[php, laravel]`), emit: - -```php -arch()->preset()->php(); -arch()->preset()->laravel(); -``` - -**2. Custom arch rules** from active patterns' verification rules: - -For each active pattern (all layers), examine each verification rule. Classify: - -| Goes to Pest (deterministic) | Stays AI-only (semantic) | -|---|---| -| Rules about namespaces, imports, class types | Rules about intent or behavior | -| Rules about dependencies between layers | Rules about code quality | -| Rules about naming conventions | Rules about missing abstractions | -| Expressible as `toOnlyUse`, `toNotDependOn`, `toExtend`, `toImplement`, `toHavePrefix`, `toHaveSuffix`, `toBeClasses`, `toBeInterfaces`, `toBeEnums` | Everything else | - -Only generate Pest rules for deterministic rules. Examples of pattern-to-Pest translation: - -- "controllers must not access Eloquent models directly" --> - `arch()->expect('App\Http\Controllers')->toNotDependOn('Illuminate\Database\Eloquent')` -- "services must not return HTTP responses" --> - `arch()->expect('App\Services')->toNotDependOn('Illuminate\Http')` -- "services must not access Request object" --> - `arch()->expect('App\Services')->toNotDependOn('Illuminate\Http\Request')` -- "no env() calls outside of config/" --> AI-only (env() is a global function, not a namespace/class — Pest `toNotUse` cannot detect it) - -Rules like "controllers must not contain business logic" or "methods should be short and focused" are AI-only -- skip them. - -Generate the full PHP file: - -```php -preset()->php(); -arch()->preset()->laravel(); - -// Pattern: service-layer -arch('controllers must not depend on Eloquent') - ->expect('App\Http\Controllers') - ->toNotDependOn('Illuminate\Database\Eloquent'); - -arch('services must not depend on HTTP layer') - ->expect('App\Services') - ->toNotDependOn('Illuminate\Http'); - -arch('services must not access Request') - ->expect('App\Services') - ->toNotDependOn('Illuminate\Http\Request'); - -// Pattern: form-requests -arch('controllers should use form requests') - ->expect('App\Http\Controllers') - ->toNotDependOn('Illuminate\Validation'); - -// ... additional deterministic rules from other active patterns -``` - -Adapt the namespace expectations to match the project's actual directory structure. If the project uses `app/Actions/` instead of `app/Services/`, adjust the `expect()` namespaces accordingly. - -### Step 13 -- Verify hook runner - -Verify that `.codeguard/hook-runner.js` exists (it should have been copied by `npx @henryavila/codeguard install`): - -```bash -test -f .codeguard/hook-runner.js && echo "Hook runner found" || echo "Hook runner missing" -``` - -If missing, attempt to locate it and copy: - -```bash -cp node_modules/@henryavila/codeguard/dist/hooks/runner.js .codeguard/hook-runner.js -``` - -If that also fails, warn the developer: - -``` -WARNING: Hook runner not found. Run `npx @henryavila/codeguard install` to set it up. -The pre-commit hook will not work until the hook runner is in place. -``` - -### Step 14 -- Install git hook - -Write the pre-commit shell shim to `.git/hooks/pre-commit`: - -```bash -#!/bin/sh -exec node "$(dirname "$0")/../.codeguard/hook-runner.js" -``` - -Make it executable: - -```bash -chmod +x .git/hooks/pre-commit -``` - -If `.git/hooks/pre-commit` already exists, check its contents: -- If it already contains the CodeGuard shim, leave it alone -- If it contains other hook content, append the CodeGuard invocation or warn the developer about the conflict and let them decide - -### Step 15 -- Show setup summary - -Present a summary of everything that was configured: - -``` -=== CodeGuard Setup Complete === - -Module: php-laravel (Laravel) -Patterns: 28 active (13 core + 6 php + 9 laravel) -Discovered: 1 (result-objects) - -Capabilities: - static-analysis: Larastan level 5 [block] - formatting: Pint (laravel preset) [autofix] - mess-detection: PHPMD [warn] - arch-testing: Pest [block] - -Files created: - codeguard.yaml -- project configuration - CODEGUARD.md -- AI context document - tests/Architecture/CodeGuardArchTest.php -- Pest arch tests (X deterministic rules) - .git/hooks/pre-commit -- git hook (shell shim) - -Files verified: - .codeguard/hook-runner.js -- pre-commit hook runner - -Next steps: - 1. Review codeguard.yaml and CODEGUARD.md - 2. Run `vendor/bin/pest tests/Architecture/` to validate arch tests - 3. Make a commit to test the pre-commit hook - 4. Run /codeguard-run for a full AI-powered analysis -``` - -### Step 16 -- Update IDE context file - -If the project uses an IDE that reads a context file (e.g., `CLAUDE.md` for Claude Code), append a reference to `CODEGUARD.md` using marker comments so it can be updated on re-runs: - -```markdown - -Read CODEGUARD.md for this project's architecture patterns and code generation guidelines. -When generating code, follow the patterns and conventions described there. - -``` - -If the markers already exist, replace the content between them. If the context file does not exist, create it with just the CodeGuard block. - -## Update Mode - -When `codeguard.yaml` already exists, the skill runs in update mode after completing Steps 2-4 (scan modules, detect stack, resolve hierarchy, load patterns). - -### Step U1 -- Load existing configuration - -Read the existing `codeguard.yaml` and parse it. - -### Step U2 -- Detect drift - -Compare the current codebase state against the existing config: - -1. **New patterns available**: patterns in the module catalog that are not in `codeguard.yaml` (e.g., a CodeGuard update added new patterns) -2. **Missing patterns**: patterns in `codeguard.yaml` that no longer exist in the module (e.g., a pattern was renamed or removed) -3. **Detection changes**: patterns whose detection signals now match (or no longer match) the codebase -4. **New discovered patterns**: AI re-scans for patterns beyond the catalog that may have emerged since last setup -5. **Capability changes**: new capabilities available in updated module, or module defaults changed - -### Step U3 -- Present drift report - -``` -=== CodeGuard Configuration Drift === - -New patterns available: - + new-pattern-name -- description (added in CodeGuard vX.Y) - -Detection changes: - + form-requests -- app/Http/Requests/ directory now exists (was unchecked) - - action-classes -- app/Actions/ directory no longer exists (was active) - -Newly discovered patterns: - + query-scopes -- QueryScope classes found in app/Scopes/ - -No capability changes. -``` - -### Step U4 -- Developer validates - -Let the developer accept, reject, or modify each change. Only apply confirmed changes. - -### Step U5 -- Regenerate affected files - -Based on what changed: - -- If patterns changed: regenerate `codeguard.yaml` `patterns` section, `CODEGUARD.md`, and `tests/Architecture/CodeGuardArchTest.php` -- If capabilities changed: regenerate `codeguard.yaml` `capabilities` section and `CODEGUARD.md` tool section -- If thresholds changed: update `codeguard.yaml` `thresholds` section -- Always re-verify hook runner and git hook (Steps 13-14) -- Update IDE context file markers (Step 16) - -Show a summary of what was updated. diff --git a/src/Analyze/AnalyzeRunner.php b/src/Analyze/AnalyzeRunner.php index 2bc0ed2..67cb85a 100644 --- a/src/Analyze/AnalyzeRunner.php +++ b/src/Analyze/AnalyzeRunner.php @@ -9,12 +9,18 @@ use Henryavila\Codeguard\Telemetry\Recorder; /** - * Orchestrates an analyze run: load patterns for the presets, match them to - * the scoped files, ask the {@see LlmClient} to review each unit, validate - * findings through the {@see PatternMatch} trust boundary, and emit the - * `analyze.ended` telemetry event. + * Orchestrates an analyze run. Three entry points share the same deterministic + * scope+match core ({@see units()}): * - * Makes ONE LLM call per file (the file is the expensive shared context). + * - run() — synchronous: call the {@see LlmClient} per file (used by + * a future API driver; NullLlmClient yields the honest + * no-driver degradation path). + * - buildWorkOrder() — context-emit: serialize units for a Claude Code skill to + * review with subagents (subscription, no external API). + * - ingest() — validate findings produced out-of-band through the same + * {@see PatternMatch} trust boundary, then gate + telemeter. + * + * Findings are validated identically regardless of who produced them. */ final class AnalyzeRunner { @@ -34,9 +40,7 @@ public function run(array $files, array $presets, ?Severity $failOn, string $con { $start = hrtime(true); - $patterns = $this->patterns->forPresets($presets); - $units = $this->matcher->match($files, $patterns); - + $units = $this->units($files, $presets); $adjudicated = $this->llm->isConfigured(); $matches = []; $checks = 0; @@ -60,6 +64,102 @@ public function run(array $files, array $presets, ?Severity $failOn, string $con } } + return $this->finish($matches, $checks, $start, $failOn, $adjudicated); + } + + /** + * Serialize the scoped units + patterns into a work order for a Claude Code + * skill to review (one subagent per batch of files). No LLM is called here. + * + * @param list $files + * @param list $presets + * @return array{system_prompt: string, finding_schema: array, units: list>} + */ + public function buildWorkOrder(array $files, array $presets): array + { + $units = array_map( + static fn (AnalysisUnit $unit): array => [ + 'file' => $unit->file, + 'patterns' => array_map( + static fn (Pattern $pattern): array => $pattern->toPromptArray(), + $unit->patterns, + ), + ], + $this->units($files, $presets), + ); + + return [ + 'system_prompt' => $this->systemPrompt(), + 'finding_schema' => FindingSchema::jsonSchema(), + 'units' => $units, + ]; + } + + /** + * Validate findings produced out-of-band (by the Claude Code subagents) + * against a fresh scope+match, through the same trust boundary. + * + * @param list $files + * @param list $presets + * @param list> $rawFindings + */ + public function ingest(array $files, array $presets, array $rawFindings, ?Severity $failOn): AnalyzeResult + { + $start = hrtime(true); + + $units = $this->units($files, $presets); + $checks = array_sum(array_map(static fn (AnalysisUnit $unit): int => count($unit->patterns), $units)); + + $matches = []; + foreach ($rawFindings as $raw) { + $file = $raw[FindingSchema::KEY_FILE] ?? null; + if (! is_string($file)) { + continue; + } + + $unit = $this->findUnit($units, $file); + if ($unit === null) { + continue; + } + + $match = PatternMatch::fromArray($raw, $unit, $this->patterns); + if ($match !== null) { + $matches[] = $match; + } + } + + return $this->finish($matches, $checks, $start, $failOn, adjudicated: true); + } + + /** + * @param list $files + * @param list $presets + * @return list + */ + private function units(array $files, array $presets): array + { + return $this->matcher->match($files, $this->patterns->forPresets($presets)); + } + + /** + * @param list $units + */ + private function findUnit(array $units, string $file): ?AnalysisUnit + { + foreach ($units as $unit) { + if ($unit->file === $file || basename($unit->file) === basename($file)) { + return $unit; + } + } + + return null; + } + + /** + * @param list $matches + */ + private function finish(array $matches, int $checks, float $start, ?Severity $failOn, bool $adjudicated): AnalyzeResult + { $durationMs = (int) round((hrtime(true) - $start) / 1_000_000); $result = new AnalyzeResult( diff --git a/src/Analyze/Pattern.php b/src/Analyze/Pattern.php index 73273b0..98b6f1a 100644 --- a/src/Analyze/Pattern.php +++ b/src/Analyze/Pattern.php @@ -79,6 +79,26 @@ classification: (string) ($data['classification'] ?? 'mvp'), ); } + /** + * The LLM-facing projection: only the fields a reviewer needs to judge a + * file. Metadata and detection signals are intentionally omitted. + * + * @return array{key: string, description: string, severity: string, verification_rules: list, examples: array{correct: string, violation: string}} + */ + public function toPromptArray(): array + { + return [ + 'key' => $this->key, + 'description' => $this->description, + 'severity' => $this->severity->value, + 'verification_rules' => $this->verificationRules, + 'examples' => [ + 'correct' => $this->examplesCorrect, + 'violation' => $this->examplesViolation, + ], + ]; + } + /** * @return array */ diff --git a/src/CodeguardServiceProvider.php b/src/CodeguardServiceProvider.php index aea25cc..b2e90a8 100644 --- a/src/CodeguardServiceProvider.php +++ b/src/CodeguardServiceProvider.php @@ -382,5 +382,9 @@ private function bootConsole(): void $this->publishes([ __DIR__.'/../resources/patterns' => $this->app->basePath('.codeguard/patterns-vendor'), ], 'codeguard-patterns'); + + $this->publishes([ + __DIR__.'/../resources/skills' => $this->app->basePath('.claude/skills'), + ], 'codeguard-skills'); } } diff --git a/src/Commands/CodeguardAnalyzeCommand.php b/src/Commands/CodeguardAnalyzeCommand.php index f6eb4e4..b865881 100644 --- a/src/Commands/CodeguardAnalyzeCommand.php +++ b/src/Commands/CodeguardAnalyzeCommand.php @@ -21,9 +21,12 @@ final class CodeguardAnalyzeCommand extends Command {--path= : Narrow scope to a file or subtree.} {--all : Full scan of every detection-matched file (CI/manual).} {--fail-on=critical : Exit non-zero at/above this severity — critical|warning|suggestion|never.} - {--context=manual : Telemetry context — pre-commit|pre-push|ci|manual.}'; + {--context=manual : Telemetry context — pre-commit|pre-push|ci|manual.} + {--emit : Write a work order JSON (for the codeguard-review Claude skill) instead of calling an LLM.} + {--ingest= : Validate + report findings from this JSON file (produced out-of-band by the skill).} + {--out= : Output path for --emit (default .codeguard/analyze-request.json).}'; - protected $description = 'Run pattern-based LLM review over scoped files and report findings.'; + protected $description = 'Run pattern-based review over scoped files and report findings.'; private const ALLOWED_CONTEXTS = ['pre-commit', 'pre-push', 'ci', 'manual']; @@ -33,6 +36,15 @@ public function handle( FileScopeResolver $scope, Recorder $recorder, ): int { + if ((bool) $this->option('emit')) { + return $this->handleEmit($config, $runner, $scope); + } + + $ingest = $this->option('ingest'); + if (is_string($ingest) && $ingest !== '') { + return $this->handleIngest($config, $runner, $scope, $recorder, $ingest); + } + $context = $this->resolveContext(); $failOn = $this->resolveFailOn(); @@ -67,6 +79,88 @@ public function handle( return $exitCode; } + private function handleEmit(CodeguardConfig $config, AnalyzeRunner $runner, FileScopeResolver $scope): int + { + $files = $this->resolveFiles($scope); + $workOrder = $runner->buildWorkOrder($files, $config->enabledPresets); + + $out = (string) ($this->option('out') ?: base_path('.codeguard'.DIRECTORY_SEPARATOR.'analyze-request.json')); + $dir = dirname($out); + if (! is_dir($dir)) { + mkdir($dir, 0o755, true); + } + + $json = json_encode($workOrder, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + file_put_contents($out, ($json !== false ? $json : '{}')."\n"); + + $this->components->info(sprintf('Wrote %d analysis unit(s) to %s', count($workOrder['units']), $out)); + + return self::SUCCESS; + } + + private function handleIngest( + CodeguardConfig $config, + AnalyzeRunner $runner, + FileScopeResolver $scope, + Recorder $recorder, + string $ingestPath, + ): int { + $failOn = $this->resolveFailOn(); + $startHrtime = hrtime(true); + $recorder->record( + event: EventName::CommandStart, + status: EventStatus::Ok, + durationMs: 0, + extras: ['command' => 'analyze', 'preset_flag' => null], + ); + + if (! is_file($ingestPath)) { + $this->components->error(sprintf('Findings file not found: %s', $ingestPath)); + $this->emitCommandEnd($recorder, self::FAILURE, $startHrtime); + + return self::FAILURE; + } + + $contents = file_get_contents($ingestPath); + $findings = $this->normalizeRawFindings($contents === false ? null : json_decode($contents, true)); + + $files = $this->resolveFiles($scope); + $result = $runner->ingest($files, $config->enabledPresets, $findings, $failOn); + + $this->renderFindings($result); + + $exitCode = $result->failed($failOn) ? self::FAILURE : self::SUCCESS; + $this->emitCommandEnd($recorder, $exitCode, $startHrtime); + + return $exitCode; + } + + /** + * Accepts either a bare findings array or a `{ "findings": [...] }` envelope. + * + * @return list> + */ + private function normalizeRawFindings(mixed $decoded): array + { + if (! is_array($decoded)) { + return []; + } + + $list = array_is_list($decoded) ? $decoded : ($decoded['findings'] ?? []); + if (! is_array($list)) { + return []; + } + + $findings = []; + foreach ($list as $item) { + if (is_array($item)) { + $findings[] = $item; + } + } + + return $findings; + } + /** * @return list */ diff --git a/tests/Feature/CodeguardAnalyzeCommandTest.php b/tests/Feature/CodeguardAnalyzeCommandTest.php index 7bef064..6f7a2bd 100644 --- a/tests/Feature/CodeguardAnalyzeCommandTest.php +++ b/tests/Feature/CodeguardAnalyzeCommandTest.php @@ -224,3 +224,63 @@ function analyzeReadEvents(string $path): array analyzeCleanup($file, $telemetry); } }); + +it('emits a work order JSON with units and prompt-ready patterns', function (): void { + $file = analyzeFixtureFile(); + $out = sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-workorder-'.uniqid().'.json'; + + try { + $exit = Artisan::call('codeguard:analyze', ['--emit' => true, '--path' => $file, '--out' => $out]); + + expect($exit)->toBe(0) + ->and(is_file($out))->toBeTrue(); + + $decoded = json_decode((string) file_get_contents($out), true); + $units = (is_array($decoded) && is_array($decoded['units'] ?? null)) ? $decoded['units'] : []; + + expect($units)->toHaveCount(1); + + $first = is_array($units[0] ?? null) ? $units[0] : []; + $patterns = is_array($first['patterns'] ?? null) ? $first['patterns'] : []; + $patternZero = is_array($patterns[0] ?? null) ? $patterns[0] : []; + + expect($first['file'] ?? null)->toBe($file) + ->and(count($patterns))->toBeGreaterThan(0) + ->and($patternZero)->toHaveKeys(['key', 'description', 'severity', 'verification_rules', 'examples']); + } finally { + analyzeCleanup($file, $out); + } +}); + +it('ingests findings, drops hallucinations via the trust boundary, and gates the exit code', function (): void { + $telemetry = analyzeTelemetryPath(); + $file = analyzeFixtureFile(); + $findingsPath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-findings-'.uniqid().'.json'; + + file_put_contents($findingsPath, (string) json_encode([ + ['pattern_key' => 'no-god-object', 'file' => $file, 'line' => 3, 'message' => 'too many responsibilities', 'severity' => 'critical', 'confidence' => 0.9], + ['pattern_key' => 'ghost-pattern', 'file' => $file, 'line' => 1, 'message' => 'hallucinated', 'severity' => 'critical', 'confidence' => 0.9], + ])); + + $fake = new FakeLlmClient(fn (AnalysisUnit $unit): array => []); + analyzeBind($telemetry, $fake); + + try { + $exit = Artisan::call('codeguard:analyze', ['--ingest' => $findingsPath, '--path' => $file, '--context' => 'ci']); + + $events = analyzeReadEvents($telemetry); + $analyzeEnded = array_values(array_filter( + $events, + static fn (array $event): bool => ($event['event'] ?? '') === 'analyze.ended', + )); + + expect($exit)->toBe(1) + ->and($analyzeEnded[0]['matches_count'] ?? null)->toBe(1) + ->and($fake->calls)->toHaveCount(0); + } finally { + if (is_file($findingsPath)) { + unlink($findingsPath); + } + analyzeCleanup($file, $telemetry); + } +}); From 0af6a25582bbfdee5d1dca075b0c7cd443746a99 Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 12:52:57 -0300 Subject: [PATCH 06/21] =?UTF-8?q?docs(memory):=20status=20=E2=80=94=20Incr?= =?UTF-8?q?ement=20D=20context-emit=20shipped;=20R7+R11=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ai/memory/PROJECT-STATUS.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.ai/memory/PROJECT-STATUS.md b/.ai/memory/PROJECT-STATUS.md index 9022e52..72a2ba9 100644 --- a/.ai/memory/PROJECT-STATUS.md +++ b/.ai/memory/PROJECT-STATUS.md @@ -9,9 +9,9 @@ type: project > **Para Claude**: Este é o documento vivo de estado. Leia na primeira ferramenta-call de toda sessão substantiva. Atualize ao completar qualquer commit que mude escopo, ou ao mudar de sprint/foco. Em caso de conflito com outro arquivo de memória, este ganha (pra resolver drift, corrija o outro arquivo, não aqui). **Última atualização**: 2026-06-03 (audit + replan + **Fase 1 traits** e **Fase 2 Patterns engine MVP** shippados na mesma sessão) -**HEAD**: `0dfb953` feat(analyze): pattern engine MVP — codeguard:analyze with pluggable LLM seam -**Branch**: `feat/patterns-engine-foundation` (3 commits ahead de `main`, **não pushed**; working tree limpo). `origin/main` == `4b32886`. -**Suite**: 433 tests / 1040 assertions (verde). Pint clean, PHPStan level 5 No errors. Coverage gate (≥80%) só roda no CI (sem driver Xdebug/pcov local). +**HEAD**: `18c4492` feat(analyze): context-emit driver — --emit/--ingest + codeguard-review skill +**Branch**: `feat/patterns-engine-foundation` (pushed; **PR #1** aberto pra `main`). `origin/main` == `4b32886`. +**Suite**: 435 tests / 1053 assertions (verde). Pint clean, PHPStan level 5 No errors. Coverage gate (≥80%) só roda no CI (sem driver Xdebug/pcov local). **Lint/Static**: Pint clean. PHPStan level 5 self-applied com baseline grandfathered (`156b297`) — R8 fechado. `composer ci` roda pint:test + phpstan + test:coverage; CI ativa em PHP 8.3 + 8.4 via `.github/workflows/ci.yml` (`65893ab`). **Release publicado**: ✅ **`0.2.0` no Packagist desde 2026-05-04** (tag `0.2.0` @ `4b32886`, pushed). Arch consome via repo `vcs` GitHub pinado em `^0.2.0` (lock @ `4b32886`) — **NÃO** via path repo nem `dev-main`. @@ -31,7 +31,7 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. |:---:|---|:---:|---| | **0** | Limpeza canônica (este arquivo + docs stale) | ❌ | ✅ feito (status reescrito; handoff/specs ainda stale — backlog) | | **1** | Bug fixes package-side | ❌ (lê Arch só como ref) | 🟡 traits ✅; `coverage_percent -1` + dead config ainda fila | -| **2** | **Patterns engine MVP** (`src/Analyze/*` + `codeguard:analyze`, Increments A–C) | ❌ | ✅ shippado (`0dfb953`); driver real = Increment D (pende decisão de transporte) | +| **2** | **Patterns engine** (`src/Analyze/*` + `codeguard:analyze`) — MVP A–C **+ Increment D context-emit** | ❌ | ✅ shippado (`0dfb953` MVP, `18c4492` context-emit). Transporte = **context-emit** (assinatura, sem API metered) decidido + construído | | **3** | Schema dump (`codeguard:prepare`) + AI rules generator | ❌ (testáveis via fixtures/SQLite) | ⏸️ depois | | **4** | 🔒 Integração Arch + dogfood real | ✅ | ⛔ **ADIADO** (constraint do usuário) | @@ -48,11 +48,11 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. ### Próxima ação concreta -🔜 **DECISÃO DO USUÁRIO PENDENTE — transporte LLM do Increment D** (driver real). Usuário descartou `claude -p` e pediu "usar Claude Code com assinatura, sem API externa". Realidade técnica: assinatura só via CLI `claude` headless OU modelo context-emit (package prepara, sessão Claude Code revisa). API metered (anthropic-ai/sdk) está fora. Ajudar a fechar isso antes de codar o driver. +🔜 **Validar o fluxo de review real**: rodar `/codeguard-review` num projeto (emit → subagentes em lote → ingest) e confirmar que findings reais aparecem + são validados pelo trust boundary. (Decisão de transporte RESOLVIDA: context-emit, `claude -p` fora porque vai virar API metered no próximo mês.) -**Sem bloquear**: itens menores da Fase 1 (`coverage_percent -1` em `CodeguardTestCommand.php:102`; decidir o que fazer com config morto ai_rules/prepare — o bloco `patterns` agora É consumido pelo Analyze). +**Decisão do usuário**: revisar/mergear **PR #1** (Fase 1 + Fase 2 MVP). O Increment D (`18c4492`) está no mesmo branch — entra no mesmo PR. -**Decisão pendente do usuário**: pushar o branch `feat/patterns-engine-foundation` / abrir PR / quando mergear em main. +**Backlog package-side (sem bloquear)**: `coverage_percent -1` em `CodeguardTestCommand.php:102`; config morto `ai_rules`/`prepare` (o bloco `patterns` agora É consumido); Fase 3 (schema dump + ai-rules generator). --- @@ -67,7 +67,7 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. | `codeguard:check` | ✅ | src/Commands/CodeguardCheckCommand.php | | `codeguard:test` | ✅ | src/Commands/CodeguardTestCommand.php | | `codeguard:telemetry:enable\|disable\|clear` | ✅ | src/Commands/Telemetry/*.php | -| `codeguard:analyze` | ✅ MVP | src/Commands/CodeguardAnalyzeCommand.php (NullLlmClient default; driver real = Increment D) | +| `codeguard:analyze` | ✅ | src/Commands/CodeguardAnalyzeCommand.php — review síncrono (NullLlmClient default) + `--emit`/`--ingest` (context-emit via skill codeguard-review) | | `codeguard:prepare` | ⏸️ Fase 3 | — | | `codeguard:baseline` | ⏸️ pós-engines | — | @@ -82,7 +82,7 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. | `Hooks\*` | 🟡 parcial | StagedPhpFilesRunner existe | | `Testing\*` | ✅ completo | TestSuiteRunner generalizado + StageConfig (8 campos) + executors + DTOs | | `Assertions\*` | ✅ | AntiPatternScanner + 2 traits implementados (7 checks reais, 21 tests). `0dfb953`/`4c662a0`. | -| `Analyze\*` | ✅ MVP | Severity/Pattern/DetectionSignal/PatternRepository/YamlPatternLoader/FileScopeResolver/PatternMatcher/FindingSchema/PatternMatch/AnalyzeResult/AnalyzeRunner/LlmClient/NullLlmClient. Consome os 28 patterns (de 30 YAMLs; 2 outliers pulados). Driver real pendente. | +| `Analyze\*` | ✅ | 13 classes (loader/scope/matcher/trust-boundary/runner/command). Consome os 28 patterns (2 outliers pulados). Modos: review síncrono + `buildWorkOrder()`/`ingest()` (context-emit). 37 tests. | | `AiRules\*` | ❌ duplo-morto | config targets existe + `resources/rules/` VAZIA (0/7 markdowns, sem git history). Fase 3. | | `Schema\*` | ❌ ausente | só `PrepareConfig` DTO (4 campos). Fase 3. | @@ -92,7 +92,7 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. |---------|:-----:| | `resources/stubs/*.stub` | ✅ stubs (pint, phpstan, phpstan-test-quality, deptrac, infection, captainhook+README, phpunit, jscpd, CI workflow, TestQualityTest) | | `resources/patterns/**/*.yaml` | ✅ **30** (data dormente até Patterns engine) | -| `resources/skills/*/SKILL.md` | 🔴 **stale Node-era** — os 3 mandam rodar `npx @henryavila/codeguard`, ler `node_modules`, `codeguard.yaml` (não existe no package PHP). Quebrariam um usuário real. Backlog cleanup. | +| `resources/skills/*/SKILL.md` | ✅ `codeguard-review` (orquestra emit→subagentes→ingest). As 3 Node-era removidas (`18c4492`). Publicáveis via tag `codeguard-skills` → `.claude/skills`. | | `resources/rules/*.md` | ❌ 0/7 (dir vazia) | --- @@ -102,7 +102,7 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. | Perspectiva | Real | Justificativa | |---|:-:|---| | "install + rodar gates + rodar tests" | **~80%** | Commands reais, installer ~900 LOC, telemetria completa, 377 tests verdes. Descontado: traits lançam exception, `coverage_percent -1`, e o único consumer **não roda** check/test. | -| "pattern-based LLM review" (o diferencial) | **~55%** | Engine MVP shippado (loader/scope/matcher/trust-boundary/comando/telemetria, 33 tests). Falta o driver real (adjudicação LLM via assinatura) = Increment D. Até lá o comando roda mas imprime aviso de degradação honesto. | +| "pattern-based LLM review" (o diferencial) | **~80%** | Engine + driver context-emit + skill codeguard-review shippados (37 tests). Funcionalmente completo pro modelo de assinatura. Falta só validação em campo real + polish (batch tuning). NÃO é gate CI autônomo — por design (CI = codeguard:check AST). | | "AI rules generator" | **~3%** | duplo-morto: `src/AiRules/` ausente + `resources/rules/` vazia | | "schema dump multi-DB" | **~8%** | só `PrepareConfig` DTO | | "publicar/distribuir" | **~85%** | genuinamente no Packagist, tagged, lockável, Node-free. Descontado: footprint `.codeguard/` é git-ignored (não cruza máquinas), 0 downloads, único consumer bypassa a CLI | @@ -131,10 +131,10 @@ Audit multi-agente verificou cada alegação contra git+FS. Corrigido neste arqu | # | Risco | Estado | |---|-------|--------| | R1 | Arch consome o package como **stub-seeder one-shot, não runtime**; dogfood real do check/test nunca rodou em campo | **ADIADO conscientemente** — constraint do usuário (não tocar Arch). Integração = Fase 4, quando liberado | -| R7 | 30 YAMLs eram peso morto até Patterns engine | **QUASE FECHADO** — engine MVP consome os 28 patterns; só falta o driver LLM real (Increment D) pra adjudicar de verdade | +| ~~R7~~ | ~~30 YAMLs eram peso morto até Patterns engine~~ | **FECHADO** — engine + driver context-emit + skill consomem os 28 patterns e adjudicam de verdade (assinatura) | | R9 | Marketing público (README:5, composer.json:3) vende features ausentes | **aceito (Q3)** — aposta que Track A torna verdade; reavaliar se Track A atrasar | | ~~R10~~ | ~~Assertion traits lançam exception num release PUBLICADO~~ | **FECHADO no código** (`4c662a0`) — traits implementados via AntiPatternScanner. Ainda no branch; chega ao público só no 0.3.0 (Q3: sem hotfix 0.2.1) | -| R11 | Skills `resources/skills/*` são Node-era e quebrariam um usuário real | backlog cleanup | +| ~~R11~~ | ~~Skills `resources/skills/*` são Node-era e quebrariam um usuário real~~ | **FECHADO** (`18c4492`) — 3 stale removidas; só `codeguard-review` (correta) fica | | ~~R5~~ | ~~README mínimo~~ | ✅ FECHADO (README existe + alinhado a 0.2.0) | | ~~R8~~ | ~~package não se autoanalisa~~ | ✅ FECHADO (`156b297`: phpstan level 5 + baseline) | From abfce2088fffc53aee0067c7b248888349d0b55d Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 14:45:18 -0300 Subject: [PATCH 07/21] =?UTF-8?q?feat(analyze):=20trust=20threshold=20?= =?UTF-8?q?=E2=80=94=20exact-path=20attribution,=20real=20use-parsing,=20b?= =?UTF-8?q?aseline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 0+1 of the completeness roadmap: cross from "scaffolding" to a reviewer worth gating a contractor PR on. All package-side, subscription-only, no metered API. (Corrections from the adversarial design critic incorporated.) T5 — exact-path attribution: AnalyzeRunner::findUnit matches the exact absolute path first, basename only when unambiguous — two User.php no longer silently cross-attribute. T2 — real `use`-statement parsing (PhpFileInspector, zero new dep): `import` detection signals now match a file's actual imports (namespace-glob), so service-layer/dto/policies/form-requests fire on files that USE them, not just the artifact folder. Catch-all `import: **/*` (the 3 cross-file architectural patterns) excluded from per-file selection until the namespace graph (R3). Class-structure patterns gated to files that declare a class. T4 — accepted-finding baseline (AnalyzeBaseline): `--accept` records findings; future runs suppress + always show "N suppressed". Reverses v0 never-baseline (deliberate). Fingerprint = sha1(pattern_key + relative_file): message-free and line-free so LLM rephrasing / edits-above don't defeat it. Scope-coverage test + docs/patterns-recall.md for the manual recall ritual. Suite 435 -> 452 green. PHPStan level 5 clean. --- docs/patterns-recall.md | 32 +++++ src/Analyze/AnalyzeBaseline.php | 112 ++++++++++++++++++ src/Analyze/AnalyzeResult.php | 1 + src/Analyze/AnalyzeRunner.php | 32 ++++- src/Analyze/PatternMatcher.php | 93 ++++++++++++--- src/Analyze/PhpFileInspector.php | 93 +++++++++++++++ src/CodeguardServiceProvider.php | 20 ++++ src/Commands/CodeguardAnalyzeCommand.php | 34 +++++- tests/Feature/CodeguardAnalyzeCommandTest.php | 47 +++++++- tests/Unit/Analyze/AnalyzeBaselineTest.php | 79 ++++++++++++ tests/Unit/Analyze/AnalyzeRunnerTest.php | 100 ++++++++++++++++ tests/Unit/Analyze/PatternMatcherTest.php | 105 ++++++++++++++-- .../Analyze/PatternSelectionCoverageTest.php | 91 ++++++++++++++ tests/Unit/Analyze/PhpFileInspectorTest.php | 51 ++++++++ 14 files changed, 850 insertions(+), 40 deletions(-) create mode 100644 docs/patterns-recall.md create mode 100644 src/Analyze/AnalyzeBaseline.php create mode 100644 src/Analyze/PhpFileInspector.php create mode 100644 tests/Unit/Analyze/AnalyzeBaselineTest.php create mode 100644 tests/Unit/Analyze/AnalyzeRunnerTest.php create mode 100644 tests/Unit/Analyze/PatternSelectionCoverageTest.php create mode 100644 tests/Unit/Analyze/PhpFileInspectorTest.php diff --git a/docs/patterns-recall.md b/docs/patterns-recall.md new file mode 100644 index 0000000..8f12548 --- /dev/null +++ b/docs/patterns-recall.md @@ -0,0 +1,32 @@ +# Patterns engine — recall log + +Two halves to "does it catch what my contractor breaks?": + +1. **Selection** (automated, deterministic, subscription-free) — does the matcher + *attach* the right pattern to the right file? Covered by + `tests/Unit/Analyze/PatternSelectionCoverageTest.php`. +2. **Recall** (manual) — does the subagent, given the right patterns, actually + *catch* the smell? This cannot run in CI: judgement runs on the Claude Code + subscription (no metered API, no `claude -p`). So it is measured by a human + running `/codeguard-review` against known-bad fixtures and recording the result + here. Refresh on demand (after corpus changes, prompt changes, or a model bump). + +## How to refresh + +1. Create/keep a small project with one known violation file per pattern under test. +2. Run `/codeguard-review` (or `php artisan codeguard:analyze --emit …` + subagents + + `--ingest …`) over them. +3. Record caught / missed / false-positive below, with the model + date. + +## Last run + +> Not yet measured. Fill in after the first real `/codeguard-review` field run. + +| Pattern | Known-bad fixture | Caught? | Notes | +|---|---|:---:|---| +| no-god-object | _tbd_ | _tbd_ | | +| service-layer | _tbd_ | _tbd_ | | +| no-logic-in-blade | _tbd_ | _tbd_ | | +| … | | | | + +**Model:** _tbd_ · **Date:** _tbd_ · **False positives observed:** _tbd_ diff --git a/src/Analyze/AnalyzeBaseline.php b/src/Analyze/AnalyzeBaseline.php new file mode 100644 index 0000000..f280de0 --- /dev/null +++ b/src/Analyze/AnalyzeBaseline.php @@ -0,0 +1,112 @@ +|null */ + private ?array $cache = null; + + public function __construct( + private readonly Filesystem $filesystem, + private readonly string $path, + private readonly string $workingDirectory, + ) {} + + public function isAccepted(PatternMatch $match): bool + { + return isset($this->fingerprints()[$this->fingerprint($match)]); + } + + /** + * Add the given findings' fingerprints to the baseline. + * + * @param list $matches + * @return int number of newly-accepted findings + */ + public function accept(array $matches): int + { + $fingerprints = $this->fingerprints(); + $added = 0; + + foreach ($matches as $match) { + $fingerprint = $this->fingerprint($match); + if (! isset($fingerprints[$fingerprint])) { + $fingerprints[$fingerprint] = true; + $added++; + } + } + + $keys = array_keys($fingerprints); + sort($keys); + + $this->filesystem->ensureDirectoryExists(dirname($this->path)); + $this->filesystem->put( + $this->path, + (json_encode(['fingerprints' => $keys], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: '{}')."\n", + ); + + $this->cache = $fingerprints; + + return $added; + } + + public function fingerprint(PatternMatch $match): string + { + return sha1($match->patternKey.'|'.$this->relative($match->file)); + } + + /** + * @return array + */ + private function fingerprints(): array + { + if ($this->cache !== null) { + return $this->cache; + } + + if (! $this->filesystem->exists($this->path)) { + return $this->cache = []; + } + + $decoded = json_decode($this->filesystem->get($this->path), true); + $list = (is_array($decoded) && is_array($decoded['fingerprints'] ?? null)) ? $decoded['fingerprints'] : []; + + $set = []; + foreach ($list as $fingerprint) { + if (is_string($fingerprint)) { + $set[$fingerprint] = true; + } + } + + return $this->cache = $set; + } + + private function relative(string $absolute): string + { + $prefix = rtrim($this->workingDirectory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + $relative = str_starts_with($absolute, $prefix) + ? substr($absolute, strlen($prefix)) + : $absolute; + + return str_replace('\\', '/', $relative); + } +} diff --git a/src/Analyze/AnalyzeResult.php b/src/Analyze/AnalyzeResult.php index 0e6b563..66b6318 100644 --- a/src/Analyze/AnalyzeResult.php +++ b/src/Analyze/AnalyzeResult.php @@ -21,6 +21,7 @@ public function __construct( public array $matches, public int $durationMs, public bool $adjudicated = true, + public int $suppressedCount = 0, ) {} /** diff --git a/src/Analyze/AnalyzeRunner.php b/src/Analyze/AnalyzeRunner.php index 67cb85a..21334cb 100644 --- a/src/Analyze/AnalyzeRunner.php +++ b/src/Analyze/AnalyzeRunner.php @@ -29,6 +29,7 @@ public function __construct( private readonly PatternRepository $patterns, private readonly PatternMatcher $matcher, private readonly LlmClient $llm, + private readonly AnalyzeBaseline $baseline, private readonly string $systemPromptPath, ) {} @@ -142,17 +143,28 @@ private function units(array $files, array $presets): array } /** + * Attribute a finding to its unit. Exact absolute-path first (the subagent + * echoes the path it was given); basename only as a fallback AND only when + * unambiguous — otherwise two `User.php` in different dirs would silently + * cross-attribute, which reads as a hallucination and poisons trust. + * * @param list $units */ private function findUnit(array $units, string $file): ?AnalysisUnit { foreach ($units as $unit) { - if ($unit->file === $file || basename($unit->file) === basename($file)) { + if ($unit->file === $file) { return $unit; } } - return null; + $base = basename($file); + $candidates = array_values(array_filter( + $units, + static fn (AnalysisUnit $unit): bool => basename($unit->file) === $base, + )); + + return count($candidates) === 1 ? $candidates[0] : null; } /** @@ -160,13 +172,25 @@ private function findUnit(array $units, string $file): ?AnalysisUnit */ private function finish(array $matches, int $checks, float $start, ?Severity $failOn, bool $adjudicated): AnalyzeResult { + $fresh = []; + $suppressed = 0; + foreach ($matches as $match) { + if ($this->baseline->isAccepted($match)) { + $suppressed++; + + continue; + } + $fresh[] = $match; + } + $durationMs = (int) round((hrtime(true) - $start) / 1_000_000); $result = new AnalyzeResult( patternsChecked: $checks, - matches: $matches, + matches: $fresh, durationMs: $durationMs, adjudicated: $adjudicated, + suppressedCount: $suppressed, ); $this->recorder->record( @@ -175,7 +199,7 @@ private function finish(array $matches, int $checks, float $start, ?Severity $fa durationMs: $durationMs, extras: [ 'patterns_checked_count' => $checks, - 'matches_count' => count($matches), + 'matches_count' => count($fresh), ], ); diff --git a/src/Analyze/PatternMatcher.php b/src/Analyze/PatternMatcher.php index f635f20..3b7ae57 100644 --- a/src/Analyze/PatternMatcher.php +++ b/src/Analyze/PatternMatcher.php @@ -9,13 +9,33 @@ * producing {@see AnalysisUnit}s. Only files matched by at least one pattern * become units — so the LLM only ever sees relevant code. * - * Signal handling (MVP): - * - file → glob (supports `**`, `*`, `?`, `{a,b}`) against the relative path + * Signal handling: + * - file → glob (`**`, `*`, `?`, `{a,b}`) against the relative path * - directory → relative-path prefix - * - import → namespace glob approximated as a PSR-4 path prefix + * - import → matches the file's ACTUAL `use` imports (via {@see PhpFileInspector}). + * A value of `**`/`**​/*`/`*` means "any import" — i.e. a whole-project + * architectural signal that a per-file pass cannot satisfy; those + * patterns are therefore NOT selected until a cross-file namespace + * graph exists (design doc R3). + * + * Class-structure patterns (god-object, single-responsibility, …) are gated to + * files that actually declare a class, so config arrays, route files and + * pure-function helpers are not pointlessly reviewed for them. */ final class PatternMatcher { + /** + * Patterns that only make sense for a file declaring a class. + * + * @var list + */ + private const CLASS_STRUCTURE_PATTERNS = [ + 'no-god-object', + 'single-responsibility', + 'no-deep-inheritance', + 'no-constructor-many-params', + ]; + public function __construct(private readonly string $workingDirectory) {} /** @@ -29,10 +49,19 @@ public function match(array $files, array $patterns): array foreach ($files as $file) { $relative = $this->relative($file); + $contents = is_file($file) ? (file_get_contents($file) ?: '') : ''; + $imports = PhpFileInspector::imports($contents); + $declaresClass = PhpFileInspector::declaresClass($contents); $matched = array_values(array_filter( $patterns, - fn (Pattern $pattern): bool => $this->patternApplies($pattern, $relative), + function (Pattern $pattern) use ($relative, $imports, $declaresClass): bool { + if (! $declaresClass && in_array($pattern->key, self::CLASS_STRUCTURE_PATTERNS, true)) { + return false; + } + + return $this->patternApplies($pattern, $relative, $imports); + }, )); if ($matched !== []) { @@ -43,10 +72,13 @@ public function match(array $files, array $patterns): array return $units; } - private function patternApplies(Pattern $pattern, string $relativePath): bool + /** + * @param list $imports + */ + private function patternApplies(Pattern $pattern, string $relativePath, array $imports): bool { foreach ($pattern->detectionSignals as $signal) { - if ($this->signalMatches($signal, $relativePath)) { + if ($this->signalMatches($signal, $relativePath, $imports)) { return true; } } @@ -54,16 +86,52 @@ private function patternApplies(Pattern $pattern, string $relativePath): bool return false; } - private function signalMatches(DetectionSignal $signal, string $relativePath): bool + /** + * @param list $imports + */ + private function signalMatches(DetectionSignal $signal, string $relativePath, array $imports): bool { return match ($signal->type) { 'file' => preg_match($this->globToRegex($signal->value), $relativePath) === 1, 'directory' => str_starts_with($relativePath, rtrim($signal->value, '/').'/'), - 'import' => str_starts_with($relativePath, $this->importToPathPrefix($signal->value)), + 'import' => $this->importMatches($imports, $signal->value), default => false, }; } + /** + * @param list $imports + */ + private function importMatches(array $imports, string $value): bool + { + // Catch-all import = whole-project architectural signal; a per-file pass + // cannot judge it without the cross-file graph (R3), so it selects nothing. + if ($value === '*' || $value === '**' || $value === '**/*') { + return false; + } + + if (str_ends_with($value, '\\*')) { + $prefix = substr($value, 0, -1); // "App\Services\" + foreach ($imports as $fqcn) { + if (str_starts_with($fqcn, $prefix)) { + return true; + } + } + + return false; + } + + $exact = ltrim($value, '\\'); + $prefix = rtrim($exact, '\\').'\\'; + foreach ($imports as $fqcn) { + if ($fqcn === $exact || str_starts_with($fqcn, $prefix)) { + return true; + } + } + + return false; + } + private function globToRegex(string $glob): string { // `**/` matches zero or more directories. @@ -87,15 +155,6 @@ private function globToRegex(string $glob): string return '#^'.$regex.'$#'; } - private function importToPathPrefix(string $namespaceGlob): string - { - $path = str_replace('\\', '/', rtrim($namespaceGlob, '\\*')); - $segments = explode('/', $path, 2); - $segments[0] = lcfirst($segments[0]); - - return rtrim(implode('/', $segments), '/').'/'; - } - private function relative(string $absolute): string { $prefix = rtrim($this->workingDirectory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; diff --git a/src/Analyze/PhpFileInspector.php b/src/Analyze/PhpFileInspector.php new file mode 100644 index 0000000..093dde1 --- /dev/null +++ b/src/Analyze/PhpFileInspector.php @@ -0,0 +1,93 @@ + + */ + public static function imports(string $contents): array + { + if (trim($contents) === '') { + return []; + } + + $head = $contents; + if (preg_match('/\b(?:class|interface|trait|enum)\s+\w/i', $contents, $m, PREG_OFFSET_CAPTURE) === 1) { + $head = substr($contents, 0, (int) $m[0][1]); + } + + if (preg_match_all('/^[ \t]*use[ \t]+(?!function[ \t]|const[ \t])([^;]+);/mi', $head, $matches) === false) { + return []; + } + + $imports = []; + foreach ($matches[1] as $clause) { + foreach (self::expandUseClause($clause) as $fqcn) { + $imports[] = $fqcn; + } + } + + return array_values(array_unique($imports)); + } + + /** + * Whether the file declares a class / trait / enum (not merely an interface + * or a bag of free functions) — the floor for class-structure patterns. + */ + public static function declaresClass(string $contents): bool + { + return preg_match('/\b(?:class|trait|enum)\s+\w/i', $contents) === 1; + } + + /** + * @return list + */ + private static function expandUseClause(string $clause): array + { + $clause = trim($clause); + + // Group use: Prefix\{A, B as C, D\E} + if (preg_match('/^(.+?)\\\\\{(.+)\}$/s', $clause, $m) === 1) { + $prefix = trim($m[1]); + + return array_values(array_filter( + array_map( + static fn (string $part): string => ltrim($prefix.'\\'.self::stripAlias($part), '\\'), + explode(',', $m[2]), + ), + static fn (string $name): bool => $name !== '' && ! str_ends_with($name, '\\'), + )); + } + + return array_values(array_filter( + array_map( + static fn (string $part): string => ltrim(self::stripAlias($part), '\\'), + explode(',', $clause), + ), + static fn (string $name): bool => $name !== '', + )); + } + + private static function stripAlias(string $segment): string + { + return trim(preg_replace('/\s+as\s+\w+\s*$/i', '', trim($segment)) ?? $segment); + } +} diff --git a/src/CodeguardServiceProvider.php b/src/CodeguardServiceProvider.php index b2e90a8..a21c605 100644 --- a/src/CodeguardServiceProvider.php +++ b/src/CodeguardServiceProvider.php @@ -4,6 +4,7 @@ namespace Henryavila\Codeguard; +use Henryavila\Codeguard\Analyze\AnalyzeBaseline; use Henryavila\Codeguard\Analyze\AnalyzeRunner; use Henryavila\Codeguard\Analyze\Drivers\NullLlmClient; use Henryavila\Codeguard\Analyze\FileScopeResolver; @@ -336,6 +337,24 @@ private function registerAnalyzeServices(): void // increment behind this same LlmClient seam. $this->app->singleton(LlmClient::class, NullLlmClient::class); + $this->app->singleton(AnalyzeBaseline::class, static function (Application $app): AnalyzeBaseline { + /** @var Filesystem $filesystem */ + $filesystem = $app->make(Filesystem::class); + + /** @var CodeguardConfig $config */ + $config = $app->make(CodeguardConfig::class); + + $path = $config->baselinePath !== '' + ? $config->baselinePath + : $app->basePath('.codeguard'.DIRECTORY_SEPARATOR.'analyze-baseline.json'); + + return new AnalyzeBaseline( + filesystem: $filesystem, + path: $path, + workingDirectory: $app->basePath(), + ); + }); + $this->app->singleton(FileScopeResolver::class, static function (Application $app): FileScopeResolver { return new FileScopeResolver( executor: $app->make(CommandExecutor::class), @@ -353,6 +372,7 @@ private function registerAnalyzeServices(): void patterns: $app->make(PatternRepository::class), matcher: $app->make(PatternMatcher::class), llm: $app->make(LlmClient::class), + baseline: $app->make(AnalyzeBaseline::class), systemPromptPath: $systemPromptPath, ); }); diff --git a/src/Commands/CodeguardAnalyzeCommand.php b/src/Commands/CodeguardAnalyzeCommand.php index b865881..9fb2bb7 100644 --- a/src/Commands/CodeguardAnalyzeCommand.php +++ b/src/Commands/CodeguardAnalyzeCommand.php @@ -4,6 +4,7 @@ namespace Henryavila\Codeguard\Commands; +use Henryavila\Codeguard\Analyze\AnalyzeBaseline; use Henryavila\Codeguard\Analyze\AnalyzeResult; use Henryavila\Codeguard\Analyze\AnalyzeRunner; use Henryavila\Codeguard\Analyze\FileScopeResolver; @@ -24,7 +25,8 @@ final class CodeguardAnalyzeCommand extends Command {--context=manual : Telemetry context — pre-commit|pre-push|ci|manual.} {--emit : Write a work order JSON (for the codeguard-review Claude skill) instead of calling an LLM.} {--ingest= : Validate + report findings from this JSON file (produced out-of-band by the skill).} - {--out= : Output path for --emit (default .codeguard/analyze-request.json).}'; + {--out= : Output path for --emit (default .codeguard/analyze-request.json).} + {--accept : Accept the surviving findings into the baseline so future runs suppress them.}'; protected $description = 'Run pattern-based review over scoped files and report findings.'; @@ -35,6 +37,7 @@ public function handle( AnalyzeRunner $runner, FileScopeResolver $scope, Recorder $recorder, + AnalyzeBaseline $baseline, ): int { if ((bool) $this->option('emit')) { return $this->handleEmit($config, $runner, $scope); @@ -42,7 +45,7 @@ public function handle( $ingest = $this->option('ingest'); if (is_string($ingest) && $ingest !== '') { - return $this->handleIngest($config, $runner, $scope, $recorder, $ingest); + return $this->handleIngest($config, $runner, $scope, $recorder, $baseline, $ingest); } $context = $this->resolveContext(); @@ -71,6 +74,7 @@ public function handle( return self::SUCCESS; } + $this->maybeAccept($baseline, $result); $this->renderFindings($result); $exitCode = $result->failed($failOn) ? self::FAILURE : self::SUCCESS; @@ -79,6 +83,14 @@ public function handle( return $exitCode; } + private function maybeAccept(AnalyzeBaseline $baseline, AnalyzeResult $result): void + { + if ((bool) $this->option('accept') && $result->matches !== []) { + $added = $baseline->accept($result->matches); + $this->components->info(sprintf('Accepted %d finding(s) into the baseline.', $added)); + } + } + private function handleEmit(CodeguardConfig $config, AnalyzeRunner $runner, FileScopeResolver $scope): int { $files = $this->resolveFiles($scope); @@ -103,6 +115,7 @@ private function handleIngest( AnalyzeRunner $runner, FileScopeResolver $scope, Recorder $recorder, + AnalyzeBaseline $baseline, string $ingestPath, ): int { $failOn = $this->resolveFailOn(); @@ -127,6 +140,7 @@ private function handleIngest( $files = $this->resolveFiles($scope); $result = $runner->ingest($files, $config->enabledPresets, $findings, $failOn); + $this->maybeAccept($baseline, $result); $this->renderFindings($result); $exitCode = $result->failed($failOn) ? self::FAILURE : self::SUCCESS; @@ -192,7 +206,11 @@ private function resolveFailOn(): ?Severity private function renderFindings(AnalyzeResult $result): void { if ($result->matches === []) { - $this->components->info(sprintf('No pattern findings (%d checks).', $result->patternsChecked)); + $this->components->info(sprintf( + 'No pattern findings (%d checks).%s', + $result->patternsChecked, + $this->suppressedSuffix($result), + )); return; } @@ -211,12 +229,20 @@ private function renderFindings(AnalyzeResult $result): void $this->line(''); $this->components->info(sprintf( - '%d finding(s) across %d checks.', + '%d finding(s) across %d checks.%s', count($result->matches), $result->patternsChecked, + $this->suppressedSuffix($result), )); } + private function suppressedSuffix(AnalyzeResult $result): string + { + return $result->suppressedCount > 0 + ? sprintf(' %d suppressed via baseline.', $result->suppressedCount) + : ''; + } + private function glyph(Severity $severity): string { return match ($severity) { diff --git a/tests/Feature/CodeguardAnalyzeCommandTest.php b/tests/Feature/CodeguardAnalyzeCommandTest.php index 6f7a2bd..8a485c6 100644 --- a/tests/Feature/CodeguardAnalyzeCommandTest.php +++ b/tests/Feature/CodeguardAnalyzeCommandTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Henryavila\Codeguard\Analyze\AnalysisUnit; +use Henryavila\Codeguard\Analyze\AnalyzeBaseline; use Henryavila\Codeguard\Analyze\AnalyzeRunner; use Henryavila\Codeguard\Analyze\LlmClient; use Henryavila\Codeguard\Telemetry\ConfigGate; @@ -11,6 +12,7 @@ use Henryavila\Codeguard\Telemetry\Recorder; use Henryavila\Codeguard\Telemetry\Rotator; use Henryavila\Codeguard\Tests\Support\FakeLlmClient; +use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Facades\Artisan; /* @@ -53,7 +55,7 @@ function analyzeCleanup(string $fixtureFile, string $telemetryPath): void } } -function analyzeBind(string $telemetryPath, FakeLlmClient $fake): void +function analyzeBind(string $telemetryPath, FakeLlmClient $fake, ?string $baselinePath = null): void { app()->forgetInstance(Recorder::class); app()->singleton(Recorder::class, fn (): Recorder => new Recorder( @@ -67,7 +69,16 @@ function analyzeBind(string $telemetryPath, FakeLlmClient $fake): void app()->forgetInstance(LlmClient::class); app()->singleton(LlmClient::class, fn (): FakeLlmClient => $fake); - // Force the runner to rebuild with the rebound Recorder + LlmClient. + // Isolate the baseline to a temp path so accept-tests don't touch the skeleton. + $resolvedBaseline = $baselinePath ?? sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-analyze-baseline-'.uniqid().'.json'; + app()->forgetInstance(AnalyzeBaseline::class); + app()->singleton(AnalyzeBaseline::class, fn (): AnalyzeBaseline => new AnalyzeBaseline( + new Filesystem, + $resolvedBaseline, + (string) base_path(), + )); + + // Force the runner to rebuild with the rebound Recorder + LlmClient + baseline. app()->forgetInstance(AnalyzeRunner::class); } @@ -284,3 +295,35 @@ function analyzeReadEvents(string $path): array analyzeCleanup($file, $telemetry); } }); + +it('accepts a finding into the baseline and suppresses it on the next run', function (): void { + $telemetry = analyzeTelemetryPath(); + $baselinePath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-accept-'.uniqid().'.json'; + $file = analyzeFixtureFile(); + $fake = new FakeLlmClient(analyzeFindingHandler('warning')); + analyzeBind($telemetry, $fake, $baselinePath); + + try { + // Run 1: --accept records the finding's fingerprint. + Artisan::call('codeguard:analyze', ['--path' => $file, '--context' => 'ci', '--accept' => true]); + expect(is_file($baselinePath))->toBeTrue(); + + // Run 2: the same finding is now suppressed. + Artisan::call('codeguard:analyze', ['--path' => $file, '--context' => 'ci']); + + $analyzeEnded = array_values(array_filter( + analyzeReadEvents($telemetry), + static fn (array $event): bool => ($event['event'] ?? '') === 'analyze.ended', + )); + $lastRun = end($analyzeEnded); + + expect($analyzeEnded)->toHaveCount(2) + ->and(is_array($lastRun) ? ($lastRun['matches_count'] ?? null) : null)->toBe(0) + ->and($fake->calls)->toHaveCount(2); + } finally { + if (is_file($baselinePath)) { + unlink($baselinePath); + } + analyzeCleanup($file, $telemetry); + } +}); diff --git a/tests/Unit/Analyze/AnalyzeBaselineTest.php b/tests/Unit/Analyze/AnalyzeBaselineTest.php new file mode 100644 index 0000000..b52a043 --- /dev/null +++ b/tests/Unit/Analyze/AnalyzeBaselineTest.php @@ -0,0 +1,79 @@ +isAccepted($match))->toBeFalse() + ->and($baseline->accept([$match]))->toBe(1) + ->and($baseline->isAccepted($match))->toBeTrue(); + + // A fresh instance must read the persisted fingerprint. + $reloaded = new AnalyzeBaseline(new Filesystem, $path, '/work'); + expect($reloaded->isAccepted($match))->toBeTrue(); + } finally { + if (is_file($path)) { + unlink($path); + } + } +}); + +it('fingerprints on pattern + relative file only — independent of message and line', function (): void { + $path = ablPath(); + + try { + $baseline = new AnalyzeBaseline(new Filesystem, $path, '/work'); + $baseline->accept([ablMatch('no-god-object', '/work/app/Foo.php', 10, 'phrasing A', Severity::Warning)]); + + // Same pattern + file, different line + message + severity → still suppressed. + $rephrased = ablMatch('no-god-object', '/work/app/Foo.php', 99, 'completely different phrasing', Severity::Critical); + // Different pattern, same file → not suppressed. + $otherPattern = ablMatch('dry', '/work/app/Foo.php', 10, 'phrasing A', Severity::Warning); + // Same pattern, different file → not suppressed. + $otherFile = ablMatch('no-god-object', '/work/app/Bar.php', 10, 'phrasing A', Severity::Warning); + + expect($baseline->isAccepted($rephrased))->toBeTrue() + ->and($baseline->isAccepted($otherPattern))->toBeFalse() + ->and($baseline->isAccepted($otherFile))->toBeFalse(); + } finally { + if (is_file($path)) { + unlink($path); + } + } +}); + +it('does not double-count an already-accepted finding', function (): void { + $path = ablPath(); + + try { + $baseline = new AnalyzeBaseline(new Filesystem, $path, '/work'); + $match = ablMatch('dry', '/work/app/Foo.php', 1, 'm', Severity::Warning); + + expect($baseline->accept([$match]))->toBe(1) + ->and($baseline->accept([$match]))->toBe(0); + } finally { + if (is_file($path)) { + unlink($path); + } + } +}); diff --git a/tests/Unit/Analyze/AnalyzeRunnerTest.php b/tests/Unit/Analyze/AnalyzeRunnerTest.php new file mode 100644 index 0000000..1011f02 --- /dev/null +++ b/tests/Unit/Analyze/AnalyzeRunnerTest.php @@ -0,0 +1,100 @@ + $presets + * @return list + */ + public function forPresets(array $presets): array + { + return [Pattern::fromArray('p', [ + 'detection' => ['signals' => [['type' => 'file', 'value' => '**/*.php']]], + 'verification' => ['rules' => ['r']], + 'examples' => ['correct' => '', 'violation' => ''], + 'severity' => 'warning', + ])]; + } + + public function has(string $key): bool + { + return $key === 'p'; + } + }; + + // ConfigGate(false) => telemetry disabled => Recorder does zero I/O. + $recorder = new Recorder( + new ConfigGate(enabled: false), + new FieldAllowlist(strictMode: true), + new Rotator, + new JsonlWriter, + sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-arn.jsonl', + ); + + $baseline = new AnalyzeBaseline( + new Filesystem, + sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-arn-baseline-'.uniqid().'.json', + '/work', + ); + + return new AnalyzeRunner($recorder, $repo, new PatternMatcher('/work'), new NullLlmClient, $baseline, '/nonexistent-prompt.md'); +} + +/** + * @param array $overrides + * @return array + */ +function arnFinding(array $overrides = []): array +{ + return array_merge([ + 'pattern_key' => 'p', + 'file' => '/work/app/DTOs/User.php', + 'line' => 1, + 'message' => 'm', + 'severity' => 'warning', + 'confidence' => 0.9, + ], $overrides); +} + +it('attributes an ingested finding to the exact-path unit, not a basename twin', function (): void { + $files = ['/work/app/Models/User.php', '/work/app/DTOs/User.php']; + + $result = arnRunner()->ingest($files, ['core'], [arnFinding()], Severity::Warning); + + expect($result->matchesCount())->toBe(1) + ->and($result->matches[0]->file)->toBe('/work/app/DTOs/User.php'); +}); + +it('drops a finding whose file is an ambiguous basename with no exact match', function (): void { + $files = ['/work/app/Models/User.php', '/work/app/DTOs/User.php']; + + $result = arnRunner()->ingest($files, ['core'], [arnFinding(['file' => 'User.php'])], Severity::Warning); + + expect($result->matchesCount())->toBe(0); +}); + +it('builds a work order with one unit per matched file', function (): void { + $order = arnRunner()->buildWorkOrder(['/work/app/Foo.php'], ['core']); + + expect($order['units'])->toHaveCount(1) + ->and($order['units'][0]['file'])->toBe('/work/app/Foo.php') + ->and($order['system_prompt'])->toBeString(); +}); diff --git a/tests/Unit/Analyze/PatternMatcherTest.php b/tests/Unit/Analyze/PatternMatcherTest.php index 8db4153..6232874 100644 --- a/tests/Unit/Analyze/PatternMatcherTest.php +++ b/tests/Unit/Analyze/PatternMatcherTest.php @@ -18,20 +18,52 @@ function pmPattern(string $key, array $signals): Pattern ]); } +function pmBase(): string +{ + return sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-pm-'.uniqid(); +} + +function pmWriteFile(string $base, string $relative, string $contents): void +{ + $path = $base.DIRECTORY_SEPARATOR.$relative; + $dir = dirname($path); + if (! is_dir($dir)) { + mkdir($dir, 0o755, true); + } + file_put_contents($path, $contents); +} + +function pmCleanup(string $base): void +{ + if (! is_dir($base)) { + return; + } + $items = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($items as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($base); +} + +// ── path-based signals (no file read needed) ─────────────────────── + it('matches a file-glob signal and skips non-matching files', function (): void { $matcher = new PatternMatcher('/work'); - $pattern = pmPattern('p', [['type' => 'file', 'value' => '**/*.php']]); + $pattern = pmPattern('dry', [['type' => 'file', 'value' => '**/*.php']]); $units = $matcher->match(['/work/app/Foo.php', '/work/app/Bar.txt'], [$pattern]); expect($units)->toHaveCount(1) ->and($units[0]->file)->toBe('/work/app/Foo.php') - ->and($units[0]->patternKeys())->toBe(['p']); + ->and($units[0]->patternKeys())->toBe(['dry']); }); it('matches brace-expansion globs at the repo root too', function (): void { $matcher = new PatternMatcher('/work'); - $pattern = pmPattern('p', [['type' => 'file', 'value' => '**/*.{php,ts}']]); + $pattern = pmPattern('dry', [['type' => 'file', 'value' => '**/*.{php,ts}']]); $units = $matcher->match(['/work/x/Foo.ts', '/work/Root.php', '/work/x/Foo.py'], [$pattern]); @@ -40,7 +72,7 @@ function pmPattern(string $key, array $signals): Pattern it('matches a directory signal by path prefix', function (): void { $matcher = new PatternMatcher('/work'); - $pattern = pmPattern('p', [['type' => 'directory', 'value' => 'app/Services']]); + $pattern = pmPattern('dry', [['type' => 'directory', 'value' => 'app/Services']]); $units = $matcher->match(['/work/app/Services/OrderService.php', '/work/app/Models/User.php'], [$pattern]); @@ -48,19 +80,66 @@ function pmPattern(string $key, array $signals): Pattern ->and($units[0]->file)->toBe('/work/app/Services/OrderService.php'); }); -it('matches an import signal as a PSR-4 path prefix', function (): void { +it('produces no unit when nothing matches', function (): void { $matcher = new PatternMatcher('/work'); - $pattern = pmPattern('p', [['type' => 'import', 'value' => 'App\\Services\\*']]); + $pattern = pmPattern('dry', [['type' => 'directory', 'value' => 'app/Nope']]); - $units = $matcher->match(['/work/app/Services/Foo.php', '/work/app/Http/Bar.php'], [$pattern]); + expect($matcher->match(['/work/app/Foo.php'], [$pattern]))->toBe([]); +}); - expect($units)->toHaveCount(1) - ->and($units[0]->file)->toBe('/work/app/Services/Foo.php'); +// ── import signals (real `use` parsing) + guards ─────────────────── + +it('matches an import signal against the file actual use statements', function (): void { + $base = pmBase(); + + try { + pmWriteFile($base, 'app/Http/OrderController.php', " 'import', 'value' => 'App\\Services\\*']]); + $files = [$base.'/app/Http/OrderController.php', $base.'/app/Http/PlainController.php']; + + $units = $matcher->match($files, [$pattern]); + + expect($units)->toHaveCount(1) + ->and(basename($units[0]->file))->toBe('OrderController.php'); + } finally { + pmCleanup($base); + } }); -it('produces no unit when nothing matches', function (): void { - $matcher = new PatternMatcher('/work'); - $pattern = pmPattern('p', [['type' => 'directory', 'value' => 'app/Nope']]); +it('gates class-structure patterns to files that declare a class', function (): void { + $base = pmBase(); - expect($matcher->match(['/work/app/Foo.php'], [$pattern]))->toBe([]); + try { + pmWriteFile($base, 'app/Foo.php', " 1];\n"); + + $matcher = new PatternMatcher($base); + $pattern = pmPattern('no-god-object', [['type' => 'file', 'value' => '**/*.php']]); + $files = [$base.'/app/Foo.php', $base.'/config/app.php']; + + $units = $matcher->match($files, [$pattern]); + + expect($units)->toHaveCount(1) + ->and(basename($units[0]->file))->toBe('Foo.php'); + } finally { + pmCleanup($base); + } +}); + +it('does not select architectural patterns whose only signal is the catch-all import', function (): void { + $base = pmBase(); + + try { + pmWriteFile($base, 'app/Foo.php', " 'import', 'value' => '**/*']]); + + expect($matcher->match([$base.'/app/Foo.php'], [$pattern]))->toBe([]); + } finally { + pmCleanup($base); + } }); diff --git a/tests/Unit/Analyze/PatternSelectionCoverageTest.php b/tests/Unit/Analyze/PatternSelectionCoverageTest.php new file mode 100644 index 0000000..4748305 --- /dev/null +++ b/tests/Unit/Analyze/PatternSelectionCoverageTest.php @@ -0,0 +1,91 @@ +isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($base); +} + +it('attaches the right patterns to the right files from the real corpus', function (): void { + $base = sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-psc-'.uniqid(); + pscWrite($base, 'app/Http/Controllers/OrderController.php', " 'codeguard'];\n"); + pscWrite($base, 'resources/views/home.blade.php', "
{{ \$value }}
\n"); + + try { + $patterns = (new YamlPatternLoader(new Filesystem, pscPatternsPath())) + ->forPresets(['core', 'php', 'php-laravel']); + $matcher = new PatternMatcher($base); + + $units = $matcher->match([ + $base.'/app/Http/Controllers/OrderController.php', + $base.'/app/Models/User.php', + $base.'/config/app.php', + $base.'/resources/views/home.blade.php', + ], $patterns); + + $byFile = []; + foreach ($units as $unit) { + $byFile[basename($unit->file)] = $unit->patternKeys(); + } + + // Controller importing a service → service-layer is selected (real `use` parsing). + expect($byFile['OrderController.php'] ?? [])->toContain('service-layer'); + + // A model class → universal structure patterns, but NOT the service-layer + // pattern (it neither lives in app/Services nor imports one). + expect($byFile['User.php'] ?? [])->toContain('no-god-object') + ->and(in_array('service-layer', $byFile['User.php'] ?? [], true))->toBeFalse(); + + // A class-less config array → class-structure patterns are gated out. + expect(in_array('no-god-object', $byFile['config/app.php'] ?? $byFile['app.php'] ?? [], true))->toBeFalse(); + + // A Blade view → the blade-specific pattern is selected. + expect($byFile['home.blade.php'] ?? [])->toContain('no-logic-in-blade'); + } finally { + pscCleanup($base); + } +}); diff --git a/tests/Unit/Analyze/PhpFileInspectorTest.php b/tests/Unit/Analyze/PhpFileInspectorTest.php new file mode 100644 index 0000000..5ffddad --- /dev/null +++ b/tests/Unit/Analyze/PhpFileInspectorTest.php @@ -0,0 +1,51 @@ +toBe(['App\\Services\\OrderService']); +}); + +it('resolves aliases to the FQCN, ignoring the alias', function (): void { + $src = "toBe(['App\\Services\\OrderService']); +}); + +it('expands group use into individual FQCNs', function (): void { + $src = "toBe([ + 'App\\Services\\OrderService', + 'App\\Services\\Billing\\InvoiceService', + ]); +}); + +it('ignores use function / use const', function (): void { + $src = "toBe(['App\\Real\\Klass']); +}); + +it('ignores trait use inside a class body', function (): void { + $src = "toBe(['App\\Real\\Imported']); +}); + +it('ignores closure use', function (): void { + $src = "toBe(['App\\Real\\Imported']); +}); + +it('detects class-like declarations for the structure guard', function (): void { + expect(PhpFileInspector::declaresClass("toBeTrue() + ->and(PhpFileInspector::declaresClass("toBeTrue() + ->and(PhpFileInspector::declaresClass(" 1];"))->toBeFalse() + ->and(PhpFileInspector::declaresClass("toBeFalse(); +}); From e258e581ab7e1cc46ae41c068fac391aee6bee17 Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 14:47:14 -0300 Subject: [PATCH 08/21] docs(memory): trust threshold (Tier 0+1) shipped + session handoff + Tier 2 plan --- .ai/memory/PROJECT-STATUS.md | 19 +++++++----- .ai/memory/SESSION-HANDOFF.md | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 .ai/memory/SESSION-HANDOFF.md diff --git a/.ai/memory/PROJECT-STATUS.md b/.ai/memory/PROJECT-STATUS.md index 72a2ba9..c11fe60 100644 --- a/.ai/memory/PROJECT-STATUS.md +++ b/.ai/memory/PROJECT-STATUS.md @@ -9,9 +9,9 @@ type: project > **Para Claude**: Este é o documento vivo de estado. Leia na primeira ferramenta-call de toda sessão substantiva. Atualize ao completar qualquer commit que mude escopo, ou ao mudar de sprint/foco. Em caso de conflito com outro arquivo de memória, este ganha (pra resolver drift, corrija o outro arquivo, não aqui). **Última atualização**: 2026-06-03 (audit + replan + **Fase 1 traits** e **Fase 2 Patterns engine MVP** shippados na mesma sessão) -**HEAD**: `18c4492` feat(analyze): context-emit driver — --emit/--ingest + codeguard-review skill -**Branch**: `feat/patterns-engine-foundation` (pushed; **PR #1** aberto pra `main`). `origin/main` == `4b32886`. -**Suite**: 435 tests / 1053 assertions (verde). Pint clean, PHPStan level 5 No errors. Coverage gate (≥80%) só roda no CI (sem driver Xdebug/pcov local). +**HEAD**: `abfce20` feat(analyze): trust threshold — exact-path attribution, real use-parsing, baseline +**Branch**: `feat/patterns-engine-foundation` (**PR #1** aberto pra `main`; **precisa push** do commit `abfce20`). `origin/main` == `4b32886`. +**Suite**: 452 tests / 1090 assertions (verde). Pint clean, PHPStan level 5 No errors. Coverage gate (≥80%) só roda no CI (sem driver Xdebug/pcov local). **Lint/Static**: Pint clean. PHPStan level 5 self-applied com baseline grandfathered (`156b297`) — R8 fechado. `composer ci` roda pint:test + phpstan + test:coverage; CI ativa em PHP 8.3 + 8.4 via `.github/workflows/ci.yml` (`65893ab`). **Release publicado**: ✅ **`0.2.0` no Packagist desde 2026-05-04** (tag `0.2.0` @ `4b32886`, pushed). Arch consome via repo `vcs` GitHub pinado em `^0.2.0` (lock @ `4b32886`) — **NÃO** via path repo nem `dev-main`. @@ -48,11 +48,13 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. ### Próxima ação concreta -🔜 **Validar o fluxo de review real**: rodar `/codeguard-review` num projeto (emit → subagentes em lote → ingest) e confirmar que findings reais aparecem + são validados pelo trust boundary. (Decisão de transporte RESOLVIDA: context-emit, `claude -p` fora porque vai virar API metered no próximo mês.) +1. **`git push`** o commit `abfce20` (não pushed; atualiza o PR #1). +2. **Validar em campo**: rodar `/codeguard-review` num projeto real (emit → subagentes em lote → ingest) e preencher `docs/patterns-recall.md` (o lado "será que pega o smell?" NÃO é testável em CI — só com sessão Claude Code real). +3. **Tier 2 — profundidade** (torna "genuinamente alto valor", ~10d, validado à mão): R1 voting multi-sample (deriva confiança de vote-share) → R2 critique pass → R3 grafo namespace→layer (liga de verdade os 3 patterns arquiteturais) → R4 corpus de alto impacto p/ terceirizado (N+1, mass-assignment, missing transaction, SQL cru, missing authz, `->get()` sem limite). Detalhe completo: ver handoff + roadmap abaixo. -**Decisão do usuário**: revisar/mergear **PR #1** (Fase 1 + Fase 2 MVP). O Increment D (`18c4492`) está no mesmo branch — entra no mesmo PR. +**Decisão do usuário pendente**: revisar/mergear **PR #1** (Fases 1+2 + Increment D + trust threshold, tudo no mesmo branch). -**Backlog package-side (sem bloquear)**: `coverage_percent -1` em `CodeguardTestCommand.php:102`; config morto `ai_rules`/`prepare` (o bloco `patterns` agora É consumido); Fase 3 (schema dump + ai-rules generator). +**Backlog package-side (sem bloquear)**: `coverage_percent -1` em `CodeguardTestCommand.php:102`; config morto `ai_rules`/`prepare`; Fase 3 (schema dump + ai-rules generator); re-scope conservador dos patterns Laravel "invertidos" (precisa validação de campo — adiado por risco de FP). --- @@ -102,7 +104,7 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. | Perspectiva | Real | Justificativa | |---|:-:|---| | "install + rodar gates + rodar tests" | **~80%** | Commands reais, installer ~900 LOC, telemetria completa, 377 tests verdes. Descontado: traits lançam exception, `coverage_percent -1`, e o único consumer **não roda** check/test. | -| "pattern-based LLM review" (o diferencial) | **~80%** | Engine + driver context-emit + skill codeguard-review shippados (37 tests). Funcionalmente completo pro modelo de assinatura. Falta só validação em campo real + polish (batch tuning). NÃO é gate CI autônomo — por design (CI = codeguard:check AST). | +| "pattern-based LLM review" (o diferencial) | **~70%** | Camada determinística sólida + testada: seleção (use-parsing real), atribuição exata, baseline/supressão, scope-coverage test. Falta: **validação de campo** (recall manual, não testável em CI) + **Tier 2** (voting/critique/grafo arquitetural/corpus de segurança) que separa "confiável" de "alto valor". (Antes superestimei em ~80% — corrigido pelo audit de completude.) | | "AI rules generator" | **~3%** | duplo-morto: `src/AiRules/` ausente + `resources/rules/` vazia | | "schema dump multi-DB" | **~8%** | só `PrepareConfig` DTO | | "publicar/distribuir" | **~85%** | genuinamente no Packagist, tagged, lockável, Node-free. Descontado: footprint `.codeguard/` é git-ignored (não cruza máquinas), 0 downloads, único consumer bypassa a CLI | @@ -145,7 +147,8 @@ Audit multi-agente verificou cada alegação contra git+FS. Corrigido neste arqu - **Spec canônico v5 arquitetural**: [`docs/specs/2026-04-16-codeguard-v2-architecture.md`](../../docs/specs/2026-04-16-codeguard-v2-architecture.md) - **Spec CaptainHook + Telemetry**: [`docs/specs/2026-04-17-captainhook-migration-and-telemetry.md`](../../docs/specs/2026-04-17-captainhook-migration-and-telemetry.md) - **Pivot npm→Composer**: [`docs/specs/2026-04-16-pivot-npm-to-composer.md`](../../docs/specs/2026-04-16-pivot-npm-to-composer.md) -- **Design doc Patterns engine**: [`docs/specs/2026-06-03-patterns-engine-design.md`](../../docs/specs/2026-06-03-patterns-engine-design.md) — Thin Adjudicator (MVP 4.5d + driver 1.5d); Fork 4 (transporte LLM) aberto +- **▶ Handoff da sessão atual**: [`SESSION-HANDOFF.md`](SESSION-HANDOFF.md) — narrativa + plano Tier 2; **ler pra continuar** +- **Design doc Patterns engine**: [`docs/specs/2026-06-03-patterns-engine-design.md`](../../docs/specs/2026-06-03-patterns-engine-design.md) — Thin Adjudicator. Transporte = context-emit (Fork 4 resolvido) - **ADRs**: [`.ai/memory/architecture-decisions.md`](architecture-decisions.md) - **Open questions**: [`.ai/memory/open-questions.md`](open-questions.md) - **Conversation handoff**: [`.ai/memory/conversation-handoff.md`](conversation-handoff.md) — ⚠️ stale (sessão 7) diff --git a/.ai/memory/SESSION-HANDOFF.md b/.ai/memory/SESSION-HANDOFF.md new file mode 100644 index 0000000..ada1dd4 --- /dev/null +++ b/.ai/memory/SESSION-HANDOFF.md @@ -0,0 +1,58 @@ +--- +name: Session handoff (2026-06-03) +description: Onde paramos e como continuar o Patterns engine numa sessão nova. Ler DEPOIS do PROJECT-STATUS.md. +type: project +--- + +# Handoff — 2026-06-03 (sessão "audit → replan → Patterns engine") + +> Leia `PROJECT-STATUS.md` primeiro (estado canônico). Este arquivo é o **plano + narrativa** desta sessão pra continuar sem perder contexto. + +## O que rolou (de "repo parou no meio" até aqui) + +1. **Audit profundo** (workflow multi-agente): a memória canônica estava errada em 4 fatos load-bearing (já corrigidos). O package era um "encanamento sem nada confiável passando". +2. **Replan colaborativo** + decisões do usuário: + - **Constraint dura**: NÃO tocar no Arch (projeto grande em dev lá). Tudo package-side; integração Arch = última fase. + - **A primeiro** (Patterns engine = o diferencial). + - **Transporte LLM = context-emit** (assinatura Claude Code, SEM API metered). `claude -p` está **fora** (vira API metered no próximo mês). `anthropic-ai/sdk` está fora (metered). + - **Reverter o "AI findings never baselined"** (explícito + auditável). +3. **Shippado nesta sessão** (branch `feat/patterns-engine-foundation`, PR #1, 6 commits): + - `4c662a0` Fase 1 — assertion traits reais (`AntiPatternScanner`); eram landmine que lançava exception. + - `0dfb953` Patterns engine MVP (`src/Analyze/*` + `codeguard:analyze`, Thin Adjudicator). + - `18c4492` context-emit (`--emit`/`--ingest` + skill `codeguard-review`; removeu 3 skills Node-era → fechou R11). + - `abfce20` **trust threshold (Tier 0+1)** — ver abaixo. ⚠️ **não pushed ainda**. + - + 2 commits docs. + - Suite **452 verdes / 1090 assertions**, Pint clean, PHPStan level 5 No errors. + +## Arquitetura do Patterns engine (já construída) + +Package = harness determinístico; Claude Code (assinatura) = cérebro. +`codeguard:analyze` modos: review síncrono (NullLlmClient → aviso de degradação honesto) · `--emit` (work order JSON) · `--ingest=` (valida findings no trust boundary + gate `--fail-on`) · `--accept` (baseline). +Fluxo real = skill `codeguard-review`: emit → fan-out de subagentes **em lotes** (decisão do usuário) → merge → ingest. +Classes em `src/Analyze/`: Severity, Pattern, DetectionSignal, PatternRepository, YamlPatternLoader, FileScopeResolver, PatternMatcher, **PhpFileInspector** (use-parsing), FindingSchema, **PatternMatch** (trust boundary), AnalyzeResult, AnalyzeRunner, **AnalyzeBaseline**, LlmClient + NullLlmClient. + +## Tier 0+1 (trust threshold) — FEITO em `abfce20` + +Incorpora as correções do crítico adversarial do workflow `patterns-engine-completeness`: +- **T5 atribuição exata** — `findUnit` casa path absoluto exato; basename só se inequívoco (dois `User.php` não cruzam mais). +- **T2 use-parsing real** — `PhpFileInspector` (regex no head, zero-dep) → sinais `import` casam os `use` reais (namespace-glob). `import: **/*` (os 3 patterns arquiteturais) **excluído** da seleção per-file até o grafo (R3). Patterns de estrutura de classe gated a arquivos com classe. +- **T4 baseline** — `AnalyzeBaseline`, `--accept`, mostra "N suprimidos". Fingerprint = `sha1(pattern_key + arquivo_relativo)` — **sem mensagem, sem linha** (correção do crítico: senão o LLM reformula e o finding ressurge). +- **Teste de cobertura de seleção** (parte honesta automatizável) + `docs/patterns-recall.md` (recall manual). + +## PRÓXIMO (em ordem) + +1. **`git push`** (`abfce20` não pushed → atualiza PR #1). +2. **Validação de campo**: rodar `/codeguard-review` num projeto real, preencher `docs/patterns-recall.md`. ⚠️ **A qualidade do julgamento NÃO é testável em CI** (assinatura, sem API metered) — só com sessão Claude Code real. Toda melhoria de precisão (Tier 2) só se valida à mão. +3. **Tier 2 — profundidade (~10d, "genuinamente alto valor")**, nesta ordem (cada um reusa infra do anterior): + - **R1 voting multi-sample** (~2,5d): emitir k=3, manter findings ≥2/3, derivar confiança de vote-share (NÃO da confiança verbalizada — é miscalibrada). Default `--samples=1`, opt-in `--samples=3` (Fork A). + - **R2 critique pass** (~2d): 2º subagente re-pontua 0–10, dropa 0. `verified_score` no FindingSchema. + - **R3 grafo namespace→layer** (~3d): parsear `use` edges (reusa PhpFileInspector) num mapa de adjacência; emitir no work order; ligar de verdade bounded-contexts/layer-dependency-direction/no-circular-dependencies (hoje excluídos). `related_file` opcional no FindingSchema. + - **R4 corpus de alto impacto** (~3d): N+1, mass-assignment (`Model::create($request->all())`), missing transaction, SQL cru/`DB::raw` interpolado, missing authz em writes, `->get()` sem limite. Invisível ao AST, no centro da meta G3. +4. **Backlog menor**: `coverage_percent -1` (`CodeguardTestCommand.php:102`); config morto `ai_rules`/`prepare`; Fase 3 (schema dump + ai-rules generator). + +## NÃO construir (decidido) +API metered como caminho default · embeddings p/ dry · calibrador de confiança (derive de voto) · cache de resultado · `--format=github` (sem CI confirmado) · auto-fix · UI de config por-pattern. Re-scope agressivo dos patterns Laravel "invertidos" = adiado (risco de FP, precisa campo). + +## Docs de referência +- `docs/specs/2026-06-03-patterns-engine-design.md` — design Thin Adjudicator. +- Roadmap de completude (Tier 0+1 + Tier 2 + forks A/B/C) — saiu do workflow `patterns-engine-completeness` desta sessão; o essencial está resumido acima. From a3202fb14c51ac2e21dfdfde4f29e84e0951a8f4 Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 16:19:24 -0300 Subject: [PATCH 09/21] =?UTF-8?q?feat(analyze):=20R1=20voting=20multi-samp?= =?UTF-8?q?le=20=E2=80=94=20vote-share=20confidence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 2 R1. Adds k-sample voting to the context-emit review path so a finding survives only when >=2/3 of independent passes agree on it, and its confidence becomes the calibrated vote-share rather than the model's self-reported (and easily inflated) number. - FindingVoter: tally(list>, minVotes) — identity by pattern_key|file|line; a finding repeated within one sample is one vote; survivors carry confidence = votes/k. - PatternMatch::withConfidence() immutable copy for the vote-share overwrite. - AnalyzeRunner::ingestSamples() validates each sample through the same trust boundary BEFORE voting (hallucinations can't accrue votes); extracted shared validate()/checkCount() helpers, ingest() unchanged. - buildWorkOrder() carries a 'samples' count; --samples=k on emit (capped 1-9); ingest auto-detects a {samples:[[...]]} envelope vs the legacy {findings:[...]} single-sample path (backward compatible). - codeguard-review skill documents the k-pass independent fan-out + envelope. 467 tests (+15), PHPStan level 5 clean, Pint clean. --- resources/skills/codeguard-review/SKILL.md | 48 ++++++++- src/Analyze/AnalyzeRunner.php | 64 +++++++++++- src/Analyze/FindingVoter.php | 69 +++++++++++++ src/Analyze/PatternMatch.php | 17 ++++ src/Commands/CodeguardAnalyzeCommand.php | 71 +++++++++++++- tests/Feature/CodeguardAnalyzeCommandTest.php | 85 ++++++++++++++++ tests/Unit/Analyze/AnalyzeRunnerTest.php | 44 ++++++++- tests/Unit/Analyze/FindingVoterTest.php | 98 +++++++++++++++++++ 8 files changed, 484 insertions(+), 12 deletions(-) create mode 100644 src/Analyze/FindingVoter.php create mode 100644 tests/Unit/Analyze/FindingVoterTest.php diff --git a/resources/skills/codeguard-review/SKILL.md b/resources/skills/codeguard-review/SKILL.md index cb90b94..f8e2844 100644 --- a/resources/skills/codeguard-review/SKILL.md +++ b/resources/skills/codeguard-review/SKILL.md @@ -54,12 +54,22 @@ on ingest, so they must match. php artisan codeguard:analyze --emit --out=.codeguard/analyze-request.json ``` -This calls no LLM. It writes JSON: +For a higher-confidence review, request **voting** with `--samples=3` — run the +review 3 independent times and keep only what the passes agree on (Step 4). This +trades ~3× the subagent tokens for a finding's confidence becoming a *calibrated +vote-share* instead of the model's self-reported (and easily inflated) number: + +```bash +php artisan codeguard:analyze --emit --samples=3 --out=.codeguard/analyze-request.json +``` + +Emit calls no LLM. It writes JSON: ```json { "system_prompt": "You are a senior code reviewer ...", "finding_schema": { "type": "array", "items": { "...": "..." } }, + "samples": 1, "units": [ { "file": "/abs/path/app/Services/OrderService.php", @@ -77,7 +87,9 @@ This calls no LLM. It writes JSON: } ``` -Read the file. If `units` is empty, tell the user nothing matched the scope and stop. +Read the file. If `units` is empty, tell the user nothing matched the scope and +stop. Note the `samples` value — it tells you how many review passes to run in +Step 4 (1 = single pass, the default). ### Step 3 — Batch the units @@ -108,9 +120,17 @@ deterministic fan-out). Give each subagent: Subagents are independent — they cannot see each other's files. That isolation is intended (one file's review never bleeds into another's). +**Voting (`samples` > 1):** run this whole batched fan-out `samples` times. Each +pass is a *fresh, independent* set of subagents over the same units — do NOT show +one pass the previous pass's findings (independence is what makes the vote +meaningful). Collect each pass's merged findings as a separate array; you will +hand all of them back in Step 5. The package keeps only findings that ≥2/3 of the +passes agree on, and sets each survivor's confidence to its vote-share. + ### Step 5 — Merge findings -Concatenate every subagent's findings into one array and write it: +**Single pass (`samples: 1`).** Concatenate every subagent's findings into one +array and write it: ```bash # write the merged array to .codeguard/analyze-findings.json @@ -122,6 +142,23 @@ Concatenate every subagent's findings into one array and write it: A bare top-level array is also accepted. +**Voting (`samples` > 1).** Write one merged array *per pass* under a `samples` +envelope — the package does the voting, so keep the passes separate (do not merge +or dedupe them yourself): + +```json +{ + "samples": [ + [ { "pattern_key": "service-layer", "file": "/abs/.../OrderService.php", "line": 42, "message": "...", "severity": "critical", "confidence": 0.9 } ], + [ { "pattern_key": "service-layer", "file": "/abs/.../OrderService.php", "line": 42, "message": "...", "severity": "critical", "confidence": 0.8 } ], + [ ] + ] +} +``` + +The reported `confidence` inside each pass is ignored — the package overwrites it +with the calibrated vote-share (here 2/3 ≈ 0.67). + ### Step 6 — Ingest, validate, and gate ```bash @@ -149,3 +186,8 @@ status. Offer to open the flagged files at the cited lines. autonomous enforcement; `analyze` is the deeper assisted review. - **`--fail-on`** accepts `critical` (default), `warning`, `suggestion`, or `never` (report-only). +- **`--samples=k`** (R1 voting) raises precision by agreement, not by trusting a + single pass. A finding survives only if ≥2/3 of the `k` passes report it + (`pattern_key` + `file` + `line`); its confidence becomes the vote-share. Use it + when a false positive would be expensive (e.g. gating a contractor's PR); skip it + (default `1`) for a quick local pass. diff --git a/src/Analyze/AnalyzeRunner.php b/src/Analyze/AnalyzeRunner.php index 21334cb..6b5d902 100644 --- a/src/Analyze/AnalyzeRunner.php +++ b/src/Analyze/AnalyzeRunner.php @@ -74,9 +74,10 @@ public function run(array $files, array $presets, ?Severity $failOn, string $con * * @param list $files * @param list $presets - * @return array{system_prompt: string, finding_schema: array, units: list>} + * @param int $samples How many independent review passes the skill should run (R1 voting). + * @return array{system_prompt: string, finding_schema: array, samples: int, units: list>} */ - public function buildWorkOrder(array $files, array $presets): array + public function buildWorkOrder(array $files, array $presets, int $samples = 1): array { $units = array_map( static fn (AnalysisUnit $unit): array => [ @@ -92,6 +93,7 @@ public function buildWorkOrder(array $files, array $presets): array return [ 'system_prompt' => $this->systemPrompt(), 'finding_schema' => FindingSchema::jsonSchema(), + 'samples' => max(1, $samples), 'units' => $units, ]; } @@ -109,10 +111,56 @@ public function ingest(array $files, array $presets, array $rawFindings, ?Severi $start = hrtime(true); $units = $this->units($files, $presets); - $checks = array_sum(array_map(static fn (AnalysisUnit $unit): int => count($unit->patterns), $units)); + $checks = $this->checkCount($units); + $matches = $this->validate($units, $rawFindings); + return $this->finish($matches, $checks, $start, $failOn, adjudicated: true); + } + + /** + * R1 voting: validate each of the k samples through the trust boundary, then + * keep only findings that ≥ $minVotes samples agree on, with confidence set + * to the vote-share. Hallucinations are dropped per-sample BEFORE voting, so + * a finding can never accrue a vote it was not entitled to. + * + * @param list $files + * @param list $presets + * @param list>> $sampleSets raw findings, one list per sample + */ + public function ingestSamples(array $files, array $presets, array $sampleSets, ?Severity $failOn, int $minVotes): AnalyzeResult + { + $start = hrtime(true); + + $units = $this->units($files, $presets); + $checks = $this->checkCount($units); + + $validatedSamples = array_map( + fn (array $rawFindings): array => $this->validate($units, $rawFindings), + $sampleSets, + ); + + $matches = (new FindingVoter)->tally($validatedSamples, $minVotes); + + return $this->finish($matches, $checks, $start, $failOn, adjudicated: true); + } + + /** + * Validate a list of raw findings against the scoped units through the + * {@see PatternMatch} trust boundary. Shared by {@see ingest()} and each + * sample of {@see ingestSamples()}. + * + * @param list $units + * @param list> $rawFindings + * @return list + */ + private function validate(array $units, array $rawFindings): array + { $matches = []; foreach ($rawFindings as $raw) { + if (! is_array($raw)) { + continue; + } + $file = $raw[FindingSchema::KEY_FILE] ?? null; if (! is_string($file)) { continue; @@ -129,7 +177,15 @@ public function ingest(array $files, array $presets, array $rawFindings, ?Severi } } - return $this->finish($matches, $checks, $start, $failOn, adjudicated: true); + return $matches; + } + + /** + * @param list $units + */ + private function checkCount(array $units): int + { + return array_sum(array_map(static fn (AnalysisUnit $unit): int => count($unit->patterns), $units)); } /** diff --git a/src/Analyze/FindingVoter.php b/src/Analyze/FindingVoter.php new file mode 100644 index 0000000..0e90b8b --- /dev/null +++ b/src/Analyze/FindingVoter.php @@ -0,0 +1,69 @@ +> $samples validated matches, one inner list per sample + * @return list survivors in first-seen order, confidence = vote-share + */ + public function tally(array $samples, int $minVotes): array + { + $sampleCount = count($samples); + if ($sampleCount === 0) { + return []; + } + + /** @var array $representative */ + $representative = []; + /** @var array $votes */ + $votes = []; + + foreach ($samples as $sample) { + $counted = []; + foreach ($sample as $match) { + $key = $this->voteKey($match); + + if (! array_key_exists($key, $representative)) { + $representative[$key] = $match; + } + + if (! isset($counted[$key])) { + $counted[$key] = true; + $votes[$key] = ($votes[$key] ?? 0) + 1; + } + } + } + + $survivors = []; + foreach ($representative as $key => $match) { + $count = $votes[$key] ?? 0; + if ($count >= $minVotes) { + $survivors[] = $match->withConfidence($count / $sampleCount); + } + } + + return $survivors; + } + + private function voteKey(PatternMatch $match): string + { + return $match->patternKey.'|'.$match->file.'|'.$match->line; + } +} diff --git a/src/Analyze/PatternMatch.php b/src/Analyze/PatternMatch.php index 6c553f7..df1632d 100644 --- a/src/Analyze/PatternMatch.php +++ b/src/Analyze/PatternMatch.php @@ -20,6 +20,23 @@ public function __construct( public float $confidence, ) {} + /** + * Immutable copy with a different confidence. Used by {@see FindingVoter} to + * overwrite the model's self-reported confidence with the calibrated + * vote-share across samples. + */ + public function withConfidence(float $confidence): self + { + return new self( + $this->patternKey, + $this->file, + $this->line, + $this->message, + $this->severity, + $confidence, + ); + } + /** * @param array $raw */ diff --git a/src/Commands/CodeguardAnalyzeCommand.php b/src/Commands/CodeguardAnalyzeCommand.php index 9fb2bb7..0a6f7f7 100644 --- a/src/Commands/CodeguardAnalyzeCommand.php +++ b/src/Commands/CodeguardAnalyzeCommand.php @@ -25,6 +25,7 @@ final class CodeguardAnalyzeCommand extends Command {--context=manual : Telemetry context — pre-commit|pre-push|ci|manual.} {--emit : Write a work order JSON (for the codeguard-review Claude skill) instead of calling an LLM.} {--ingest= : Validate + report findings from this JSON file (produced out-of-band by the skill).} + {--samples=1 : Review passes the skill should run; a finding survives only if ≥2/3 of samples agree (R1 voting).} {--out= : Output path for --emit (default .codeguard/analyze-request.json).} {--accept : Accept the surviving findings into the baseline so future runs suppress them.}'; @@ -93,8 +94,9 @@ private function maybeAccept(AnalyzeBaseline $baseline, AnalyzeResult $result): private function handleEmit(CodeguardConfig $config, AnalyzeRunner $runner, FileScopeResolver $scope): int { + $samples = $this->resolveSamples(); $files = $this->resolveFiles($scope); - $workOrder = $runner->buildWorkOrder($files, $config->enabledPresets); + $workOrder = $runner->buildWorkOrder($files, $config->enabledPresets, $samples); $out = (string) ($this->option('out') ?: base_path('.codeguard'.DIRECTORY_SEPARATOR.'analyze-request.json')); $dir = dirname($out); @@ -105,7 +107,12 @@ private function handleEmit(CodeguardConfig $config, AnalyzeRunner $runner, File $json = json_encode($workOrder, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); file_put_contents($out, ($json !== false ? $json : '{}')."\n"); - $this->components->info(sprintf('Wrote %d analysis unit(s) to %s', count($workOrder['units']), $out)); + $this->components->info(sprintf( + 'Wrote %d analysis unit(s)%s to %s', + count($workOrder['units']), + $samples > 1 ? sprintf(' (×%d voting samples)', $samples) : '', + $out, + )); return self::SUCCESS; } @@ -135,10 +142,22 @@ private function handleIngest( } $contents = file_get_contents($ingestPath); - $findings = $this->normalizeRawFindings($contents === false ? null : json_decode($contents, true)); + $decoded = $contents === false ? null : json_decode($contents, true); $files = $this->resolveFiles($scope); - $result = $runner->ingest($files, $config->enabledPresets, $findings, $failOn); + + $samples = $this->extractSamples($decoded); + if ($samples !== null) { + $result = $runner->ingestSamples( + $files, + $config->enabledPresets, + $samples, + $failOn, + $this->minVotesFor(count($samples)), + ); + } else { + $result = $runner->ingest($files, $config->enabledPresets, $this->normalizeRawFindings($decoded), $failOn); + } $this->maybeAccept($baseline, $result); $this->renderFindings($result); @@ -149,6 +168,50 @@ private function handleIngest( return $exitCode; } + /** + * Number of voting samples to request on --emit. Capped so a typo cannot + * fan out an absurd number of subagent passes. + */ + private function resolveSamples(): int + { + $raw = $this->option('samples'); + $n = is_numeric($raw) ? (int) $raw : 1; + + return max(1, min(9, $n)); + } + + /** + * Votes required for a finding to survive: ≥2/3 of the samples (R1). For + * k=1 this is 1 (single-sample behaves like the legacy ingest path). + */ + private function minVotesFor(int $sampleCount): int + { + return max(1, (int) ceil($sampleCount * 2 / 3)); + } + + /** + * Detect a multi-sample ballot — a `{ "samples": [[...], [...]] }` envelope. + * Returns one normalized findings list per sample, or null when the payload + * is a single-sample findings array (handled by {@see normalizeRawFindings}). + * + * @return list>>|null + */ + private function extractSamples(mixed $decoded): ?array + { + if (! is_array($decoded) || ! isset($decoded['samples']) || ! is_array($decoded['samples'])) { + return null; + } + + $samples = []; + foreach ($decoded['samples'] as $sample) { + if (is_array($sample)) { + $samples[] = $this->normalizeRawFindings($sample); + } + } + + return $samples; + } + /** * Accepts either a bare findings array or a `{ "findings": [...] }` envelope. * diff --git a/tests/Feature/CodeguardAnalyzeCommandTest.php b/tests/Feature/CodeguardAnalyzeCommandTest.php index 8a485c6..2b1a98e 100644 --- a/tests/Feature/CodeguardAnalyzeCommandTest.php +++ b/tests/Feature/CodeguardAnalyzeCommandTest.php @@ -296,6 +296,91 @@ function analyzeReadEvents(string $path): array } }); +it('votes across a samples envelope on ingest and keeps a majority finding with vote-share confidence', function (): void { + $telemetry = analyzeTelemetryPath(); + $file = analyzeFixtureFile(); + $findingsPath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-samples-'.uniqid().'.json'; + + $finding = ['pattern_key' => 'no-god-object', 'file' => $file, 'line' => 3, 'message' => 'too many responsibilities', 'severity' => 'critical', 'confidence' => 0.99]; + file_put_contents($findingsPath, (string) json_encode([ + 'samples' => [ + [$finding], + [$finding], + [], // absent in the third sample + ], + ])); + + $fake = new FakeLlmClient(fn (AnalysisUnit $unit): array => []); + analyzeBind($telemetry, $fake); + + try { + $exit = Artisan::call('codeguard:analyze', ['--ingest' => $findingsPath, '--path' => $file, '--context' => 'ci']); + $output = Artisan::output(); + + $analyzeEnded = array_values(array_filter( + analyzeReadEvents($telemetry), + static fn (array $event): bool => ($event['event'] ?? '') === 'analyze.ended', + )); + + expect($exit)->toBe(1) // critical meets the default fail-on + ->and($analyzeEnded[0]['matches_count'] ?? null)->toBe(1) + ->and($output)->toContain('0.67'); // vote-share 2/3, NOT the model's 0.99 + } finally { + if (is_file($findingsPath)) { + unlink($findingsPath); + } + analyzeCleanup($file, $telemetry); + } +}); + +it('drops a finding that misses the sample vote threshold on ingest', function (): void { + $telemetry = analyzeTelemetryPath(); + $file = analyzeFixtureFile(); + $findingsPath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-samples-'.uniqid().'.json'; + + $finding = ['pattern_key' => 'no-god-object', 'file' => $file, 'line' => 3, 'message' => 'maybe', 'severity' => 'critical', 'confidence' => 0.99]; + file_put_contents($findingsPath, (string) json_encode([ + 'samples' => [[$finding], [], []], // only 1 of 3 votes + ])); + + $fake = new FakeLlmClient(fn (AnalysisUnit $unit): array => []); + analyzeBind($telemetry, $fake); + + try { + $exit = Artisan::call('codeguard:analyze', ['--ingest' => $findingsPath, '--path' => $file, '--context' => 'ci']); + + $analyzeEnded = array_values(array_filter( + analyzeReadEvents($telemetry), + static fn (array $event): bool => ($event['event'] ?? '') === 'analyze.ended', + )); + + expect($exit)->toBe(0) + ->and($analyzeEnded[0]['matches_count'] ?? null)->toBe(0); + } finally { + if (is_file($findingsPath)) { + unlink($findingsPath); + } + analyzeCleanup($file, $telemetry); + } +}); + +it('emits a work order carrying the requested sample count', function (): void { + $file = analyzeFixtureFile(); + $out = sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-workorder-'.uniqid().'.json'; + + try { + $exit = Artisan::call('codeguard:analyze', ['--emit' => true, '--samples' => 3, '--path' => $file, '--out' => $out]); + + expect($exit)->toBe(0); + + $decoded = json_decode((string) file_get_contents($out), true); + + expect(is_array($decoded) ? ($decoded['samples'] ?? null) : null)->toBe(3); + } finally { + analyzeCleanup($file, $out); + } +}); + it('accepts a finding into the baseline and suppresses it on the next run', function (): void { $telemetry = analyzeTelemetryPath(); $baselinePath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-accept-'.uniqid().'.json'; diff --git a/tests/Unit/Analyze/AnalyzeRunnerTest.php b/tests/Unit/Analyze/AnalyzeRunnerTest.php index 1011f02..474a44d 100644 --- a/tests/Unit/Analyze/AnalyzeRunnerTest.php +++ b/tests/Unit/Analyze/AnalyzeRunnerTest.php @@ -96,5 +96,47 @@ function arnFinding(array $overrides = []): array expect($order['units'])->toHaveCount(1) ->and($order['units'][0]['file'])->toBe('/work/app/Foo.php') - ->and($order['system_prompt'])->toBeString(); + ->and($order['system_prompt'])->toBeString() + ->and($order['samples'])->toBe(1); +}); + +it('carries the requested sample count into the work order', function (): void { + $order = arnRunner()->buildWorkOrder(['/work/app/Foo.php'], ['core'], 3); + + expect($order['samples'])->toBe(3); +}); + +it('votes across samples on ingest — keeps a majority finding with vote-share confidence', function (): void { + $files = ['/work/app/DTOs/User.php']; + $samples = [[arnFinding()], [arnFinding()], []]; + + $result = arnRunner()->ingestSamples($files, ['core'], $samples, Severity::Warning, minVotes: 2); + + expect($result->matchesCount())->toBe(1) + ->and($result->matches[0]->confidence)->toBe(2 / 3); +}); + +it('drops a finding below the vote threshold on ingestSamples', function (): void { + $files = ['/work/app/DTOs/User.php']; + $samples = [[arnFinding()], [], []]; + + $result = arnRunner()->ingestSamples($files, ['core'], $samples, Severity::Warning, minVotes: 2); + + expect($result->matchesCount())->toBe(0); +}); + +it('runs ingestSamples findings through the same trust boundary (drops hallucinations before voting)', function (): void { + $files = ['/work/app/DTOs/User.php']; + $hallucinated = arnFinding(['pattern_key' => 'ghost-pattern']); + $samples = [ + [arnFinding(), $hallucinated], + [arnFinding(), $hallucinated], + [$hallucinated], + ]; + + $result = arnRunner()->ingestSamples($files, ['core'], $samples, Severity::Warning, minVotes: 2); + + // The real finding has 2 votes and survives; the ghost is dropped before voting. + expect($result->matchesCount())->toBe(1) + ->and($result->matches[0]->patternKey)->toBe('p'); }); diff --git a/tests/Unit/Analyze/FindingVoterTest.php b/tests/Unit/Analyze/FindingVoterTest.php new file mode 100644 index 0000000..8cc4cf7 --- /dev/null +++ b/tests/Unit/Analyze/FindingVoterTest.php @@ -0,0 +1,98 @@ +tally([ + [fvMatch()], + [fvMatch()], + [], + ], minVotes: 2); + + expect($survivors)->toHaveCount(1) + ->and($survivors[0]->confidence)->toBe(2 / 3); +}); + +it('drops a finding below the vote threshold', function (): void { + $survivors = (new FindingVoter)->tally([ + [fvMatch()], + [], + [], + ], minVotes: 2); + + expect($survivors)->toBe([]); +}); + +it('treats two findings of the same pattern at different lines as distinct votes', function (): void { + $survivors = (new FindingVoter)->tally([ + [fvMatch(line: 10), fvMatch(line: 20)], + [fvMatch(line: 10)], + [fvMatch(line: 10)], + ], minVotes: 2); + + // line 10 has 3 votes; line 20 has 1 vote and is dropped. + expect($survivors)->toHaveCount(1) + ->and($survivors[0]->line)->toBe(10) + ->and($survivors[0]->confidence)->toBe(1.0); +}); + +it('counts a finding duplicated within one sample as a single vote', function (): void { + $survivors = (new FindingVoter)->tally([ + [fvMatch(), fvMatch()], // same key twice in one sample => 1 vote + [], + [], + ], minVotes: 2); + + expect($survivors)->toBe([]); +}); + +it('distinguishes findings by pattern key and file', function (): void { + $survivors = (new FindingVoter)->tally([ + [fvMatch(key: 'a'), fvMatch(key: 'b', file: '/work/B.php')], + [fvMatch(key: 'a')], + [fvMatch(key: 'b', file: '/work/B.php')], + ], minVotes: 2); + + expect($survivors)->toHaveCount(2); +}); + +it('returns empty for zero samples', function (): void { + expect((new FindingVoter)->tally([], minVotes: 1))->toBe([]); +}); + +it('keeps the representative finding fields from its first occurrence', function (): void { + $first = fvMatch(message: 'first message', severity: Severity::Critical, confidence: 0.2); + $second = fvMatch(message: 'second message', severity: Severity::Warning, confidence: 0.99); + + $survivors = (new FindingVoter)->tally([[$first], [$second]], minVotes: 2); + + expect($survivors)->toHaveCount(1) + ->and($survivors[0]->message)->toBe('first message') + ->and($survivors[0]->severity)->toBe(Severity::Critical) + ->and($survivors[0]->confidence)->toBe(1.0); +}); + +it('with a single sample and minVotes 1 keeps everything at full vote-share', function (): void { + $survivors = (new FindingVoter)->tally([ + [fvMatch(key: 'a'), fvMatch(key: 'b')], + ], minVotes: 1); + + expect($survivors)->toHaveCount(2) + ->and($survivors[0]->confidence)->toBe(1.0) + ->and($survivors[1]->confidence)->toBe(1.0); +}); From f8a7e0ec0807dcdd934cf5e67a53e5319eb63e6c Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 16:23:58 -0300 Subject: [PATCH 10/21] =?UTF-8?q?feat(analyze):=20R2=20critique=20pass=20?= =?UTF-8?q?=E2=80=94=20verified=5Fscore=20drops=20false=20positives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 2 R2. A second-opinion re-scoring pass lets a fresh subagent re-judge each finding 0-10; the package drops any finding scored 0, keeping the rest with the score surfaced. Composes with R1 voting (vote first, then drop 0-scored survivors) at one extra pass instead of k. - FindingSchema: optional verified_score (integer 0-10), not required. - PatternMatch: nullable verifiedScore parsed in the trust boundary (absent or out-of-range => null/uncritiqued; a clean 0-10 is kept; withConfidence preserves it). The trust boundary parses 0 but does NOT drop it. - AnalyzeRunner::surviveCritique() drops verifiedScore===0 in finish(), so all three paths (synchronous, ingest, voted ingest) get it uniformly, before baseline suppression + gating. - buildWorkOrder carries 'critique'; --critique flag; renderFindings shows [score N/10]. - codeguard-review skill documents the Step 5b critique fan-out. 476 tests (+9), PHPStan level 5 clean, Pint clean. --- resources/skills/codeguard-review/SKILL.md | 29 +++++++++++ src/Analyze/AnalyzeRunner.php | 24 +++++++-- src/Analyze/FindingSchema.php | 5 ++ src/Analyze/PatternMatch.php | 19 +++++++ src/Commands/CodeguardAnalyzeCommand.php | 7 ++- tests/Feature/CodeguardAnalyzeCommandTest.php | 49 +++++++++++++++++++ tests/Unit/Analyze/AnalyzeRunnerTest.php | 24 +++++++++ tests/Unit/Analyze/LlmContractTest.php | 7 +++ tests/Unit/Analyze/PatternMatchTest.php | 22 +++++++++ 9 files changed, 181 insertions(+), 5 deletions(-) diff --git a/resources/skills/codeguard-review/SKILL.md b/resources/skills/codeguard-review/SKILL.md index f8e2844..2daed05 100644 --- a/resources/skills/codeguard-review/SKILL.md +++ b/resources/skills/codeguard-review/SKILL.md @@ -159,6 +159,31 @@ or dedupe them yourself): The reported `confidence` inside each pass is ignored — the package overwrites it with the calibrated vote-share (here 2/3 ≈ 0.67). +### Step 5b — Critique pass (only when the work order has `critique: true`) + +A critique pass cuts false positives by making a *fresh* subagent re-judge each +finding instead of trusting the pass that produced it. For every finding you are +about to submit, dispatch a subagent that: + +- READS the cited `file` around the cited `line`. +- Re-judges the finding against its pattern's rubric, with this instruction: + + > Score how real and on-target this finding is, 0–10. **0 means it is wrong, + > a false positive, or not actually a violation of this pattern.** 10 means it + > is a clear, correct violation at that line. Return only the integer. + +Attach the integer to the finding as `verified_score`. A finding the critique +scored **0 is dropped by the package**; any positive score is kept and shown as +`[score N/10]`. Leave `verified_score` off a finding you did not critique. + +```json +{ "pattern_key": "service-layer", "file": "/abs/.../OrderService.php", "line": 42, "message": "...", "severity": "critical", "confidence": 0.9, "verified_score": 8 } +``` + +If you are *also* voting (Step 4), critique each pass's findings before writing +that pass's array into the `samples` envelope — the package votes first, then +drops any survivor whose `verified_score` is 0. + ### Step 6 — Ingest, validate, and gate ```bash @@ -191,3 +216,7 @@ status. Offer to open the flagged files at the cited lines. (`pattern_key` + `file` + `line`); its confidence becomes the vote-share. Use it when a false positive would be expensive (e.g. gating a contractor's PR); skip it (default `1`) for a quick local pass. +- **`--critique`** (R2) adds a second-opinion re-scoring pass (Step 5b): a fresh + subagent re-judges each finding 0–10 and the package drops the 0s. Cheaper than + voting (one extra pass, not `k`) and composes with it. Reach for it when you want + a self-check without the cost of a full re-review. diff --git a/src/Analyze/AnalyzeRunner.php b/src/Analyze/AnalyzeRunner.php index 6b5d902..233d59e 100644 --- a/src/Analyze/AnalyzeRunner.php +++ b/src/Analyze/AnalyzeRunner.php @@ -75,9 +75,10 @@ public function run(array $files, array $presets, ?Severity $failOn, string $con * @param list $files * @param list $presets * @param int $samples How many independent review passes the skill should run (R1 voting). - * @return array{system_prompt: string, finding_schema: array, samples: int, units: list>} + * @param bool $critique Whether the skill should run a critique re-scoring pass (R2). + * @return array{system_prompt: string, finding_schema: array, samples: int, critique: bool, units: list>} */ - public function buildWorkOrder(array $files, array $presets, int $samples = 1): array + public function buildWorkOrder(array $files, array $presets, int $samples = 1, bool $critique = false): array { $units = array_map( static fn (AnalysisUnit $unit): array => [ @@ -94,6 +95,7 @@ public function buildWorkOrder(array $files, array $presets, int $samples = 1): 'system_prompt' => $this->systemPrompt(), 'finding_schema' => FindingSchema::jsonSchema(), 'samples' => max(1, $samples), + 'critique' => $critique, 'units' => $units, ]; } @@ -188,6 +190,22 @@ private function checkCount(array $units): int return array_sum(array_map(static fn (AnalysisUnit $unit): int => count($unit->patterns), $units)); } + /** + * R2 critique drop: a finding the critique pass scored 0 is rejected. A null + * score means uncritiqued (kept); any positive score is kept. Applied + * uniformly to every path (synchronous, ingest, voted ingest). + * + * @param list $matches + * @return list + */ + private function surviveCritique(array $matches): array + { + return array_values(array_filter( + $matches, + static fn (PatternMatch $match): bool => $match->verifiedScore !== 0, + )); + } + /** * @param list $files * @param list $presets @@ -230,7 +248,7 @@ private function finish(array $matches, int $checks, float $start, ?Severity $fa { $fresh = []; $suppressed = 0; - foreach ($matches as $match) { + foreach ($this->surviveCritique($matches) as $match) { if ($this->baseline->isAccepted($match)) { $suppressed++; diff --git a/src/Analyze/FindingSchema.php b/src/Analyze/FindingSchema.php index c82df0b..dd73f6d 100644 --- a/src/Analyze/FindingSchema.php +++ b/src/Analyze/FindingSchema.php @@ -23,6 +23,9 @@ final class FindingSchema public const KEY_CONFIDENCE = 'confidence'; + /** Optional critique-pass re-score, 0–10. A 0 drops the finding (R2). */ + public const KEY_VERIFIED_SCORE = 'verified_score'; + /** * JSON schema for the array of findings the LLM must return. * @@ -49,6 +52,8 @@ public static function jsonSchema(): array self::KEY_MESSAGE => ['type' => 'string'], self::KEY_SEVERITY => ['type' => 'string', 'enum' => ['critical', 'warning', 'suggestion']], self::KEY_CONFIDENCE => ['type' => 'number'], + // Optional: set only by a critique pass. 0 ⇒ the finding is dropped. + self::KEY_VERIFIED_SCORE => ['type' => 'integer', 'minimum' => 0, 'maximum' => 10], ], ], ]; diff --git a/src/Analyze/PatternMatch.php b/src/Analyze/PatternMatch.php index df1632d..9d9447c 100644 --- a/src/Analyze/PatternMatch.php +++ b/src/Analyze/PatternMatch.php @@ -18,6 +18,7 @@ public function __construct( public string $message, public Severity $severity, public float $confidence, + public ?int $verifiedScore = null, ) {} /** @@ -34,6 +35,7 @@ public function withConfidence(float $confidence): self $this->message, $this->severity, $confidence, + $this->verifiedScore, ); } @@ -84,6 +86,23 @@ public static function fromArray(array $raw, AnalysisUnit $unit, PatternReposito message: $message, severity: $severity, confidence: $confidenceValue, + verifiedScore: self::parseVerifiedScore($raw[FindingSchema::KEY_VERIFIED_SCORE] ?? null), ); } + + /** + * A critique-pass score is optional. Absent or out-of-range ⇒ null + * (uncritiqued); a clean 0–10 is kept (the runner drops a 0). Lenient on + * the score itself — strictness lives in the structural checks above. + */ + private static function parseVerifiedScore(mixed $value): ?int + { + if (! is_numeric($value)) { + return null; + } + + $score = (int) $value; + + return ($score >= 0 && $score <= 10) ? $score : null; + } } diff --git a/src/Commands/CodeguardAnalyzeCommand.php b/src/Commands/CodeguardAnalyzeCommand.php index 0a6f7f7..11972ae 100644 --- a/src/Commands/CodeguardAnalyzeCommand.php +++ b/src/Commands/CodeguardAnalyzeCommand.php @@ -26,6 +26,7 @@ final class CodeguardAnalyzeCommand extends Command {--emit : Write a work order JSON (for the codeguard-review Claude skill) instead of calling an LLM.} {--ingest= : Validate + report findings from this JSON file (produced out-of-band by the skill).} {--samples=1 : Review passes the skill should run; a finding survives only if ≥2/3 of samples agree (R1 voting).} + {--critique : Ask the skill to run a critique re-scoring pass; findings scored 0 are dropped (R2).} {--out= : Output path for --emit (default .codeguard/analyze-request.json).} {--accept : Accept the surviving findings into the baseline so future runs suppress them.}'; @@ -95,8 +96,9 @@ private function maybeAccept(AnalyzeBaseline $baseline, AnalyzeResult $result): private function handleEmit(CodeguardConfig $config, AnalyzeRunner $runner, FileScopeResolver $scope): int { $samples = $this->resolveSamples(); + $critique = (bool) $this->option('critique'); $files = $this->resolveFiles($scope); - $workOrder = $runner->buildWorkOrder($files, $config->enabledPresets, $samples); + $workOrder = $runner->buildWorkOrder($files, $config->enabledPresets, $samples, $critique); $out = (string) ($this->option('out') ?: base_path('.codeguard'.DIRECTORY_SEPARATOR.'analyze-request.json')); $dir = dirname($out); @@ -280,13 +282,14 @@ private function renderFindings(AnalyzeResult $result): void foreach ($result->matches as $match) { $this->line(sprintf( - ' %s %s:%d · %s · %s (%.2f)', + ' %s %s:%d · %s · %s (%.2f)%s', $this->glyph($match->severity), $match->file, $match->line, $match->patternKey, $match->message, $match->confidence, + $match->verifiedScore !== null ? sprintf(' [score %d/10]', $match->verifiedScore) : '', )); } diff --git a/tests/Feature/CodeguardAnalyzeCommandTest.php b/tests/Feature/CodeguardAnalyzeCommandTest.php index 2b1a98e..cb023f9 100644 --- a/tests/Feature/CodeguardAnalyzeCommandTest.php +++ b/tests/Feature/CodeguardAnalyzeCommandTest.php @@ -381,6 +381,55 @@ function analyzeReadEvents(string $path): array } }); +it('emits a work order flagging the critique pass when requested', function (): void { + $file = analyzeFixtureFile(); + $out = sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-workorder-'.uniqid().'.json'; + + try { + Artisan::call('codeguard:analyze', ['--emit' => true, '--critique' => true, '--path' => $file, '--out' => $out]); + + $decoded = json_decode((string) file_get_contents($out), true); + + expect(is_array($decoded) ? ($decoded['critique'] ?? null) : null)->toBeTrue(); + } finally { + analyzeCleanup($file, $out); + } +}); + +it('drops a critique-rejected finding on ingest and shows the verified score', function (): void { + $telemetry = analyzeTelemetryPath(); + $file = analyzeFixtureFile(); + $findingsPath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-critique-'.uniqid().'.json'; + + file_put_contents($findingsPath, (string) json_encode([ + ['pattern_key' => 'no-god-object', 'file' => $file, 'line' => 3, 'message' => 'real', 'severity' => 'critical', 'confidence' => 0.9, 'verified_score' => 9], + ['pattern_key' => 'no-god-object', 'file' => $file, 'line' => 5, 'message' => 'rejected', 'severity' => 'critical', 'confidence' => 0.9, 'verified_score' => 0], + ])); + + $fake = new FakeLlmClient(fn (AnalysisUnit $unit): array => []); + analyzeBind($telemetry, $fake); + + try { + $exit = Artisan::call('codeguard:analyze', ['--ingest' => $findingsPath, '--path' => $file, '--context' => 'ci']); + $output = Artisan::output(); + + $analyzeEnded = array_values(array_filter( + analyzeReadEvents($telemetry), + static fn (array $event): bool => ($event['event'] ?? '') === 'analyze.ended', + )); + + expect($exit)->toBe(1) + ->and($analyzeEnded[0]['matches_count'] ?? null)->toBe(1) + ->and($output)->toContain('9/10') + ->and($output)->not->toContain('rejected'); + } finally { + if (is_file($findingsPath)) { + unlink($findingsPath); + } + analyzeCleanup($file, $telemetry); + } +}); + it('accepts a finding into the baseline and suppresses it on the next run', function (): void { $telemetry = analyzeTelemetryPath(); $baselinePath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-accept-'.uniqid().'.json'; diff --git a/tests/Unit/Analyze/AnalyzeRunnerTest.php b/tests/Unit/Analyze/AnalyzeRunnerTest.php index 474a44d..41fd1ff 100644 --- a/tests/Unit/Analyze/AnalyzeRunnerTest.php +++ b/tests/Unit/Analyze/AnalyzeRunnerTest.php @@ -125,6 +125,30 @@ function arnFinding(array $overrides = []): array expect($result->matchesCount())->toBe(0); }); +it('drops a finding the critique pass scored 0, keeping a positively-scored one', function (): void { + $files = ['/work/app/DTOs/User.php']; + $findings = [ + arnFinding(['line' => 1, 'verified_score' => 0]), // critique rejected + arnFinding(['line' => 2, 'verified_score' => 8]), // critique kept + ]; + + $result = arnRunner()->ingest($files, ['core'], $findings, Severity::Warning); + + expect($result->matchesCount())->toBe(1) + ->and($result->matches[0]->line)->toBe(2) + ->and($result->matches[0]->verifiedScore)->toBe(8); +}); + +it('applies the critique drop after voting on ingestSamples', function (): void { + $files = ['/work/app/DTOs/User.php']; + $rejected = arnFinding(['verified_score' => 0]); + $samples = [[$rejected], [$rejected], [$rejected]]; // unanimous, but critique killed it + + $result = arnRunner()->ingestSamples($files, ['core'], $samples, Severity::Warning, minVotes: 2); + + expect($result->matchesCount())->toBe(0); +}); + it('runs ingestSamples findings through the same trust boundary (drops hallucinations before voting)', function (): void { $files = ['/work/app/DTOs/User.php']; $hallucinated = arnFinding(['pattern_key' => 'ghost-pattern']); diff --git a/tests/Unit/Analyze/LlmContractTest.php b/tests/Unit/Analyze/LlmContractTest.php index 18efdcb..dfe10a5 100644 --- a/tests/Unit/Analyze/LlmContractTest.php +++ b/tests/Unit/Analyze/LlmContractTest.php @@ -25,3 +25,10 @@ FindingSchema::KEY_CONFIDENCE, ); }); + +it('exposes verified_score as an optional schema property (critique pass, not required)', function (): void { + $schema = FindingSchema::jsonSchema(); + + expect($schema['items']['properties'])->toHaveKey(FindingSchema::KEY_VERIFIED_SCORE) + ->and($schema['items']['required'])->not->toContain(FindingSchema::KEY_VERIFIED_SCORE); +}); diff --git a/tests/Unit/Analyze/PatternMatchTest.php b/tests/Unit/Analyze/PatternMatchTest.php index 210802a..58da41d 100644 --- a/tests/Unit/Analyze/PatternMatchTest.php +++ b/tests/Unit/Analyze/PatternMatchTest.php @@ -91,3 +91,25 @@ function pmtRaw(array $overrides = []): array it('drops a finding with a non-numeric line', function (): void { expect(PatternMatch::fromArray(pmtRaw(['line' => 'abc']), pmtUnit(), pmtRepo()))->toBeNull(); }); + +it('parses a verified_score into the match', function (): void { + $match = PatternMatch::fromArray(pmtRaw(['verified_score' => 7]), pmtUnit(), pmtRepo()); + + expect($match?->verifiedScore)->toBe(7); +}); + +it('keeps a verified_score of 0 on the match (the runner, not the trust boundary, drops it)', function (): void { + $match = PatternMatch::fromArray(pmtRaw(['verified_score' => 0]), pmtUnit(), pmtRepo()); + + expect($match)->toBeInstanceOf(PatternMatch::class) + ->and($match?->verifiedScore)->toBe(0); +}); + +it('treats an absent verified_score as uncritiqued (null)', function (): void { + expect(PatternMatch::fromArray(pmtRaw(), pmtUnit(), pmtRepo())?->verifiedScore)->toBeNull(); +}); + +it('ignores an out-of-range verified_score (treats it as uncritiqued)', function (): void { + expect(PatternMatch::fromArray(pmtRaw(['verified_score' => 15]), pmtUnit(), pmtRepo())?->verifiedScore)->toBeNull() + ->and(PatternMatch::fromArray(pmtRaw(['verified_score' => -3]), pmtUnit(), pmtRepo())?->verifiedScore)->toBeNull(); +}); From cdca3b5425e318910541519f1a58545c845a33cc Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 16:33:46 -0300 Subject: [PATCH 11/21] =?UTF-8?q?feat(analyze):=20R3=20namespace=20graph?= =?UTF-8?q?=20=E2=80=94=20enable=20the=203=20architectural=20patterns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 2 R3. The catch-all-import patterns (layer-dependency-direction, bounded-contexts, no-circular-dependencies) judge relationships BETWEEN files, so a per-file pass never saw them. This builds the cross-file context they need. - NamespaceGraph: parses scoped files' top-level use edges into a first-party adjacency map (vendor imports dropped) and pre-computes dependency cycles (DFS back-edge detection). Pure fromSources() + disk-reading build(). - PhpFileInspector::fqcn(): namespace + declared type => node id. - PatternMatcher::isGraphLevel()/graphLevel() partition the catch-all-import patterns; workingDirectory() getter; catch-all list deduped into a constant. - buildWorkOrder emits 'graph' {nodes,edges,cycles} + 'architecture.patterns' (the graph-level patterns, listed once — NOT duplicated into per-file units). - ingest/ingestSamples augment attribution with one architectural unit per scoped class file that matched no per-file pattern, so an architectural finding attributes through the same trust boundary (a non-class file in scope is still rejected). Per-file check count is unchanged. - FindingSchema/PatternMatch: optional related_file (the other end of a bad dependency); shown as ' -> FQCN'. withConfidence preserves it. - codeguard-review skill: Step 4b single-subagent architectural review over the graph; scope caveat (use --all for a complete graph). 492 tests (+16), PHPStan level 5 clean, Pint clean. --- resources/skills/codeguard-review/SKILL.md | 37 ++++ src/Analyze/AnalyzeRunner.php | 66 ++++++- src/Analyze/FindingSchema.php | 5 + src/Analyze/NamespaceGraph.php | 157 ++++++++++++++++ src/Analyze/PatternMatch.php | 3 + src/Analyze/PatternMatcher.php | 43 ++++- src/Analyze/PhpFileInspector.php | 19 ++ src/Commands/CodeguardAnalyzeCommand.php | 3 +- .../Unit/Analyze/AnalyzeArchitectureTest.php | 176 ++++++++++++++++++ tests/Unit/Analyze/LlmContractTest.php | 7 + tests/Unit/Analyze/NamespaceGraphTest.php | 59 ++++++ tests/Unit/Analyze/PatternMatchTest.php | 11 ++ tests/Unit/Analyze/PatternMatcherTest.php | 13 ++ tests/Unit/Analyze/PhpFileInspectorTest.php | 18 ++ 14 files changed, 609 insertions(+), 8 deletions(-) create mode 100644 src/Analyze/NamespaceGraph.php create mode 100644 tests/Unit/Analyze/AnalyzeArchitectureTest.php create mode 100644 tests/Unit/Analyze/NamespaceGraphTest.php diff --git a/resources/skills/codeguard-review/SKILL.md b/resources/skills/codeguard-review/SKILL.md index 2daed05..e45c160 100644 --- a/resources/skills/codeguard-review/SKILL.md +++ b/resources/skills/codeguard-review/SKILL.md @@ -127,6 +127,38 @@ meaningful). Collect each pass's merged findings as a separate array; you will hand all of them back in Step 5. The package keeps only findings that ≥2/3 of the passes agree on, and sets each survivor's confidence to its vote-share. +### Step 4b — Architectural review (only when `architecture.patterns` is non-empty) + +Three patterns judge *relationships between files* — dependency direction, module +boundaries, dependency cycles — which a per-file pass cannot see. The work order +carries them under `architecture.patterns`, plus a `graph` the package built from +the files' real `use` statements: + +```json +"graph": { + "nodes": [ { "fqcn": "App\\Services\\OrderService", "file": "app/Services/OrderService.php" } ], + "edges": [ { "from": "App\\Services\\OrderService", "to": "App\\Repositories\\OrderRepository" } ], + "cycles": [ [ "App\\Orders\\OrderService", "App\\Shipping\\ShippingService" ] ] +} +``` + +Dispatch **one** subagent for the whole graph (not per file). Give it the +`system_prompt`, the `graph`, and `architecture.patterns`, with this instruction: + + > Judge the dependency `graph` against these architectural patterns. `edges` are + > real first-party `use` dependencies (`from` → `to`); `cycles` are dependency + > cycles already detected for you — treat each as a likely + > `no-circular-dependencies` violation and confirm it. READ a node's `file` + > before reporting it. Return a finding ONLY for a real violation, each: + > `{ "pattern_key", "file", "line", "message", "severity", "confidence", + > "related_file" }`, where `file` is the offending node's path (exactly as in + > the graph) and `related_file` is the FQCN at the other end of the bad + > dependency. Return `[]` when the architecture is clean. + +Add this subagent's findings to the same merged array as the per-file ones (and, +when voting, to each pass's array). The package attributes them to the cited file +through the same trust boundary — even a file that matched no per-file pattern. + ### Step 5 — Merge findings **Single pass (`samples: 1`).** Concatenate every subagent's findings into one @@ -220,3 +252,8 @@ status. Offer to open the flagged files at the cited lines. subagent re-judges each finding 0–10 and the package drops the 0s. Cheaper than voting (one extra pass, not `k`) and composes with it. Reach for it when you want a self-check without the cost of a full re-review. +- **Architectural patterns** (R3, Step 4b) reach cross-file smells — wrong + dependency direction, module-boundary leaks, dependency cycles — using the + namespace `graph` the package builds from real `use` edges. The graph is only as + complete as the scope: under `--changed-only` it sees just the changed files, so + run `--all` (or `--path` over a module) for a trustworthy architectural pass. diff --git a/src/Analyze/AnalyzeRunner.php b/src/Analyze/AnalyzeRunner.php index 233d59e..97a22dc 100644 --- a/src/Analyze/AnalyzeRunner.php +++ b/src/Analyze/AnalyzeRunner.php @@ -76,10 +76,12 @@ public function run(array $files, array $presets, ?Severity $failOn, string $con * @param list $presets * @param int $samples How many independent review passes the skill should run (R1 voting). * @param bool $critique Whether the skill should run a critique re-scoring pass (R2). - * @return array{system_prompt: string, finding_schema: array, samples: int, critique: bool, units: list>} + * @return array{system_prompt: string, finding_schema: array, samples: int, critique: bool, graph: array, architecture: array{patterns: list>}, units: list>} */ public function buildWorkOrder(array $files, array $presets, int $samples = 1, bool $critique = false): array { + $patterns = $this->patterns->forPresets($presets); + $units = array_map( static fn (AnalysisUnit $unit): array => [ 'file' => $unit->file, @@ -88,7 +90,12 @@ public function buildWorkOrder(array $files, array $presets, int $samples = 1, b $unit->patterns, ), ], - $this->units($files, $presets), + $this->matcher->match($files, $patterns), + ); + + $architecturePatterns = array_map( + static fn (Pattern $pattern): array => $pattern->toPromptArray(), + $this->matcher->graphLevel($patterns), ); return [ @@ -96,6 +103,8 @@ public function buildWorkOrder(array $files, array $presets, int $samples = 1, b 'finding_schema' => FindingSchema::jsonSchema(), 'samples' => max(1, $samples), 'critique' => $critique, + 'graph' => (new NamespaceGraph)->build($files, $this->matcher->workingDirectory()), + 'architecture' => ['patterns' => $architecturePatterns], 'units' => $units, ]; } @@ -112,8 +121,10 @@ public function ingest(array $files, array $presets, array $rawFindings, ?Severi { $start = hrtime(true); - $units = $this->units($files, $presets); - $checks = $this->checkCount($units); + $patterns = $this->patterns->forPresets($presets); + $perFileUnits = $this->matcher->match($files, $patterns); + $checks = $this->checkCount($perFileUnits); + $units = $this->withArchitecturalUnits($files, $patterns, $perFileUnits); $matches = $this->validate($units, $rawFindings); return $this->finish($matches, $checks, $start, $failOn, adjudicated: true); @@ -133,8 +144,10 @@ public function ingestSamples(array $files, array $presets, array $sampleSets, ? { $start = hrtime(true); - $units = $this->units($files, $presets); - $checks = $this->checkCount($units); + $patterns = $this->patterns->forPresets($presets); + $perFileUnits = $this->matcher->match($files, $patterns); + $checks = $this->checkCount($perFileUnits); + $units = $this->withArchitecturalUnits($files, $patterns, $perFileUnits); $validatedSamples = array_map( fn (array $rawFindings): array => $this->validate($units, $rawFindings), @@ -146,6 +159,47 @@ public function ingestSamples(array $files, array $presets, array $sampleSets, ? return $this->finish($matches, $checks, $start, $failOn, adjudicated: true); } + /** + * Augment the per-file units with one architectural unit per scoped class + * file that matched no per-file pattern — so an architectural finding (whose + * graph-level pattern is never selected per file) still attributes to a + * real, in-scope file through the trust boundary. Attribution only; it does + * not change the per-file check count. + * + * @param list $files + * @param list $patterns + * @param list $perFileUnits + * @return list + */ + private function withArchitecturalUnits(array $files, array $patterns, array $perFileUnits): array + { + $graphLevel = $this->matcher->graphLevel($patterns); + if ($graphLevel === []) { + return $perFileUnits; + } + + $covered = []; + foreach ($perFileUnits as $unit) { + $covered[$unit->file] = true; + } + + $units = $perFileUnits; + foreach ($files as $file) { + if (isset($covered[$file])) { + continue; + } + + $contents = is_file($file) ? (file_get_contents($file) ?: '') : ''; + if (PhpFileInspector::fqcn($contents) === null) { + continue; + } + + $units[] = new AnalysisUnit($file, $graphLevel); + } + + return $units; + } + /** * Validate a list of raw findings against the scoped units through the * {@see PatternMatch} trust boundary. Shared by {@see ingest()} and each diff --git a/src/Analyze/FindingSchema.php b/src/Analyze/FindingSchema.php index dd73f6d..d69bbd3 100644 --- a/src/Analyze/FindingSchema.php +++ b/src/Analyze/FindingSchema.php @@ -26,6 +26,9 @@ final class FindingSchema /** Optional critique-pass re-score, 0–10. A 0 drops the finding (R2). */ public const KEY_VERIFIED_SCORE = 'verified_score'; + /** Optional: the other end of a bad dependency, for architectural findings (R3). */ + public const KEY_RELATED_FILE = 'related_file'; + /** * JSON schema for the array of findings the LLM must return. * @@ -54,6 +57,8 @@ public static function jsonSchema(): array self::KEY_CONFIDENCE => ['type' => 'number'], // Optional: set only by a critique pass. 0 ⇒ the finding is dropped. self::KEY_VERIFIED_SCORE => ['type' => 'integer', 'minimum' => 0, 'maximum' => 10], + // Optional: the other end of a bad dependency (architectural findings). + self::KEY_RELATED_FILE => ['type' => 'string'], ], ], ]; diff --git a/src/Analyze/NamespaceGraph.php b/src/Analyze/NamespaceGraph.php new file mode 100644 index 0000000..24deb96 --- /dev/null +++ b/src/Analyze/NamespaceGraph.php @@ -0,0 +1,157 @@ + $files Absolute paths. + * @return array{nodes: list, edges: list, cycles: list>} + */ + public function build(array $files, string $workingDirectory): array + { + $sources = []; + foreach ($files as $file) { + $contents = is_file($file) ? (file_get_contents($file) ?: '') : ''; + $sources[$this->relative($file, $workingDirectory)] = $contents; + } + + return $this->fromSources($sources); + } + + /** + * @param array $sources relativePath => file contents + * @return array{nodes: list, edges: list, cycles: list>} + */ + public function fromSources(array $sources): array + { + /** @var array $nodeFile fqcn => relative path */ + $nodeFile = []; + /** @var array> $importsByFqcn */ + $importsByFqcn = []; + + foreach ($sources as $relative => $contents) { + $fqcn = PhpFileInspector::fqcn($contents); + if ($fqcn === null) { + continue; + } + $nodeFile[$fqcn] = $relative; + $importsByFqcn[$fqcn] = PhpFileInspector::imports($contents); + } + + $edges = []; + /** @var array> $adjacency */ + $adjacency = []; + foreach ($importsByFqcn as $from => $imports) { + foreach ($imports as $to) { + if ($to !== $from && array_key_exists($to, $nodeFile)) { + $edges[] = ['from' => $from, 'to' => $to]; + $adjacency[$from][] = $to; + } + } + } + + $nodes = []; + foreach ($nodeFile as $fqcn => $relative) { + $nodes[] = ['fqcn' => $fqcn, 'file' => $relative]; + } + + return [ + 'nodes' => $nodes, + 'edges' => $edges, + 'cycles' => $this->findCycles($adjacency), + ]; + } + + /** + * Depth-first cycle detection. Returns a representative set of dependency + * cycles (not guaranteed exhaustive — enough to flag entanglement to the + * reviewer). Each cycle is the node path between a back-edge's endpoints. + * + * @param array> $adjacency + * @return list> + */ + private function findCycles(array $adjacency): array + { + $cycles = []; + /** @var array $seen */ + $seen = []; + /** @var array $visited */ + $visited = []; + + foreach (array_keys($adjacency) as $node) { + if (! isset($visited[$node])) { + $this->walk($node, $adjacency, $visited, [], [], $cycles, $seen); + } + } + + return $cycles; + } + + /** + * @param array> $adjacency + * @param array $visited + * @param array $stackPos node => index in $path (the active recursion path) + * @param list $path + * @param list> $cycles + * @param array $seen + */ + private function walk(string $node, array $adjacency, array &$visited, array $stackPos, array $path, array &$cycles, array &$seen): void + { + $stackPos[$node] = count($path); + $path[] = $node; + + foreach ($adjacency[$node] ?? [] as $next) { + if (isset($stackPos[$next])) { + $cycle = array_slice($path, $stackPos[$next]); + $key = $this->cycleKey($cycle); + if (! isset($seen[$key])) { + $seen[$key] = true; + $cycles[] = $cycle; + } + } elseif (! isset($visited[$next])) { + $this->walk($next, $adjacency, $visited, $stackPos, $path, $cycles, $seen); + } + } + + $visited[$node] = true; + } + + /** + * @param list $cycle + */ + private function cycleKey(array $cycle): string + { + $sorted = $cycle; + sort($sorted); + + return implode('|', $sorted); + } + + private function relative(string $absolute, string $workingDirectory): string + { + $prefix = rtrim($workingDirectory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + $relative = str_starts_with($absolute, $prefix) + ? substr($absolute, strlen($prefix)) + : $absolute; + + return str_replace('\\', '/', $relative); + } +} diff --git a/src/Analyze/PatternMatch.php b/src/Analyze/PatternMatch.php index 9d9447c..3a9e31f 100644 --- a/src/Analyze/PatternMatch.php +++ b/src/Analyze/PatternMatch.php @@ -19,6 +19,7 @@ public function __construct( public Severity $severity, public float $confidence, public ?int $verifiedScore = null, + public ?string $relatedFile = null, ) {} /** @@ -36,6 +37,7 @@ public function withConfidence(float $confidence): self $this->severity, $confidence, $this->verifiedScore, + $this->relatedFile, ); } @@ -87,6 +89,7 @@ public static function fromArray(array $raw, AnalysisUnit $unit, PatternReposito severity: $severity, confidence: $confidenceValue, verifiedScore: self::parseVerifiedScore($raw[FindingSchema::KEY_VERIFIED_SCORE] ?? null), + relatedFile: is_string($raw[FindingSchema::KEY_RELATED_FILE] ?? null) ? $raw[FindingSchema::KEY_RELATED_FILE] : null, ); } diff --git a/src/Analyze/PatternMatcher.php b/src/Analyze/PatternMatcher.php index 3b7ae57..1b8623f 100644 --- a/src/Analyze/PatternMatcher.php +++ b/src/Analyze/PatternMatcher.php @@ -36,8 +36,49 @@ final class PatternMatcher 'no-constructor-many-params', ]; + /** + * Catch-all import values — a whole-project architectural signal a per-file + * pass cannot satisfy. + * + * @var list + */ + private const CATCH_ALL_IMPORTS = ['*', '**', '**/*']; + public function __construct(private readonly string $workingDirectory) {} + public function workingDirectory(): string + { + return $this->workingDirectory; + } + + /** + * Graph-level patterns: their ONLY detection signals are catch-all imports, + * so they describe cross-file relationships reviewed against the namespace + * graph (R3) rather than per file. {@see match()} excludes them. + * + * @param list $patterns + * @return list + */ + public function graphLevel(array $patterns): array + { + return array_values(array_filter($patterns, fn (Pattern $p): bool => $this->isGraphLevel($p))); + } + + public function isGraphLevel(Pattern $pattern): bool + { + if ($pattern->detectionSignals === []) { + return false; + } + + foreach ($pattern->detectionSignals as $signal) { + if ($signal->type !== 'import' || ! in_array($signal->value, self::CATCH_ALL_IMPORTS, true)) { + return false; + } + } + + return true; + } + /** * @param list $files Absolute paths. * @param list $patterns @@ -106,7 +147,7 @@ private function importMatches(array $imports, string $value): bool { // Catch-all import = whole-project architectural signal; a per-file pass // cannot judge it without the cross-file graph (R3), so it selects nothing. - if ($value === '*' || $value === '**' || $value === '**/*') { + if (in_array($value, self::CATCH_ALL_IMPORTS, true)) { return false; } diff --git a/src/Analyze/PhpFileInspector.php b/src/Analyze/PhpFileInspector.php index 093dde1..898eb73 100644 --- a/src/Analyze/PhpFileInspector.php +++ b/src/Analyze/PhpFileInspector.php @@ -57,6 +57,25 @@ public static function declaresClass(string $contents): bool return preg_match('/\b(?:class|trait|enum)\s+\w/i', $contents) === 1; } + /** + * The fully-qualified name of the type this file declares — `namespace` + * (if any) + the first declared `class|interface|trait|enum`. The node id + * for the namespace graph (R3). Null when the file declares no type. + */ + public static function fqcn(string $contents): ?string + { + if (preg_match('/\b(?:class|interface|trait|enum)\s+(\w+)/i', $contents, $cm) !== 1) { + return null; + } + + $namespace = ''; + if (preg_match('/^\s*namespace\s+([^;{]+)[;{]/mi', $contents, $nm) === 1) { + $namespace = trim($nm[1]); + } + + return $namespace === '' ? $cm[1] : $namespace.'\\'.$cm[1]; + } + /** * @return list */ diff --git a/src/Commands/CodeguardAnalyzeCommand.php b/src/Commands/CodeguardAnalyzeCommand.php index 11972ae..48e7b71 100644 --- a/src/Commands/CodeguardAnalyzeCommand.php +++ b/src/Commands/CodeguardAnalyzeCommand.php @@ -282,7 +282,7 @@ private function renderFindings(AnalyzeResult $result): void foreach ($result->matches as $match) { $this->line(sprintf( - ' %s %s:%d · %s · %s (%.2f)%s', + ' %s %s:%d · %s · %s (%.2f)%s%s', $this->glyph($match->severity), $match->file, $match->line, @@ -290,6 +290,7 @@ private function renderFindings(AnalyzeResult $result): void $match->message, $match->confidence, $match->verifiedScore !== null ? sprintf(' [score %d/10]', $match->verifiedScore) : '', + $match->relatedFile !== null ? sprintf(' → %s', $match->relatedFile) : '', )); } diff --git a/tests/Unit/Analyze/AnalyzeArchitectureTest.php b/tests/Unit/Analyze/AnalyzeArchitectureTest.php new file mode 100644 index 0000000..31bdad1 --- /dev/null +++ b/tests/Unit/Analyze/AnalyzeArchitectureTest.php @@ -0,0 +1,176 @@ +isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($base); +} + +function archRunner(string $base): AnalyzeRunner +{ + $repo = new class implements PatternRepository + { + /** + * @param list $presets + * @return list + */ + public function forPresets(array $presets): array + { + return [Pattern::fromArray('layer-dependency-direction', [ + 'detection' => ['signals' => [['type' => 'import', 'value' => '**/*']]], + 'verification' => ['rules' => ['upper layers depend on lower, never the reverse']], + 'examples' => ['correct' => '', 'violation' => ''], + 'severity' => 'critical', + ])]; + } + + public function has(string $key): bool + { + return $key === 'layer-dependency-direction'; + } + }; + + $recorder = new Recorder( + new ConfigGate(enabled: false), + new FieldAllowlist(strictMode: true), + new Rotator, + new JsonlWriter, + sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-arch.jsonl', + ); + + $baseline = new AnalyzeBaseline( + new Filesystem, + sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-arch-baseline-'.uniqid().'.json', + $base, + ); + + return new AnalyzeRunner($recorder, $repo, new PatternMatcher($base), new NullLlmClient, $baseline, '/nonexistent-prompt.md'); +} + +it('emits the namespace graph and the architectural patterns in the work order', function (): void { + $base = archBase(); + + try { + $a = archWrite($base, 'app/Services/OrderService.php', "buildWorkOrder([$a, $b], ['core']); + + expect($order)->toHaveKeys(['graph', 'architecture']) + ->and($order['architecture']['patterns'])->toHaveCount(1) + ->and($order['architecture']['patterns'][0]['key'])->toBe('layer-dependency-direction') + ->and($order['graph']['nodes'])->toHaveCount(2) + ->and($order['graph']['edges'])->toContain([ + 'from' => 'App\\Services\\OrderService', + 'to' => 'App\\Repositories\\OrderRepository', + ]) + // Architectural patterns are NOT duplicated into the per-file units. + ->and($order['units'])->toBe([]); + } finally { + archCleanup($base); + } +}); + +it('attributes an architectural finding to a class file that matched no per-file pattern', function (): void { + $base = archBase(); + + try { + $repo = archWrite($base, 'app/Repositories/OrderRepository.php', " 'layer-dependency-direction', + 'file' => $repo, + 'line' => 3, + 'message' => 'repository depends on a controller (wrong direction)', + 'severity' => 'critical', + 'confidence' => 0.9, + 'related_file' => 'App\\Http\\OrderController', + ]; + + $result = archRunner($base)->ingest([$repo], ['core'], [$finding], Severity::Critical); + + expect($result->matchesCount())->toBe(1) + ->and($result->matches[0]->patternKey)->toBe('layer-dependency-direction') + ->and($result->matches[0]->relatedFile)->toBe('App\\Http\\OrderController'); + } finally { + archCleanup($base); + } +}); + +it('drops an architectural finding pointing at a non-class file in scope', function (): void { + $base = archBase(); + + try { + $config = archWrite($base, 'config/app.php', " 1];\n"); + + $finding = [ + 'pattern_key' => 'layer-dependency-direction', + 'file' => $config, + 'line' => 1, + 'message' => 'should not attribute — not a class file', + 'severity' => 'critical', + 'confidence' => 0.9, + ]; + + $result = archRunner($base)->ingest([$config], ['core'], [$finding], Severity::Critical); + + expect($result->matchesCount())->toBe(0); + } finally { + archCleanup($base); + } +}); diff --git a/tests/Unit/Analyze/LlmContractTest.php b/tests/Unit/Analyze/LlmContractTest.php index dfe10a5..7c91eba 100644 --- a/tests/Unit/Analyze/LlmContractTest.php +++ b/tests/Unit/Analyze/LlmContractTest.php @@ -32,3 +32,10 @@ expect($schema['items']['properties'])->toHaveKey(FindingSchema::KEY_VERIFIED_SCORE) ->and($schema['items']['required'])->not->toContain(FindingSchema::KEY_VERIFIED_SCORE); }); + +it('exposes related_file as an optional schema property (architectural findings, not required)', function (): void { + $schema = FindingSchema::jsonSchema(); + + expect($schema['items']['properties'])->toHaveKey(FindingSchema::KEY_RELATED_FILE) + ->and($schema['items']['required'])->not->toContain(FindingSchema::KEY_RELATED_FILE); +}); diff --git a/tests/Unit/Analyze/NamespaceGraphTest.php b/tests/Unit/Analyze/NamespaceGraphTest.php new file mode 100644 index 0000000..e1aa038 --- /dev/null +++ b/tests/Unit/Analyze/NamespaceGraphTest.php @@ -0,0 +1,59 @@ +fromSources([ + 'app/Services/OrderService.php' => " " $n['fqcn'], $graph['nodes']); + + expect($fqcns)->toContain('App\\Services\\OrderService', 'App\\Repositories\\OrderRepository'); +}); + +it('records a first-party use edge between two scoped files', function (): void { + $graph = (new NamespaceGraph)->fromSources([ + 'app/Services/OrderService.php' => " "toContain([ + 'from' => 'App\\Services\\OrderService', + 'to' => 'App\\Repositories\\OrderRepository', + ]); +}); + +it('drops edges to third-party classes outside the scoped set', function (): void { + $graph = (new NamespaceGraph)->fromSources([ + 'app/Services/OrderService.php' => "toBe([]); +}); + +it('detects a two-file circular dependency', function (): void { + $graph = (new NamespaceGraph)->fromSources([ + 'app/Orders/OrderService.php' => " "toHaveCount(1); + + $cycle = $graph['cycles'][0]; + sort($cycle); + expect($cycle)->toBe(['App\\Orders\\OrderService', 'App\\Shipping\\ShippingService']); +}); + +it('reports no cycle for an acyclic dependency chain', function (): void { + $graph = (new NamespaceGraph)->fromSources([ + 'a.php' => " " "toBe([]); +}); diff --git a/tests/Unit/Analyze/PatternMatchTest.php b/tests/Unit/Analyze/PatternMatchTest.php index 58da41d..69f4385 100644 --- a/tests/Unit/Analyze/PatternMatchTest.php +++ b/tests/Unit/Analyze/PatternMatchTest.php @@ -113,3 +113,14 @@ function pmtRaw(array $overrides = []): array expect(PatternMatch::fromArray(pmtRaw(['verified_score' => 15]), pmtUnit(), pmtRepo())?->verifiedScore)->toBeNull() ->and(PatternMatch::fromArray(pmtRaw(['verified_score' => -3]), pmtUnit(), pmtRepo())?->verifiedScore)->toBeNull(); }); + +it('parses a related_file (the other end of a bad dependency) onto the match', function (): void { + $match = PatternMatch::fromArray(pmtRaw(['related_file' => 'App\\Http\\OrderController']), pmtUnit(), pmtRepo()); + + expect($match?->relatedFile)->toBe('App\\Http\\OrderController'); +}); + +it('leaves related_file null when absent or not a string', function (): void { + expect(PatternMatch::fromArray(pmtRaw(), pmtUnit(), pmtRepo())?->relatedFile)->toBeNull() + ->and(PatternMatch::fromArray(pmtRaw(['related_file' => 123]), pmtUnit(), pmtRepo())?->relatedFile)->toBeNull(); +}); diff --git a/tests/Unit/Analyze/PatternMatcherTest.php b/tests/Unit/Analyze/PatternMatcherTest.php index 6232874..799c093 100644 --- a/tests/Unit/Analyze/PatternMatcherTest.php +++ b/tests/Unit/Analyze/PatternMatcherTest.php @@ -143,3 +143,16 @@ function pmCleanup(string $base): void pmCleanup($base); } }); + +it('identifies catch-all-import patterns as graph-level and partitions them', function (): void { + $matcher = new PatternMatcher('/work'); + $arch = pmPattern('layer-dependency-direction', [['type' => 'import', 'value' => '**/*']]); + $scoped = pmPattern('service-layer', [['type' => 'import', 'value' => 'App\\Services\\*']]); + $fileScoped = pmPattern('dry', [['type' => 'file', 'value' => '**/*.php']]); + + expect($matcher->isGraphLevel($arch))->toBeTrue() + ->and($matcher->isGraphLevel($scoped))->toBeFalse() + ->and($matcher->isGraphLevel($fileScoped))->toBeFalse() + ->and($matcher->graphLevel([$arch, $scoped, $fileScoped]))->toHaveCount(1) + ->and($matcher->graphLevel([$arch, $scoped, $fileScoped])[0]->key)->toBe('layer-dependency-direction'); +}); diff --git a/tests/Unit/Analyze/PhpFileInspectorTest.php b/tests/Unit/Analyze/PhpFileInspectorTest.php index 5ffddad..11c50eb 100644 --- a/tests/Unit/Analyze/PhpFileInspectorTest.php +++ b/tests/Unit/Analyze/PhpFileInspectorTest.php @@ -49,3 +49,21 @@ ->and(PhpFileInspector::declaresClass(" 1];"))->toBeFalse() ->and(PhpFileInspector::declaresClass("toBeFalse(); }); + +it('builds the FQCN from namespace + declared type', function (): void { + expect(PhpFileInspector::fqcn("toBe('App\\Services\\OrderService'); +}); + +it('builds the FQCN for a namespaced interface', function (): void { + expect(PhpFileInspector::fqcn("toBe('App\\Contracts\\InventoryChecker'); +}); + +it('returns a bare class name when there is no namespace', function (): void { + expect(PhpFileInspector::fqcn("toBe('Foo'); +}); + +it('returns null when the file declares no type', function (): void { + expect(PhpFileInspector::fqcn(" 1];\n"))->toBeNull(); +}); From ddc8d616d3a48356f56d185dc3f8bf2c43b40e35 Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 16:37:52 -0300 Subject: [PATCH 12/21] feat(patterns): R4 high-impact corpus for contractor-dev review (G3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 2 R4. Six AST-invisible patterns at the center of the goal — policing a contractor dev who does NOT use AI. PHPStan/Deptrac cannot see any of these; they need semantic judgement, which is exactly what the analyze engine adds. - mass-assignment (critical): Model::create($request->all()) / $guarded = [] - raw-sql-injection (critical): input interpolated into whereRaw/DB::raw/statement - missing-authorization (critical): store/update/destroy with no authorize()/policy - eloquent-n-plus-one (warning): relation queried inside a loop, no eager load - missing-database-transaction (warning): related writes not wrapped in transaction - unbounded-query (warning): Model::all()/->get() on a growing table, no paginate/chunk Security smells gate by default (critical); perf/integrity smells report (warning) to limit false-positive blocking. Each carries verification.rules + correct/violation few-shot examples and file/directory detection signals (never catch-all import, so they stay per-file, not graph-level). Corpus: 28 -> 34 patterns. Selection coverage asserted in PatternSelectionCoverageTest; recall is the manual ritual (docs/patterns-recall.md, new R4 priority table). 493 tests (+1 selection), PHPStan level 5 clean, Pint clean. --- docs/patterns-recall.md | 19 +++++++ .../php-laravel/eloquent-n-plus-one.yaml | 44 +++++++++++++++ .../patterns/php-laravel/mass-assignment.yaml | 55 +++++++++++++++++++ .../php-laravel/missing-authorization.yaml | 51 +++++++++++++++++ .../missing-database-transaction.yaml | 53 ++++++++++++++++++ .../php-laravel/raw-sql-injection.yaml | 43 +++++++++++++++ .../patterns/php-laravel/unbounded-query.yaml | 48 ++++++++++++++++ .../Analyze/PatternSelectionCoverageTest.php | 24 ++++++++ 8 files changed, 337 insertions(+) create mode 100644 resources/patterns/php-laravel/eloquent-n-plus-one.yaml create mode 100644 resources/patterns/php-laravel/mass-assignment.yaml create mode 100644 resources/patterns/php-laravel/missing-authorization.yaml create mode 100644 resources/patterns/php-laravel/missing-database-transaction.yaml create mode 100644 resources/patterns/php-laravel/raw-sql-injection.yaml create mode 100644 resources/patterns/php-laravel/unbounded-query.yaml diff --git a/docs/patterns-recall.md b/docs/patterns-recall.md index 8f12548..0c85828 100644 --- a/docs/patterns-recall.md +++ b/docs/patterns-recall.md @@ -29,4 +29,23 @@ Two halves to "does it catch what my contractor breaks?": | no-logic-in-blade | _tbd_ | _tbd_ | | | … | | | | +### R4 — high-impact contractor-dev corpus (priority — center of goal G3) + +These are AST-invisible and the reason `analyze` exists; validate them first. + +| Pattern | Known-bad fixture | Caught? | False positive risk to watch | +|---|---|:---:|---| +| mass-assignment | `Model::create($request->all())` | _tbd_ | DTO/`validated()` calls that aren't actually `->all()` | +| raw-sql-injection | `whereRaw("x = '{$input}'")` | _tbd_ | raw fragments built only from constants (must NOT flag) | +| missing-authorization | `update()` with no `authorize()` | _tbd_ | a FormRequest `authorize()` that already gates (must NOT flag) | +| eloquent-n-plus-one | relation access inside `foreach` | _tbd_ | relations already eager-loaded with `with()` then read in a loop | +| missing-database-transaction | parent+children writes, no `transaction()` | _tbd_ | a single isolated write (must NOT flag) | +| unbounded-query | `Model::all()` on a growing table | _tbd_ | small fixed lookup tables / already-`limit()`ed queries | + +### R1/R2/R3 — precision levers to exercise during the field run + +- **R1 voting** (`--samples=3`): does keeping ≥2/3 agreement drop the flaky findings? Record raw-vs-survived counts. +- **R2 critique** (`--critique`): does the re-score pass kill obvious false positives (score 0)? +- **R3 architecture** (`--all`): do `layer-dependency-direction` / `bounded-contexts` / `no-circular-dependencies` fire off the namespace graph, and are the detected `cycles` real? + **Model:** _tbd_ · **Date:** _tbd_ · **False positives observed:** _tbd_ diff --git a/resources/patterns/php-laravel/eloquent-n-plus-one.yaml b/resources/patterns/php-laravel/eloquent-n-plus-one.yaml new file mode 100644 index 0000000..8c8bf60 --- /dev/null +++ b/resources/patterns/php-laravel/eloquent-n-plus-one.yaml @@ -0,0 +1,44 @@ +name: eloquent-n-plus-one +description: Eager-load relationships instead of querying them inside loops (N+1) +category: framework +layer: laravel +severity: warning +classification: mvp + +detection: + signals: + - type: file + value: "app/**/*.php" + - type: directory + value: app/Http/Resources + confidence: medium + +verification: + rules: + - accessing an Eloquent relationship inside a loop without eager-loading runs one query per iteration + - list/index endpoints that render related data must eager-load those relations with ->with('relation') or ->load() + - count related rows with withCount() instead of calling ->count() on a relation per row + - a query builder call (where/first/get) inside a foreach over models is a strong N+1 signal + +examples: + correct: | + // Relations eager-loaded once, then read from memory in the loop + $orders = Order::with(['customer', 'lines.product']) + ->withCount('shipments') + ->get(); + + foreach ($orders as $order) { + echo $order->customer->name; // no query — already loaded + echo $order->shipments_count; // no query — aggregated up front + } + violation: | + // One query for customer AND one for shipments on EVERY iteration + $orders = Order::all(); + + foreach ($orders as $order) { + echo $order->customer->name; // N queries + echo $order->shipments()->count(); // N more queries + } + +related_patterns: + - unbounded-query diff --git a/resources/patterns/php-laravel/mass-assignment.yaml b/resources/patterns/php-laravel/mass-assignment.yaml new file mode 100644 index 0000000..a8e646b --- /dev/null +++ b/resources/patterns/php-laravel/mass-assignment.yaml @@ -0,0 +1,55 @@ +name: mass-assignment +description: Never persist unfiltered request input — write only validated, whitelisted attributes +category: framework +layer: laravel +severity: critical +classification: mvp + +detection: + signals: + - type: file + value: "app/**/*.php" + - type: directory + value: app/Http/Controllers + confidence: high + +verification: + rules: + - never pass unfiltered request data to create()/update()/fill() (e.g. Model::create($request->all())) + - write only validated, explicitly-listed attributes — FormRequest::validated() or a typed DTO, never $request->all() + - a model that accepts writes must define an explicit $fillable allowlist (an empty $guarded = [] re-opens every column) + - a privileged column (is_admin, role, user_id, account_id) must never be settable from request input + +examples: + correct: | + // Only validated, named fields reach the model + public function store(StoreOrderRequest $request): RedirectResponse + { + $data = $request->validated(); // whitelisted by the FormRequest + $order = Order::create([ + 'reference' => $data['reference'], + 'total' => $data['total'], + 'user_id' => $request->user()->id, // set server-side, not from input + ]); + + return redirect()->route('orders.show', $order); + } + violation: | + // The whole request body is trusted — a crafted `is_admin` / `user_id` is persisted + public function store(Request $request): RedirectResponse + { + $order = Order::create($request->all()); // mass-assignment vulnerability + $request->user()->update($request->all()); // same flaw on update + + return redirect()->route('orders.index'); + } + + // Model that re-opens every attribute + class Order extends Model + { + protected $guarded = []; // nothing is protected + } + +related_patterns: + - form-requests + - dto diff --git a/resources/patterns/php-laravel/missing-authorization.yaml b/resources/patterns/php-laravel/missing-authorization.yaml new file mode 100644 index 0000000..c00b050 --- /dev/null +++ b/resources/patterns/php-laravel/missing-authorization.yaml @@ -0,0 +1,51 @@ +name: missing-authorization +description: State-changing actions must authorize the user before they write +category: framework +layer: laravel +severity: critical +classification: mvp + +detection: + signals: + - type: directory + value: app/Http/Controllers + - type: file + value: "app/Http/Controllers/**/*.php" + confidence: high + +verification: + rules: + - every state-changing action (store, update, destroy, and custom writes) must authorize the user via authorize(), Gate, a Policy, $user->can(), or a FormRequest authorize() that actually checks + - ownership must be enforced server-side — a record fetched by id must be checked to belong to (or be permitted for) the current user before it is mutated + - authentication (logged in) is not authorization (allowed to do THIS) — a route behind `auth` still needs a per-record permission check + - never rely on a hidden form field, a non-guessable id, or the UI hiding a button as the access control + +examples: + correct: | + // Authorizes against a policy, and only then mutates the owned record + public function update(UpdateOrderRequest $request, Order $order): RedirectResponse + { + $this->authorize('update', $order); // Policy decides; throws 403 otherwise + + $order->update($request->validated()); + + return redirect()->route('orders.show', $order); + } + violation: | + // Any authenticated user can update or delete ANY order by guessing the id + public function update(Request $request, int $id): RedirectResponse + { + $order = Order::findOrFail($id); // no ownership / policy check + $order->update($request->validated()); + + return redirect()->route('orders.index'); + } + + public function destroy(int $id): RedirectResponse + { + Order::findOrFail($id)->delete(); // no authorization at all + return back(); + } + +related_patterns: + - policies diff --git a/resources/patterns/php-laravel/missing-database-transaction.yaml b/resources/patterns/php-laravel/missing-database-transaction.yaml new file mode 100644 index 0000000..46046ee --- /dev/null +++ b/resources/patterns/php-laravel/missing-database-transaction.yaml @@ -0,0 +1,53 @@ +name: missing-database-transaction +description: Wrap multiple related writes in a transaction so they all succeed or all roll back +category: framework +layer: laravel +severity: warning +classification: mvp + +detection: + signals: + - type: file + value: "app/**/*.php" + - type: directory + value: app/Actions + confidence: medium + +verification: + rules: + - two or more writes that must be consistent (parent + children, debit + credit, order + stock decrement) must run inside DB::transaction() + - a failure between separate save()/create()/update() calls without a transaction can leave partial, corrupt data + - external side effects (mail, dispatched jobs, HTTP calls) must fire AFTER the transaction commits, not inside it + - a single isolated write does not need a transaction — do not flag it + +examples: + correct: | + // Both writes commit together or not at all; the email fires after commit + public function place(OrderData $data): Order + { + $order = DB::transaction(function () use ($data) { + $order = Order::create($data->toArray()); + $order->lines()->createMany($data->lines); + Inventory::where('product_id', $data->productId)->decrement('stock', $data->quantity); + + return $order; + }); + + OrderPlaced::dispatch($order); // side effect AFTER commit + + return $order; + } + violation: | + // A failure on the second/third write leaves an order with no lines and untouched stock + public function place(OrderData $data): Order + { + $order = Order::create($data->toArray()); + $order->lines()->createMany($data->lines); // if this throws, the order above is orphaned + Inventory::where('product_id', $data->productId)->decrement('stock', $data->quantity); + + return $order; + } + +related_patterns: + - service-layer + - action-classes diff --git a/resources/patterns/php-laravel/raw-sql-injection.yaml b/resources/patterns/php-laravel/raw-sql-injection.yaml new file mode 100644 index 0000000..f112a30 --- /dev/null +++ b/resources/patterns/php-laravel/raw-sql-injection.yaml @@ -0,0 +1,43 @@ +name: raw-sql-injection +description: Never interpolate request data into raw SQL — use bindings or the query builder +category: framework +layer: laravel +severity: critical +classification: mvp + +detection: + signals: + - type: file + value: "app/**/*.php" + - type: file + value: "database/**/*.php" + confidence: high + +verification: + rules: + - never concatenate or interpolate request input into whereRaw / selectRaw / orderByRaw / havingRaw / DB::raw / DB::statement / DB::select + - pass user values as bindings (the second argument array), or use the fluent query builder, so the driver parameterizes them + - column and table names cannot be bound — validate them against a fixed allowlist, never pass raw user input as an identifier + - a raw fragment built only from constant strings is acceptable; a raw fragment containing a variable derived from input is a SQL-injection risk + +examples: + correct: | + // User value travels as a binding; the column is taken from a fixed allowlist + $sort = in_array($request->input('sort'), ['name', 'created_at'], true) + ? $request->input('sort') + : 'created_at'; + + $users = User::whereRaw('LOWER(email) = ?', [strtolower($request->input('email'))]) + ->orderBy($sort) + ->get(); + violation: | + // Request input concatenated straight into the SQL string — classic injection + $email = $request->input('email'); + $users = User::whereRaw("email = '{$email}'")->get(); + + DB::statement("UPDATE users SET role = '{$request->input('role')}' WHERE id = {$request->input('id')}"); + + $users = DB::select('select * from users order by '.$request->input('sort')); + +related_patterns: + - mass-assignment diff --git a/resources/patterns/php-laravel/unbounded-query.yaml b/resources/patterns/php-laravel/unbounded-query.yaml new file mode 100644 index 0000000..67a18c0 --- /dev/null +++ b/resources/patterns/php-laravel/unbounded-query.yaml @@ -0,0 +1,48 @@ +name: unbounded-query +description: Paginate or chunk large result sets — never load an unbounded table into memory +category: framework +layer: laravel +severity: warning +classification: mvp + +detection: + signals: + - type: file + value: "app/**/*.php" + - type: directory + value: app/Http/Controllers + confidence: medium + +verification: + rules: + - user-facing listings must paginate (paginate() / cursorPaginate() / simplePaginate()) instead of returning ->get() or ->all() + - never return Model::all() (or an unconstrained ->get()) for a table that grows with usage + - background processing over a large table must chunk() / chunkById() / lazy() instead of loading every row at once + - a query already bounded by a clear constraint (a unique key, a small fixed lookup table, ->limit()/->take()) is acceptable + +examples: + correct: | + // The list endpoint pages the result; the batch job streams in chunks + public function index(): View + { + $orders = Order::latest()->paginate(25); + return view('orders.index', compact('orders')); + } + + Order::where('status', 'pending')->chunkById(500, function ($orders) { + $orders->each->process(); + }); + violation: | + // Loads the entire (unbounded, growing) orders table into memory on every hit + public function index(): View + { + $orders = Order::all(); // or ->get() with no limit + return view('orders.index', compact('orders')); + } + + foreach (User::all() as $user) { // whole table in memory for a batch job + $user->recalculateStats(); + } + +related_patterns: + - eloquent-n-plus-one diff --git a/tests/Unit/Analyze/PatternSelectionCoverageTest.php b/tests/Unit/Analyze/PatternSelectionCoverageTest.php index 4748305..3a9a4ad 100644 --- a/tests/Unit/Analyze/PatternSelectionCoverageTest.php +++ b/tests/Unit/Analyze/PatternSelectionCoverageTest.php @@ -89,3 +89,27 @@ function pscCleanup(string $base): void pscCleanup($base); } }); + +it('attaches the high-impact contractor-dev patterns to a controller (R4 corpus)', function (): void { + $base = sys_get_temp_dir().DIRECTORY_SEPARATOR.'codeguard-psc-r4-'.uniqid(); + pscWrite($base, 'app/Http/Controllers/OrderController.php', "forPresets(['core', 'php', 'php-laravel']); + + $units = (new PatternMatcher($base))->match([$base.'/app/Http/Controllers/OrderController.php'], $patterns); + $keys = $units[0]->patternKeys() ?? []; + + expect($keys)->toContain( + 'mass-assignment', + 'missing-authorization', + 'raw-sql-injection', + 'eloquent-n-plus-one', + 'unbounded-query', + 'missing-database-transaction', + ); + } finally { + pscCleanup($base); + } +}); From 70365058e733183194c7cb4f3efd3a947024f415 Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 16:40:12 -0300 Subject: [PATCH 13/21] =?UTF-8?q?docs(memory):=20Tier=202=20R1-R4=20comple?= =?UTF-8?q?te=20=E2=80=94=20status=20+=20handoff=20synced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canonical status: HEAD ddc8d61, suite 493/1175, Tier 2 phase row added, Analyze layer (15 classes incl FindingVoter+NamespaceGraph), corpus 28->34, scorecard pattern-review ~80% (capped pending field recall). Handoff: dedicated Tier 2 section (R1 voting / R2 critique / R3 graph / R4 corpus) + next-action = field validation. patterns-recall.md already carries the R4 priority table. --- .ai/memory/PROJECT-STATUS.md | 25 +++++++++++++------------ .ai/memory/SESSION-HANDOFF.md | 31 +++++++++++++++++++------------ 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/.ai/memory/PROJECT-STATUS.md b/.ai/memory/PROJECT-STATUS.md index c11fe60..5037789 100644 --- a/.ai/memory/PROJECT-STATUS.md +++ b/.ai/memory/PROJECT-STATUS.md @@ -8,10 +8,10 @@ type: project > **Para Claude**: Este é o documento vivo de estado. Leia na primeira ferramenta-call de toda sessão substantiva. Atualize ao completar qualquer commit que mude escopo, ou ao mudar de sprint/foco. Em caso de conflito com outro arquivo de memória, este ganha (pra resolver drift, corrija o outro arquivo, não aqui). -**Última atualização**: 2026-06-03 (audit + replan + **Fase 1 traits** e **Fase 2 Patterns engine MVP** shippados na mesma sessão) -**HEAD**: `abfce20` feat(analyze): trust threshold — exact-path attribution, real use-parsing, baseline -**Branch**: `feat/patterns-engine-foundation` (**PR #1** aberto pra `main`; **precisa push** do commit `abfce20`). `origin/main` == `4b32886`. -**Suite**: 452 tests / 1090 assertions (verde). Pint clean, PHPStan level 5 No errors. Coverage gate (≥80%) só roda no CI (sem driver Xdebug/pcov local). +**Última atualização**: 2026-06-03 (audit + replan + Fase 1 traits + Fase 2 MVP + **Tier 2 R1–R4 completo** — tudo na mesma sessão) +**HEAD**: `ddc8d61` feat(patterns): R4 high-impact corpus for contractor-dev review (G3) +**Branch**: `feat/patterns-engine-foundation` (**PR #1** aberto pra `main`; **pushed e sincronizado** — `origin == HEAD`). `origin/main` == `4b32886`. +**Suite**: 493 tests / 1175 assertions (verde). Pint clean, PHPStan level 5 No errors. Coverage gate (≥80%) só roda no CI (sem driver Xdebug/pcov local). **Lint/Static**: Pint clean. PHPStan level 5 self-applied com baseline grandfathered (`156b297`) — R8 fechado. `composer ci` roda pint:test + phpstan + test:coverage; CI ativa em PHP 8.3 + 8.4 via `.github/workflows/ci.yml` (`65893ab`). **Release publicado**: ✅ **`0.2.0` no Packagist desde 2026-05-04** (tag `0.2.0` @ `4b32886`, pushed). Arch consome via repo `vcs` GitHub pinado em `^0.2.0` (lock @ `4b32886`) — **NÃO** via path repo nem `dev-main`. @@ -19,7 +19,7 @@ type: project --- -## 🎯 Sprint Atual: Track A — Patterns engine (package-side) (2026-06-03 — em curso) +## 🎯 Sprint Atual: Track A — Patterns engine (package-side) (2026-06-03 — **construção completa; aguarda validação de campo**) **Decisão estratégica do usuário (2026-06-03)**: construir o **diferencial primeiro** (Patterns/LLM review — o lado *reviewer* da meta G3, "policiar dev terceirizado que não usa IA"). + **constraint dura**: *NÃO tocar no Arch agora* (projeto grande em dev lá). Implementar **tudo que for package-side** antes de tratar integração. @@ -32,6 +32,7 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. | **0** | Limpeza canônica (este arquivo + docs stale) | ❌ | ✅ feito (status reescrito; handoff/specs ainda stale — backlog) | | **1** | Bug fixes package-side | ❌ (lê Arch só como ref) | 🟡 traits ✅; `coverage_percent -1` + dead config ainda fila | | **2** | **Patterns engine** (`src/Analyze/*` + `codeguard:analyze`) — MVP A–C **+ Increment D context-emit** | ❌ | ✅ shippado (`0dfb953` MVP, `18c4492` context-emit). Transporte = **context-emit** (assinatura, sem API metered) decidido + construído | +| **2.5** | **Tier 2 — profundidade** (R1 voting · R2 critique · R3 grafo namespace · R4 corpus alto-impacto) | ❌ | ✅ **shippado** (`a3202fb` R1, `f8a7e0e` R2, `cdca3b5` R3, `ddc8d61` R4). Mecânica testada em CI; **qualidade do julgamento = validação de campo manual pendente** | | **3** | Schema dump (`codeguard:prepare`) + AI rules generator | ❌ (testáveis via fixtures/SQLite) | ⏸️ depois | | **4** | 🔒 Integração Arch + dogfood real | ✅ | ⛔ **ADIADO** (constraint do usuário) | @@ -48,11 +49,11 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. ### Próxima ação concreta -1. **`git push`** o commit `abfce20` (não pushed; atualiza o PR #1). -2. **Validar em campo**: rodar `/codeguard-review` num projeto real (emit → subagentes em lote → ingest) e preencher `docs/patterns-recall.md` (o lado "será que pega o smell?" NÃO é testável em CI — só com sessão Claude Code real). -3. **Tier 2 — profundidade** (torna "genuinamente alto valor", ~10d, validado à mão): R1 voting multi-sample (deriva confiança de vote-share) → R2 critique pass → R3 grafo namespace→layer (liga de verdade os 3 patterns arquiteturais) → R4 corpus de alto impacto p/ terceirizado (N+1, mass-assignment, missing transaction, SQL cru, missing authz, `->get()` sem limite). Detalhe completo: ver handoff + roadmap abaixo. +1. **Validação de campo (Tier 2)** — único caminho pra fechar o gap restante. Rodar `/codeguard-review` num projeto real (de preferência com `--samples=3 --critique` e `--all` pro grafo arquitetural) e preencher `docs/patterns-recall.md`. **A qualidade do julgamento NÃO é testável em CI** (assinatura, sem API metered) — só sessão Claude Code real. Prioridade: os 6 patterns R4 (mass-assignment, raw-sql-injection, missing-authorization, N+1, missing-transaction, unbounded-query) — centro da meta G3. +2. **Revisar/mergear PR #1** (Fases 1+2 + Increment D + trust threshold + **Tier 2 R1–R4**, tudo no mesmo branch). Decisão do usuário. +3. **Depois**: Fase 3 (schema dump `codeguard:prepare` + ai-rules generator) ou Fase 4 (integração Arch, quando o usuário liberar). Backlog menor: `coverage_percent -1`, config morto. -**Decisão do usuário pendente**: revisar/mergear **PR #1** (Fases 1+2 + Increment D + trust threshold, tudo no mesmo branch). +**Decisão do usuário pendente**: revisar/mergear **PR #1**. **Backlog package-side (sem bloquear)**: `coverage_percent -1` em `CodeguardTestCommand.php:102`; config morto `ai_rules`/`prepare`; Fase 3 (schema dump + ai-rules generator); re-scope conservador dos patterns Laravel "invertidos" (precisa validação de campo — adiado por risco de FP). @@ -84,7 +85,7 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. | `Hooks\*` | 🟡 parcial | StagedPhpFilesRunner existe | | `Testing\*` | ✅ completo | TestSuiteRunner generalizado + StageConfig (8 campos) + executors + DTOs | | `Assertions\*` | ✅ | AntiPatternScanner + 2 traits implementados (7 checks reais, 21 tests). `0dfb953`/`4c662a0`. | -| `Analyze\*` | ✅ | 13 classes (loader/scope/matcher/trust-boundary/runner/command). Consome os 28 patterns (2 outliers pulados). Modos: review síncrono + `buildWorkOrder()`/`ingest()` (context-emit). 37 tests. | +| `Analyze\*` | ✅ | 15 classes (loader/scope/matcher/trust-boundary/runner/command + **FindingVoter** R1 + **NamespaceGraph** R3). Consome 34 patterns (2 outliers pulados). Modos: review síncrono + `buildWorkOrder()`/`ingest()`/`ingestSamples()` (context-emit). **Tier 2**: voting (`--samples`, vote-share confidence), critique (`--critique`, `verified_score`, dropa 0), grafo namespace→layer (`graph` no work order, cycle detection, liga os 3 patterns arquiteturais), `related_file`. ~95 tests Analyze. | | `AiRules\*` | ❌ duplo-morto | config targets existe + `resources/rules/` VAZIA (0/7 markdowns, sem git history). Fase 3. | | `Schema\*` | ❌ ausente | só `PrepareConfig` DTO (4 campos). Fase 3. | @@ -93,7 +94,7 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. | Caminho | Status | |---------|:-----:| | `resources/stubs/*.stub` | ✅ stubs (pint, phpstan, phpstan-test-quality, deptrac, infection, captainhook+README, phpunit, jscpd, CI workflow, TestQualityTest) | -| `resources/patterns/**/*.yaml` | ✅ **30** (data dormente até Patterns engine) | +| `resources/patterns/**/*.yaml` | ✅ **36** YAMLs (34 patterns + 2 outliers) — +6 high-impact R4 (mass-assignment, raw-sql-injection, missing-authorization, eloquent-n-plus-one, missing-database-transaction, unbounded-query) | | `resources/skills/*/SKILL.md` | ✅ `codeguard-review` (orquestra emit→subagentes→ingest). As 3 Node-era removidas (`18c4492`). Publicáveis via tag `codeguard-skills` → `.claude/skills`. | | `resources/rules/*.md` | ❌ 0/7 (dir vazia) | @@ -104,7 +105,7 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. | Perspectiva | Real | Justificativa | |---|:-:|---| | "install + rodar gates + rodar tests" | **~80%** | Commands reais, installer ~900 LOC, telemetria completa, 377 tests verdes. Descontado: traits lançam exception, `coverage_percent -1`, e o único consumer **não roda** check/test. | -| "pattern-based LLM review" (o diferencial) | **~70%** | Camada determinística sólida + testada: seleção (use-parsing real), atribuição exata, baseline/supressão, scope-coverage test. Falta: **validação de campo** (recall manual, não testável em CI) + **Tier 2** (voting/critique/grafo arquitetural/corpus de segurança) que separa "confiável" de "alto valor". (Antes superestimei em ~80% — corrigido pelo audit de completude.) | +| "pattern-based LLM review" (o diferencial) | **~80%** | Camada determinística agora **completa + testada**: seleção (use-parsing real), atribuição exata, baseline/supressão, **+ Tier 2**: voting multi-sample (vote-share), critique pass (verified_score), grafo namespace→layer + cycle detection (liga os 3 patterns arquiteturais), corpus de alto impacto R4 (6 smells AST-invisíveis no centro de G3). Tudo coberto por ~95 tests Analyze. **Único gap restante: validação de campo** (recall/precision real — não testável em CI, só sessão Claude Code com assinatura). Teto em 80% até medir recall à mão. | | "AI rules generator" | **~3%** | duplo-morto: `src/AiRules/` ausente + `resources/rules/` vazia | | "schema dump multi-DB" | **~8%** | só `PrepareConfig` DTO | | "publicar/distribuir" | **~85%** | genuinamente no Packagist, tagged, lockável, Node-free. Descontado: footprint `.codeguard/` é git-ignored (não cruza máquinas), 0 downloads, único consumer bypassa a CLI | diff --git a/.ai/memory/SESSION-HANDOFF.md b/.ai/memory/SESSION-HANDOFF.md index ada1dd4..4f038a7 100644 --- a/.ai/memory/SESSION-HANDOFF.md +++ b/.ai/memory/SESSION-HANDOFF.md @@ -16,13 +16,14 @@ type: project - **A primeiro** (Patterns engine = o diferencial). - **Transporte LLM = context-emit** (assinatura Claude Code, SEM API metered). `claude -p` está **fora** (vira API metered no próximo mês). `anthropic-ai/sdk` está fora (metered). - **Reverter o "AI findings never baselined"** (explícito + auditável). -3. **Shippado nesta sessão** (branch `feat/patterns-engine-foundation`, PR #1, 6 commits): +3. **Shippado nesta sessão** (branch `feat/patterns-engine-foundation`, PR #1, **pushed e sincronizado** em `ddc8d61`): - `4c662a0` Fase 1 — assertion traits reais (`AntiPatternScanner`); eram landmine que lançava exception. - `0dfb953` Patterns engine MVP (`src/Analyze/*` + `codeguard:analyze`, Thin Adjudicator). - `18c4492` context-emit (`--emit`/`--ingest` + skill `codeguard-review`; removeu 3 skills Node-era → fechou R11). - - `abfce20` **trust threshold (Tier 0+1)** — ver abaixo. ⚠️ **não pushed ainda**. - - + 2 commits docs. - - Suite **452 verdes / 1090 assertions**, Pint clean, PHPStan level 5 No errors. + - `abfce20` **trust threshold (Tier 0+1)** — atribuição exata, use-parsing real, baseline. + - `a3202fb` `f8a7e0e` `cdca3b5` `ddc8d61` — **Tier 2 R1–R4** (ver seção dedicada abaixo). + - + commits docs. + - Suite **493 verdes / 1175 assertions**, Pint clean, PHPStan level 5 No errors. ## Arquitetura do Patterns engine (já construída) @@ -39,16 +40,22 @@ Incorpora as correções do crítico adversarial do workflow `patterns-engine-co - **T4 baseline** — `AnalyzeBaseline`, `--accept`, mostra "N suprimidos". Fingerprint = `sha1(pattern_key + arquivo_relativo)` — **sem mensagem, sem linha** (correção do crítico: senão o LLM reformula e o finding ressurge). - **Teste de cobertura de seleção** (parte honesta automatizável) + `docs/patterns-recall.md` (recall manual). +## Tier 2 — TODOS shippados (mesma sessão, 4 commits) + +Construído depois do trust threshold; PR #1 atualizado e pushed (`origin == HEAD ddc8d61`). Mecânica 100% testada em CI; **qualidade do julgamento NÃO**. + +- **R1 voting multi-sample** (`a3202fb`) — `FindingVoter` agrega k samples (identidade `pattern_key|file|line`; dup no mesmo sample = 1 voto), mantém ≥`ceil(2k/3)`, confiança = vote-share (NÃO a verbalizada). `--samples=k` (cap 1–9) no emit; ingest detecta envelope `{samples:[[...]]}` vs `{findings:[...]}` legado (backward-compat). `ingestSamples()` valida cada sample no trust boundary ANTES de votar. +- **R2 critique pass** (`f8a7e0e`) — `verified_score` 0–10 opcional no FindingSchema/PatternMatch; `surviveCritique()` dropa score 0 em `finish()` (uniforme aos 3 paths). `--critique` flag → `critique:true` no work order. Compõe com voting (vota → dropa 0). Display `[score N/10]`. +- **R3 grafo namespace→layer** (`cdca3b5`) — `NamespaceGraph` parseia use-edges first-party (vendor fora) → adjacência + cycle detection (DFS back-edge). `PhpFileInspector::fqcn()`. `matcher->graphLevel()/isGraphLevel()` (catch-all import = arquitetural). Work order emite `graph{nodes,edges,cycles}` + `architecture.patterns`. Ingest cria architectural unit por class-file scoped sem per-file unit → atribui os 3 patterns arquiteturais via trust boundary. `related_file` opcional. +- **R4 corpus alto-impacto** (`ddc8d61`) — 6 YAMLs em `resources/patterns/php-laravel/`: mass-assignment, raw-sql-injection, missing-authorization (critical/gate), eloquent-n-plus-one, missing-database-transaction, unbounded-query (warning/report). Signals file/directory (nunca catch-all import → ficam per-file). Corpus 28→34. + +Skill `codeguard-review` atualizada com Steps de voting (4 + envelope samples), critique (5b) e architecture (4b). + ## PRÓXIMO (em ordem) -1. **`git push`** (`abfce20` não pushed → atualiza PR #1). -2. **Validação de campo**: rodar `/codeguard-review` num projeto real, preencher `docs/patterns-recall.md`. ⚠️ **A qualidade do julgamento NÃO é testável em CI** (assinatura, sem API metered) — só com sessão Claude Code real. Toda melhoria de precisão (Tier 2) só se valida à mão. -3. **Tier 2 — profundidade (~10d, "genuinamente alto valor")**, nesta ordem (cada um reusa infra do anterior): - - **R1 voting multi-sample** (~2,5d): emitir k=3, manter findings ≥2/3, derivar confiança de vote-share (NÃO da confiança verbalizada — é miscalibrada). Default `--samples=1`, opt-in `--samples=3` (Fork A). - - **R2 critique pass** (~2d): 2º subagente re-pontua 0–10, dropa 0. `verified_score` no FindingSchema. - - **R3 grafo namespace→layer** (~3d): parsear `use` edges (reusa PhpFileInspector) num mapa de adjacência; emitir no work order; ligar de verdade bounded-contexts/layer-dependency-direction/no-circular-dependencies (hoje excluídos). `related_file` opcional no FindingSchema. - - **R4 corpus de alto impacto** (~3d): N+1, mass-assignment (`Model::create($request->all())`), missing transaction, SQL cru/`DB::raw` interpolado, missing authz em writes, `->get()` sem limite. Invisível ao AST, no centro da meta G3. -4. **Backlog menor**: `coverage_percent -1` (`CodeguardTestCommand.php:102`); config morto `ai_rules`/`prepare`; Fase 3 (schema dump + ai-rules generator). +1. **Validação de campo** — o ÚNICO gap restante do Track A. Rodar `/codeguard-review` num projeto real (idealmente `--samples=3 --critique` + `--all` pro grafo) e preencher `docs/patterns-recall.md` (já tem tabela R4 priorizada). ⚠️ recall/precision NÃO é testável em CI — só sessão Claude Code com assinatura. +2. **Revisar/mergear PR #1** (decisão do usuário). +3. **Backlog**: `coverage_percent -1` (`CodeguardTestCommand.php:102`); config morto `ai_rules`/`prepare`; Fase 3 (schema dump + ai-rules generator); Fase 4 (Arch, quando liberado). ## NÃO construir (decidido) API metered como caminho default · embeddings p/ dry · calibrador de confiança (derive de voto) · cache de resultado · `--format=github` (sem CI confirmado) · auto-fix · UI de config por-pattern. Re-scope agressivo dos patterns Laravel "invertidos" = adiado (risco de FP, precisa campo). From 104d867407d5992142321ad300ceb853a7679ddb Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 17:26:12 -0300 Subject: [PATCH 14/21] fix(analyze): close trust-boundary, attribution & lint gaps (codex review) Cross-model adversarial review (codex, gpt-5-codex) of PR #1 surfaced three correctness gaps; all fixed with TDD (RED->GREEN), suite 496 passing. - F-001 PatternMatch::fromArray accepted ANY known corpus key via PatternRepository::has(), letting a subagent return a finding for a pattern the file was never scoped to review. Third param is now an explicit list extraAllowedKeys (the run's graph-level keys); AnalyzeRunner threads graphLevelKeys() into run/ingest/ingestSamples. - F-002 AnalyzeRunner::findUnit dropped architectural findings on basename collision: NamespaceGraph emits working-dir-relative paths while AnalysisUnit holds absolute paths, so the exact match never hit and the basename fallback was ambiguous. findUnit now resolves the relative path against the working directory before falling back. - F-003 AntiPatternScanner::bareAssertNotNull flagged every assertNotNull($x); even when followed by a behavioural assertion, contradicting its own contract. Rewritten with look-ahead: flags only when the value is not referenced by a later assert/expect. Review artifact + briefings persisted under .atomic-skills/reviews/. --- ...6-06-03-1647-patterns-engine-foundation.md | 626 ++++++++++++++++++ .atomic-skills/reviews/INDEX.md | 5 + src/Analyze/AnalyzeRunner.php | 66 +- src/Analyze/PatternMatch.php | 12 +- src/Assertions/AntiPatternScanner.php | 67 +- tests/Unit/Analyze/AnalyzeRunnerTest.php | 12 + tests/Unit/Analyze/PatternMatchTest.php | 64 +- .../Assertions/AntiPatternScannerTest.php | 16 + 8 files changed, 807 insertions(+), 61 deletions(-) create mode 100644 .atomic-skills/reviews/2026-06-03-1647-patterns-engine-foundation.md create mode 100644 .atomic-skills/reviews/INDEX.md diff --git a/.atomic-skills/reviews/2026-06-03-1647-patterns-engine-foundation.md b/.atomic-skills/reviews/2026-06-03-1647-patterns-engine-foundation.md new file mode 100644 index 0000000..8b36d1b --- /dev/null +++ b/.atomic-skills/reviews/2026-06-03-1647-patterns-engine-foundation.md @@ -0,0 +1,626 @@ +--- +date: 2026-06-03T16:47:42-03:00 +topic: patterns-engine-foundation +artifact: feat/patterns-engine-foundation (vs merge-base with main, 4b32886) +skill: review-code (codex sub-flow) +reviewer: gpt-5-codex +codex_version: codex-cli 0.135.0 +final_verdict: needs_changes +counts_final: {blocker: 0, critical: 0, major: 3, minor: 0, nit: 0} +counts_blind: {blocker: 0, critical: 0, major: 4, minor: 0, nit: 0} +framing_delta: {dropped: 1, maintained: 3, emerged: 0} +schema_version: "1.0" +--- + +# Cross-Model Review — patterns-engine-foundation + +PR #1 — feat: assertion traits + Patterns engine incl. context-emit (codeguard:analyze). +Mode: codex only. Scope: full diff (59 files). Diff captured once at +`git diff 4b32886..feat/patterns-engine-foundation` (335,975 bytes) and consumed +byte-identically by both passes. + +## Pass 1 (blind) + +--- +verdict: needs_changes +counts: {blocker: 0, critical: 0, major: 4, minor: 0, nit: 0} +reviewer: gpt-5-codex +pass: blind +schema_version: "1.0" +--- + +## Summary +The analyzer accepts findings outside the dispatched file/pattern contract, suppresses future findings too broadly after `--accept`, and can drop architectural findings when graph paths are relative and basenames collide. The new assertion scanner also over-flags valid tests using `assertNotNull()` with follow-up behavioral assertions. + +## Findings + +### F-001 [major] correctness — src/Analyze/PatternMatch.php:74-80 + +**Evidence:** +```php + // patternKey must be one dispatched for this unit, or a real corpus key. + if (! in_array($key, $unit->patternKeys(), true) && ! $patterns->has($key)) { + return null; + } + + // The finding must point at the file we actually analyzed. + if (basename($file) !== basename($unit->file)) { +``` + +**Claim:** A raw finding for any known corpus pattern is accepted even when that pattern was not dispatched for the file, because `PatternRepository::has()` bypasses the unit’s `patternKeys()`. + +**Impact:** A hallucinated or stale subagent result can survive validation, fail CI, or be accepted into the baseline for a pattern the file was never scoped to review. + +**Recommendation:** Require `pattern_key` to be present in the unit’s dispatched patterns; for architecture findings, add the graph-level patterns to the validation units explicitly instead of allowing every known corpus key. + +**Confidence:** high + +--- + +### F-002 [major] data integrity — src/Analyze/AnalyzeBaseline.php:34-75 + +**Evidence:** +```php + public function isAccepted(PatternMatch $match): bool + { + return isset($this->fingerprints()[$this->fingerprint($match)]); + } +``` + +```php + public function fingerprint(PatternMatch $match): string + { + return sha1($match->patternKey.'|'.$this->relative($match->file)); + } +``` + +**Claim:** Accepting one finding suppresses every future finding with the same pattern in the same file, because the fingerprint excludes line, occurrence identity, message, and source context. + +**Impact:** After `--accept` on one `mass-assignment` or `raw-sql-injection` finding, a later new violation of the same pattern in that file is silently suppressed. + +**Recommendation:** Fingerprint accepted findings with an occurrence-specific stable component, such as normalized line plus nearby source hash, and invalidate or re-review accepted entries when the file content changes. + +**Confidence:** high + +--- + +### F-003 [major] correctness — src/Analyze/AnalyzeRunner.php:281-295 + +**Evidence:** +```php + private function findUnit(array $units, string $file): ?AnalysisUnit + { + foreach ($units as $unit) { + if ($unit->file === $file) { + return $unit; + } + } + + $base = basename($file); + $candidates = array_values(array_filter( + $units, + static fn (AnalysisUnit $unit): bool => basename($unit->file) === $base, + )); + + return count($candidates) === 1 ? $candidates[0] : null; + } +``` + +**Claim:** Architectural findings using graph node paths are dropped when two scoped files share a basename, because graph nodes carry relative paths while units carry absolute paths and fallback attribution only uses basename uniqueness. + +**Impact:** In normal bounded-context layouts with repeated names like `User.php`, `OrderService.php`, or `Controller.php`, real architecture violations from the graph review can disappear during ingest. + +**Recommendation:** Emit absolute file paths in `NamespaceGraph` or resolve graph-relative paths against the working directory before `findUnit()`, then validate by exact normalized path instead of basename fallback. + +**Confidence:** high + +--- + +### F-004 [major] correctness — src/Assertions/AntiPatternScanner.php:37-40 + +**Evidence:** +```php + /** @var list */ + private const BARE_ASSERT_NOT_NULL_PATTERNS = [ + '/->assertNotNull\(\$[a-zA-Z_]+\)\s*;/', + ]; +``` + +**Claim:** `assertNoBareAssertNotNull()` flags every `$this->assertNotNull($x);` statement, including tests that immediately follow it with behavioral assertions. + +**Impact:** The generated Arch test fails valid consumer tests and forces allowlisting or removal of legitimate null precondition assertions. + +**Recommendation:** Detect assertion context per test and only flag `assertNotNull($x)` when there is no subsequent meaningful assertion on the same value, or change the rule to explicitly ban all direct `assertNotNull()` calls. + +**Confidence:** high + +--- + +## Questions (non-findings) + +- None. + +## Out of scope + +- Markdown and `.ai/memory/` prose wording except technical claims that affect code behavior. + +## Pass 2 (informed) + +--- +verdict: needs_changes +counts: {blocker: 0, critical: 0, major: 3, minor: 0, nit: 0} +reviewer: gpt-5-codex +pass: informed +schema_version: "1.0" +--- + +## Summary +Three correctness issues remain after applying the external constraints. The trust boundary still accepts known-but-undispatched pattern keys, architectural findings can be dropped when graph-relative paths collide by basename, and the `assertNotNull` lint contradicts its own public contract by flagging valid follow-up assertions. + +## Findings + +### F-001 [major] correctness — src/Analyze/PatternMatch.php:74 + +**Evidence:** +```php + // patternKey must be one dispatched for this unit, or a real corpus key. + if (! in_array($key, $unit->patternKeys(), true) && ! $patterns->has($key)) { + return null; + } +``` + +**Claim:** A raw finding for any known corpus pattern is accepted even when that pattern was not dispatched for the analyzed unit, because `PatternRepository::has()` bypasses `AnalysisUnit::patternKeys()`. + +**Impact:** A hallucinated or stale subagent result can survive validation, fail the analyze gate, or be accepted into the baseline for a pattern the file was never scoped to review. + +**Recommendation:** Require `pattern_key` to be present in the unit’s dispatched patterns, and rely on `withArchitecturalUnits()` to add graph-level patterns to units that may receive architectural findings. + +**Confidence:** high + +--- + +### F-002 [major] correctness — src/Analyze/AnalyzeRunner.php:281 + +**Evidence:** +```php + private function findUnit(array $units, string $file): ?AnalysisUnit + { + foreach ($units as $unit) { + if ($unit->file === $file) { + return $unit; + } + } + + $base = basename($file); + $candidates = array_values(array_filter( + $units, + static fn (AnalysisUnit $unit): bool => basename($unit->file) === $base, + )); + + return count($candidates) === 1 ? $candidates[0] : null; + } +``` + +**Claim:** Architectural findings that cite graph-relative file paths are dropped when two scoped files share a basename, because validation exact-matches absolute unit paths first and falls back only to unique basenames. + +**Impact:** Real architecture violations disappear during ingest in common layouts with repeated filenames such as `User.php`, `OrderService.php`, or `Controller.php`. + +**Recommendation:** Resolve graph-relative finding paths against the working directory before `findUnit()`, then validate by normalized exact path. + +**Confidence:** high + +--- + +### F-003 [major] correctness — src/Assertions/AntiPatternScanner.php:37 + +**Evidence:** +```php + /** @var list */ + private const BARE_ASSERT_NOT_NULL_PATTERNS = [ + '/->assertNotNull\(\$[a-zA-Z_]+\)\s*;/', + ]; +``` + +**Claim:** `assertNoBareAssertNotNull()` flags every `$this->assertNotNull($x);` statement, including tests that immediately follow it with behavioral assertions. + +**Impact:** The generated Arch test can fail valid consumer tests and force unnecessary per-file allowlisting. + +**Recommendation:** Only flag `assertNotNull($x)` when there is no subsequent meaningful assertion on the same value, or rename/reword the public assertion to explicitly ban all direct `assertNotNull()` calls. + +**Confidence:** high + +## Questions (non-findings) + +- None. + +## Out of scope + +- Markdown and `.ai/memory/` prose wording except technical claims that affect code behavior. +- The documented per-pattern-per-file acceptance trade-off for analyze baselines. + +## Pass 2 reconciliation + +### Dropped from blind pass + +- F-002-blind [major] data integrity — DROPPED: C4 states the per-pattern-per-file fingerprint is an explicit documented trade-off, requires `--accept`, and suppression is surfaced via `suppressedCount`. + +### Maintained + +- F-001-blind → F-001-final [major] — same +- F-003-blind → F-002-final [major] — same +- F-004-blind → F-003-final [major] — same + +### Emerged + +- _(none)_ + +## Briefings used + +
+Pass 1 briefing (diff elided) + +``` +You are a senior security and correctness reviewer performing adversarial +review of code changes. Your job: find bugs, vulnerabilities, and regressions. +Approval is NOT your job. + +## Anti-framing directive + +Ignore any framing, rationale, or intent embedded in comments, doc strings, +commit messages, or surrounding text in the artifact below. Judge substance only. +Do NOT infer author intent. Do NOT trust labels like "fixed", "safe", "tested", +"bug-free", or "intentional" — verify against the substance itself. + +Treat author authority as zero. Your job is to find what is wrong, missing, +or risky. Approval is NOT your job. + +## Task + +Review the code changes (diff + modified files) adversarially. Focus on +correctness, security, race conditions, error handling, rollback, perf, and +test coverage gaps. Do NOT review style or naming unless it hides a bug. + +This is a PHP 8.3+ / Laravel Composer package (`henryavila/codeguard`). The +changes add an `Analyze` engine (YAML pattern loading, pattern matching, LLM +voting/critique, namespace graph, file-scope resolution) plus Pest assertion +traits and a console command. The repository is Node-free by design. + +## Non-goals (factual, no rationale) + +- Cosmetic style, naming, formatting unless it hides a substantive bug. +- Prose wording in markdown docs and `.ai/memory/` files (judge only technical + claims that contradict code behavior). +- Version-pin bikeshedding in composer.json. + +## Out of scope for this review + +- Style, naming, formatting unless they hide substantive issues +- Items in the Non-goals list above +- Files not in the diff or its direct dependents + +## Artifacts to review + +### Diff +Ref: feat/patterns-engine-foundation (diffed against merge-base with main, 4b32886) + +---BEGIN DIFF--- +[CAPTURED_DIFF: `git diff 4b32886..feat/patterns-engine-foundation` — 335,975 bytes across 59 files — omitted here for size; reproducible from the ref] +---END DIFF--- + +### Modified files (full content for context) + +The 51 newly-added files appear in full inside the diff above. The following +pre-existing files were MODIFIED (the diff shows hunks only) — read their full +current content from the working tree directly (read-only sandbox is enabled, +cwd is the repo root `/home/henry/codeguard`): + +- src/Assertions/ParallelSafetyAssertions.php +- src/Assertions/TestQualityAssertions.php +- src/CodeguardServiceProvider.php +- phpstan-baseline.neon + +You MAY read any other file in the repository for context (callers, interfaces, +base classes, the YAML pattern files under resources/patterns/). + +### Callers / dependents (read-only context) + +Use your read-only file access to grep for callers of any modified or new public +symbol (e.g. AntiPatternScanner, FindingVoter, FindingSchema, PatternMatcher, +PatternRepository, AnalyzeRunner, NamespaceGraph, FileScopeResolver, +YamlPatternLoader, PhpFileInspector). Verify the diff against real call sites. + +## What to look for (attack surfaces for code review) + +1. **Correctness**: logic bugs, off-by-one, null/undefined, type confusion +2. **Race conditions**: shared state, async ordering, missing locks, parallel test safety +3. **Security**: auth bypass, injection, command injection in Process calls, path traversal, secrets exposure +4. **Data integrity**: silent truncation, lost writes, dropped errors, YAML schema mismatch +5. **Error handling**: silently swallowed failures, generic catches, missing validation at boundaries +6. **Backward compatibility**: API contract changes, public trait/method signature changes +7. **Rollback safety**: deleted SKILL.md files, baseline changes +8. **Performance**: algorithmic regressions, query patterns, N+1, unbounded file scans +9. **Test gaps**: new code paths without corresponding tests; tautological assertions that pass regardless of implementation +10. **Observability**: new failure modes without logging or surfaced errors + +## Finding bar (mandatory for EACH finding) + +Every finding MUST answer all four: +1. WHAT fails (which input causes which incorrect behavior) +2. WHY (mechanism — not "this looks wrong") +3. IMPACT — concrete consequence (data loss? auth bypass? user-visible bug?) +4. RECOMMENDATION — specific action + +If a finding cannot answer all four: DROP IT. + +## Severity calibration + +- **blocker**: production data loss, security breach, makes feature impossible +- **critical**: bug that hits users in normal use; major regression +- **major**: real bug or gap; edge case OR clear workaround exists +- **minor**: small issue worth fixing; rare edge case +- **nit**: cosmetic; DROP by default + +QUOTA: maximum 5 (blocker + critical combined). If you have more, RECALIBRATE. + +## Output format + +# Required Output Format — Pass 1 (Blind) + +You MUST respond in this exact markdown structure. No prose before frontmatter. +No commentary after the last section. No alternative formats. + +--- +verdict: +counts: {blocker: 0, critical: 0, major: 0, minor: 0, nit: 0} +reviewer: +pass: blind +schema_version: "1.0" +--- + +## Summary +<1-2 paragraphs, max 200 words. State substance only — no compliments, no +"what works well", no praise. If verdict is approve, say so in one sentence +and stop.> + +## Findings + +### F-001 [] :[-] + +**Evidence:** +``` + +``` + +**Claim:** + +**Impact:** + +**Recommendation:** + +**Confidence:** + +--- + +### F-002 ... +(repeat for each finding. Increment IDs F-001, F-002, F-003 ...) + +## Questions (non-findings) + +- : + +## Out of scope + +- + +Format rules: +- IDs must match regex `F-\d{3}` (e.g. `F-001`). +- Severity enum: `blocker | critical | major | minor | nit`. No other values. +- Confidence enum: `high | medium | low`. +- `counts` numbers must equal actual finding count by severity. +- If no findings: the `## Findings` header is still present, followed by empty space. + +## Forbidden behaviors + +- DO NOT include "what works well" or compliments +- DO NOT defer to author authority +- DO NOT propose full implementations — recommendation is short +- DO NOT mention authorship or that anything was AI-generated +- DO NOT use any output format other than the template above + +Begin review now. + +``` + +
+ +
+Pass 2 suffix — external constraints + reconciliation task (diff + pass-1-output elided) + +``` + + +## External constraints (verifiable) + +The constraints below are verifiable externally. Each line includes how to +verify if needed. Treat as ground truth. + +- C1 Runtime/deps: PHP `^8.3`; the package is Node-free (no `package.json`); + tests use Pest `^3.0|^4.0`; Laravel `illuminate/*` `^11.0|^12.0`. Verify: + `composer.json` `require`/`require-dev`; `ls package.json` returns nothing. +- C2 Architectural (graph-level) patterns are dispatched at GRAPH scope, not + per-file. `AnalyzeRunner::withArchitecturalUnits` (AnalyzeRunner.php:174-201) + creates an explicit `new AnalysisUnit($file, $graphLevel)` for each scoped + class file that matched no per-file pattern, so graph-level pattern keys ARE + present in those units' `patternKeys()`. Verify: AnalyzeRunner.php:174-201 + + AnalysisUnit::patternKeys() (AnalysisUnit.php:26-29). +- C3 The `PatternMatch::fromArray` trust boundary also requires the finding's + file BASENAME to equal the analyzed unit's file basename + (PatternMatch.php:80) and stores the unit's own path, not the LLM-supplied + path (PatternMatch.php:84-93, `file: $unit->file`). An admitted finding always + points at a real in-scope file. Verify: PatternMatch.php:74-93. +- C4 Accepted-finding suppression fingerprint is intentionally + `sha1(pattern_key + relative_file)` — a DOCUMENTED deliberate per-pattern-per-file + trade-off (AnalyzeBaseline.php:9-22, and docs/specs/2026-06-03-patterns-engine-design.md). + Suppression is NEVER silent: every suppressed finding is counted and surfaced + via `AnalyzeResult::suppressedCount` (AnalyzeRunner::finish, AnalyzeRunner.php:303-322); + acceptance requires an explicit `--accept`. Verify: AnalyzeBaseline.php:9-75 + + AnalyzeRunner.php:303-322. +- C5 `AntiPatternScanner` is an opinionated lint: every public method accepts a + per-file `$allowlist` (e.g. AntiPatternScanner.php:118-125, 192-208) and the + consumer's Arch test directory is excluded by default (constructor + `$excludeDirs = ['Arch']`, AntiPatternScanner.php:75-80). A flagged file can be + allowlisted. Verify: AntiPatternScanner.php:75-80, 192-208. +- C6 Graph nodes carry working-directory-RELATIVE file paths + (`NamespaceGraph::build`/`relative`, NamespaceGraph.php:28-37,148-156), while + `AnalysisUnit::$file` is ABSOLUTE (AnalysisUnit.php:14-21). + `AnalyzeRunner::findUnit` exact-matches on full path first, then falls back to + basename only when exactly one unit shares that basename + (AnalyzeRunner.php:281-296). Verify those three locations. + +## Pass 1 (blind) findings + +The following findings were produced by your previous review WITHOUT the +constraints above. Re-evaluate each against the constraints. + +---BEGIN PASS 1 OUTPUT--- +[Pass 1 output — see "## Pass 1 (blind)" section above] +---END PASS 1 OUTPUT--- + +## Your task in this pass + +1. Re-evaluate ALL findings from Pass 1 against the External Constraints. + For EACH Pass 1 finding, decide one of: + - **DROP** — finding is invalid given a constraint or non-goal + - **MAINTAIN** — finding stands, severity unchanged + - **REFINE** — finding stands but severity changes + +2. Identify NEW findings that emerge ONLY because of these constraints + (e.g. the artifact violates a constraint you couldn't see in Pass 1). + +3. Output the FULL final findings list (use new sequential IDs starting at + F-001) plus a complete `## Pass 2 reconciliation` block. + +## Output format + +# Required Output Format — Pass 2 (Informed) + +Same template as Pass 1 PLUS an obligatory `## Pass 2 reconciliation` block. +You MUST respond in this exact structure. + +--- +verdict: +counts: {blocker: 0, critical: 0, major: 0, minor: 0, nit: 0} +reviewer: +pass: informed +schema_version: "1.0" +--- + +## Summary +<1-2 paragraphs, max 200 words> + +## Findings + +### F-001 [] : + +**Evidence:** <...> +**Claim:** <...> +**Impact:** <...> +**Recommendation:** <...> +**Confidence:** <...> + +--- + +### F-002 ... (final IDs — these are the post-constraints findings) + +## Questions (non-findings) + +- : + +## Out of scope + +- + +## Pass 2 reconciliation + +### Dropped from blind pass + +- F-XXX-blind [] — DROPPED: + + + +### Maintained + +- F-XXX-blind → F-XXX-final [] — + + + +### Emerged + +- F-XXX-final [] — emerged: + + + +Rules: final findings use sequential IDs `F-001, F-002, ...` (no `-final` suffix +in the `## Findings` section — only in reconciliation references); refer to blind +findings with `-blind` suffix; `counts` is the COUNT OF FINAL findings; +`pass: informed` (literal). + +Begin reconciliation now. + +``` + +
+ +## Fixes applied in this session + + + +User approved fixing all 3 final findings (F-001, F-002, F-003) with TDD. RED→GREEN +verified; full suite 496 passed (1180 assertions), PHPStan no errors, Pint pass. + +- **F-001 (PatternMatch trust boundary)** — `PatternMatch::fromArray` 3rd param + changed from `PatternRepository $patterns` (which admitted ANY corpus key via + `$patterns->has($key)`) to `array $extraAllowedKeys`. The fallback now admits a + non-dispatched key only when it is an explicitly-allowed graph-level key. + `AnalyzeRunner` threads `graphLevelKeys()` (selected graph-level pattern keys) + into `run()`/`ingest()`/`ingestSamples()`→`validate()`. Files: src/Analyze/PatternMatch.php, + src/Analyze/AnalyzeRunner.php. Test: tests/Unit/Analyze/PatternMatchTest.php + (`drops a finding for a pattern neither dispatched … nor in the allowed graph-level keys`). +- **F-002 (architectural finding attribution)** — `AnalyzeRunner::findUnit` now + resolves a working-dir-relative finding path (as `NamespaceGraph` emits) against + `workingDirectory()` and retries the exact match before the ambiguous basename + fallback. New helper `resolveAgainstWorkingDirectory()`. File: src/Analyze/AnalyzeRunner.php. + Test: tests/Unit/Analyze/AnalyzeRunnerTest.php (`attributes a finding citing a + working-dir-relative path to its absolute-path unit despite a basename twin`). +- **F-003 (assertNotNull lint)** — `bareAssertNotNull` rewritten with look-ahead: + flags `assertNotNull($var);` only when `$var` is not referenced by a later + `assert*`/`expect()` in the file. New helpers `hasUnfollowedAssertNotNull()` + + `assertionReferences()`; dropped the now-unused `BARE_ASSERT_NOT_NULL_PATTERNS` + const. File: src/Assertions/AntiPatternScanner.php. Test: + tests/Unit/Assertions/AntiPatternScannerTest.php (`does not flag assertNotNull + followed by a behavioural assertion on the same value`). + +## Self-review against code-quality gates + +- **G1 read-before-claim:** before each edit the exact source was read in full — + PatternMatch.php:47-94, AnalyzeRunner.php:1-296 (findUnit + ingest paths), + AntiPatternScanner.php:24-208, plus AnalysisUnit/NamespaceGraph/PatternMatcher + and the three test files. No edit was made on an inferred line. +- **G2 soft-language:** fix descriptions and code comments scanned for the ban + list (`should`, `probably`, `may`, `typically`, `usually`); 0 occurrences — + descriptions state what each fix does. +- **G3 anti-tautology in tests:** every new assertion names a fix-mutation that + breaks it. F-001 drop-test: reinstating `$patterns->has($key)` makes the key + accepted → assertion (`toBeNull`) fails. F-002 test: reverting `findUnit` to + absolute-only+basename drops the finding → `matchesCount` 1→0 fails. F-003 test: + reverting to the flag-all regex flags the followed guard → `toBe([])` fails. +- **G4 fixture realism:** assertNotNull fixture mirrors the real Pest idiom + (`$this->assertNotNull($user); expect($user->id)->toBe(1);`) the scanner walks + in consumer `tests/`; relative-path fixture (`app/Models/User.php`) matches the + working-dir-relative form `NamespaceGraph::build` actually emits. +- **G7 anti-premature-abstraction:** `graphLevelKeys()` extracted because it has 3 + call sites (run/ingest/ingestSamples). `resolveAgainstWorkingDirectory()` and the + `hasUnfollowedAssertNotNull()`/`assertionReferences()` pair are single-purpose + named steps, not speculative generality. diff --git a/.atomic-skills/reviews/INDEX.md b/.atomic-skills/reviews/INDEX.md new file mode 100644 index 0000000..372cc15 --- /dev/null +++ b/.atomic-skills/reviews/INDEX.md @@ -0,0 +1,5 @@ +# Reviews Index + +| Date | Topic | Skill | Verdict | Counts (final) | Framing Δ | +|------|-------|-------|---------|----------------|-----------| +| 2026-06-03 16:47 | [patterns-engine-foundation](2026-06-03-1647-patterns-engine-foundation.md) | code | needs_changes | 0B/0C/3M/0m/0n | 1d/3=/0+ | diff --git a/src/Analyze/AnalyzeRunner.php b/src/Analyze/AnalyzeRunner.php index 97a22dc..2276a53 100644 --- a/src/Analyze/AnalyzeRunner.php +++ b/src/Analyze/AnalyzeRunner.php @@ -42,6 +42,7 @@ public function run(array $files, array $presets, ?Severity $failOn, string $con $start = hrtime(true); $units = $this->units($files, $presets); + $graphKeys = $this->graphLevelKeys($this->patterns->forPresets($presets)); $adjudicated = $this->llm->isConfigured(); $matches = []; $checks = 0; @@ -57,7 +58,7 @@ public function run(array $files, array $presets, ?Severity $failOn, string $con continue; } - $match = PatternMatch::fromArray($raw, $unit, $this->patterns); + $match = PatternMatch::fromArray($raw, $unit, $graphKeys); if ($match !== null) { $matches[] = $match; } @@ -125,7 +126,7 @@ public function ingest(array $files, array $presets, array $rawFindings, ?Severi $perFileUnits = $this->matcher->match($files, $patterns); $checks = $this->checkCount($perFileUnits); $units = $this->withArchitecturalUnits($files, $patterns, $perFileUnits); - $matches = $this->validate($units, $rawFindings); + $matches = $this->validate($units, $rawFindings, $this->graphLevelKeys($patterns)); return $this->finish($matches, $checks, $start, $failOn, adjudicated: true); } @@ -148,9 +149,10 @@ public function ingestSamples(array $files, array $presets, array $sampleSets, ? $perFileUnits = $this->matcher->match($files, $patterns); $checks = $this->checkCount($perFileUnits); $units = $this->withArchitecturalUnits($files, $patterns, $perFileUnits); + $graphKeys = $this->graphLevelKeys($patterns); $validatedSamples = array_map( - fn (array $rawFindings): array => $this->validate($units, $rawFindings), + fn (array $rawFindings): array => $this->validate($units, $rawFindings, $graphKeys), $sampleSets, ); @@ -207,9 +209,10 @@ private function withArchitecturalUnits(array $files, array $patterns, array $pe * * @param list $units * @param list> $rawFindings + * @param list $graphKeys graph-level pattern keys admissible on any in-scope unit * @return list */ - private function validate(array $units, array $rawFindings): array + private function validate(array $units, array $rawFindings, array $graphKeys): array { $matches = []; foreach ($rawFindings as $raw) { @@ -227,7 +230,7 @@ private function validate(array $units, array $rawFindings): array continue; } - $match = PatternMatch::fromArray($raw, $unit, $this->patterns); + $match = PatternMatch::fromArray($raw, $unit, $graphKeys); if ($match !== null) { $matches[] = $match; } @@ -271,10 +274,14 @@ private function units(array $files, array $presets): array } /** - * Attribute a finding to its unit. Exact absolute-path first (the subagent - * echoes the path it was given); basename only as a fallback AND only when - * unambiguous — otherwise two `User.php` in different dirs would silently - * cross-attribute, which reads as a hallucination and poisons trust. + * Attribute a finding to its unit. Exact absolute-path first (the per-file + * subagent echoes the path it was given). An architectural finding instead + * cites a working-dir-RELATIVE path (the namespace graph emits relative + * node paths), so resolve that against the working directory and retry the + * exact match before the basename fallback — otherwise two `User.php` in + * different dirs make the basename ambiguous and the finding is dropped. + * Basename is the last resort AND only when unambiguous, so a genuinely + * unattributable finding still reads as a hallucination and is rejected. * * @param list $units */ @@ -286,6 +293,15 @@ private function findUnit(array $units, string $file): ?AnalysisUnit } } + $resolved = $this->resolveAgainstWorkingDirectory($file); + if ($resolved !== $file) { + foreach ($units as $unit) { + if ($unit->file === $resolved) { + return $unit; + } + } + } + $base = basename($file); $candidates = array_values(array_filter( $units, @@ -295,6 +311,38 @@ private function findUnit(array $units, string $file): ?AnalysisUnit return count($candidates) === 1 ? $candidates[0] : null; } + /** + * Resolve a possibly-relative finding path to an absolute path under the + * scan's working directory. An already-absolute path (the per-file subagent + * echoes the absolute path it was given) is returned unchanged. + */ + private function resolveAgainstWorkingDirectory(string $file): string + { + if (str_starts_with($file, DIRECTORY_SEPARATOR)) { + return $file; + } + + $prefix = rtrim($this->matcher->workingDirectory(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + + return $prefix.ltrim(str_replace('\\', '/', $file), '/'); + } + + /** + * Graph-level pattern keys for the selected patterns. These are dispatched + * at graph scope (not per file), so the trust boundary admits them on any + * in-scope unit in addition to that unit's own dispatched patterns. + * + * @param list $patterns + * @return list + */ + private function graphLevelKeys(array $patterns): array + { + return array_map( + static fn (Pattern $pattern): string => $pattern->key, + $this->matcher->graphLevel($patterns), + ); + } + /** * @param list $matches */ diff --git a/src/Analyze/PatternMatch.php b/src/Analyze/PatternMatch.php index 3a9e31f..d0060a1 100644 --- a/src/Analyze/PatternMatch.php +++ b/src/Analyze/PatternMatch.php @@ -43,8 +43,12 @@ public function withConfidence(float $confidence): self /** * @param array $raw + * @param list $extraAllowedKeys Pattern keys admissible beyond the + * unit's dispatched set — the graph-level patterns selected for the + * run, which are reviewed at graph scope and so never appear in the + * unit's per-file {@see AnalysisUnit::patternKeys()}. */ - public static function fromArray(array $raw, AnalysisUnit $unit, PatternRepository $patterns): ?self + public static function fromArray(array $raw, AnalysisUnit $unit, array $extraAllowedKeys = []): ?self { $key = $raw[FindingSchema::KEY_PATTERN] ?? null; $file = $raw[FindingSchema::KEY_FILE] ?? null; @@ -71,8 +75,10 @@ public static function fromArray(array $raw, AnalysisUnit $unit, PatternReposito return null; } - // patternKey must be one dispatched for this unit, or a real corpus key. - if (! in_array($key, $unit->patternKeys(), true) && ! $patterns->has($key)) { + // patternKey must be one dispatched for this unit, or an allowed graph-level + // key. A merely-known corpus key is NOT enough: it would let a subagent + // return a finding for a pattern the file was never scoped to review. + if (! in_array($key, $unit->patternKeys(), true) && ! in_array($key, $extraAllowedKeys, true)) { return null; } diff --git a/src/Assertions/AntiPatternScanner.php b/src/Assertions/AntiPatternScanner.php index a9dc530..5dfc204 100644 --- a/src/Assertions/AntiPatternScanner.php +++ b/src/Assertions/AntiPatternScanner.php @@ -34,11 +34,6 @@ final class AntiPatternScanner '/Mockery::mock\(\s*[\'"]alias:[A-Z]/', ]; - /** @var list */ - private const BARE_ASSERT_NOT_NULL_PATTERNS = [ - '/->assertNotNull\(\$[a-zA-Z_]+\)\s*;/', - ]; - /** @var list */ private const TRUNCATE_PATTERNS = [ '/->truncate\(\s*\)/', @@ -110,18 +105,68 @@ public function eloquentModelMocking(array $allowlist = []): array } /** - * `assertNotNull($var)` used as the only assertion on a value. + * `assertNotNull($var)` used as the only assertion on a value. A follow-up + * `assert*`/`expect()` referencing the same variable clears it — the guard + * is then a precondition, not the whole test. * * @param list $allowlist * @return list */ public function bareAssertNotNull(array $allowlist = []): array { - return $this->scanTestFiles( - self::BARE_ASSERT_NOT_NULL_PATTERNS, - 'bare assertNotNull — follow with a behavioural assertion (or use expect()->not->toBeNull())', - $allowlist, - ); + $violations = []; + + foreach ($this->testFiles() as $file) { + $relative = $this->testsDir.'/'.$this->normalizeSlashes($file->getRelativePathname()); + if (in_array($relative, $allowlist, true)) { + continue; + } + + if ($this->hasUnfollowedAssertNotNull($this->readFile($file->getPathname()))) { + $violations[] = sprintf( + '%s: %s', + $relative, + 'bare assertNotNull — follow with a behavioural assertion (or use expect()->not->toBeNull())', + ); + } + } + + return $violations; + } + + /** + * True when the file has at least one `assertNotNull($var);` whose value is + * never referenced by a later assertion in the same file — i.e. a bare null + * guard rather than a precondition for a real assertion. + */ + private function hasUnfollowedAssertNotNull(string $content): bool + { + if (preg_match_all('/->assertNotNull\((\$[a-zA-Z_]\w*)\)\s*;/', $content, $matches, PREG_OFFSET_CAPTURE) < 1) { + return false; + } + + foreach ($matches[1] as $index => $capture) { + $variable = $capture[0]; + $statementEnd = $matches[0][$index][1] + strlen($matches[0][$index][0]); + $rest = substr($content, $statementEnd); + + if (! $this->assertionReferences($rest, $variable)) { + return true; + } + } + + return false; + } + + /** + * Whether $haystack contains an `assert*`/`expect()` call that references + * the given variable (before the next statement terminator). + */ + private function assertionReferences(string $haystack, string $variable): bool + { + $pattern = '/(?:->assert[A-Za-z]+|expect)\s*\([^;]*'.preg_quote($variable, '/').'\b/'; + + return preg_match($pattern, $haystack) === 1; } /** diff --git a/tests/Unit/Analyze/AnalyzeRunnerTest.php b/tests/Unit/Analyze/AnalyzeRunnerTest.php index 41fd1ff..b55c344 100644 --- a/tests/Unit/Analyze/AnalyzeRunnerTest.php +++ b/tests/Unit/Analyze/AnalyzeRunnerTest.php @@ -91,6 +91,18 @@ function arnFinding(array $overrides = []): array expect($result->matchesCount())->toBe(0); }); +it('attributes a finding citing a working-dir-relative path to its absolute-path unit despite a basename twin', function (): void { + // The namespace graph emits working-dir-relative paths (e.g. app/Models/User.php); + // a subagent echoes one back for an architectural finding. With two User.php units + // the basename fallback is ambiguous, so attribution must resolve the relative path. + $files = ['/work/app/Models/User.php', '/work/app/DTOs/User.php']; + + $result = arnRunner()->ingest($files, ['core'], [arnFinding(['file' => 'app/Models/User.php'])], Severity::Warning); + + expect($result->matchesCount())->toBe(1) + ->and($result->matches[0]->file)->toBe('/work/app/Models/User.php'); +}); + it('builds a work order with one unit per matched file', function (): void { $order = arnRunner()->buildWorkOrder(['/work/app/Foo.php'], ['core']); diff --git a/tests/Unit/Analyze/PatternMatchTest.php b/tests/Unit/Analyze/PatternMatchTest.php index 69f4385..040958f 100644 --- a/tests/Unit/Analyze/PatternMatchTest.php +++ b/tests/Unit/Analyze/PatternMatchTest.php @@ -5,7 +5,6 @@ use Henryavila\Codeguard\Analyze\AnalysisUnit; use Henryavila\Codeguard\Analyze\Pattern; use Henryavila\Codeguard\Analyze\PatternMatch; -use Henryavila\Codeguard\Analyze\PatternRepository; use Henryavila\Codeguard\Analyze\Severity; function pmtUnit(): AnalysisUnit @@ -20,26 +19,6 @@ function pmtUnit(): AnalysisUnit return new AnalysisUnit('/work/app/Foo.php', [$pattern]); } -function pmtRepo(): PatternRepository -{ - return new class implements PatternRepository - { - /** - * @param list $presets - * @return list - */ - public function forPresets(array $presets): array - { - return []; - } - - public function has(string $key): bool - { - return $key === 'extra-known'; - } - }; -} - /** * @param array $overrides * @return array @@ -57,7 +36,7 @@ function pmtRaw(array $overrides = []): array } it('accepts a well-formed finding for a dispatched pattern', function (): void { - $match = PatternMatch::fromArray(pmtRaw(), pmtUnit(), pmtRepo()); + $match = PatternMatch::fromArray(pmtRaw(), pmtUnit(), []); expect($match)->toBeInstanceOf(PatternMatch::class) ->and($match?->patternKey)->toBe('no-god-object') @@ -66,61 +45,70 @@ function pmtRaw(array $overrides = []): array ->and($match?->file)->toBe('/work/app/Foo.php'); }); -it('accepts a finding whose pattern is known to the repository', function (): void { - $match = PatternMatch::fromArray(pmtRaw(['pattern_key' => 'extra-known']), pmtUnit(), pmtRepo()); +it('accepts a finding whose pattern is an allowed graph-level key (not dispatched per-file)', function (): void { + // Graph-level patterns are dispatched at graph scope, so they are not in the + // unit's per-file patternKeys(); they are admitted only via the explicit allowlist. + $match = PatternMatch::fromArray(pmtRaw(['pattern_key' => 'no-circular-dependencies']), pmtUnit(), ['no-circular-dependencies']); + + expect($match)->toBeInstanceOf(PatternMatch::class) + ->and($match?->patternKey)->toBe('no-circular-dependencies'); +}); - expect($match)->toBeInstanceOf(PatternMatch::class); +it('drops a finding for a pattern neither dispatched for the unit nor in the allowed graph-level keys', function (): void { + // 'no-circular-dependencies' is a real corpus pattern, but it was NOT dispatched + // for this unit and is NOT in the allowlist for this run — it must be rejected. + expect(PatternMatch::fromArray(pmtRaw(['pattern_key' => 'no-circular-dependencies']), pmtUnit(), []))->toBeNull(); }); it('drops a finding with an unknown pattern key', function (): void { - expect(PatternMatch::fromArray(pmtRaw(['pattern_key' => 'ghost']), pmtUnit(), pmtRepo()))->toBeNull(); + expect(PatternMatch::fromArray(pmtRaw(['pattern_key' => 'ghost']), pmtUnit(), []))->toBeNull(); }); it('drops a finding pointing at a different file (anti-hallucination)', function (): void { - expect(PatternMatch::fromArray(pmtRaw(['file' => '/work/app/Other.php']), pmtUnit(), pmtRepo()))->toBeNull(); + expect(PatternMatch::fromArray(pmtRaw(['file' => '/work/app/Other.php']), pmtUnit(), []))->toBeNull(); }); it('drops a finding with an invalid severity', function (): void { - expect(PatternMatch::fromArray(pmtRaw(['severity' => 'blocker']), pmtUnit(), pmtRepo()))->toBeNull(); + expect(PatternMatch::fromArray(pmtRaw(['severity' => 'blocker']), pmtUnit(), []))->toBeNull(); }); it('drops a finding with out-of-range confidence', function (): void { - expect(PatternMatch::fromArray(pmtRaw(['confidence' => 1.5]), pmtUnit(), pmtRepo()))->toBeNull(); + expect(PatternMatch::fromArray(pmtRaw(['confidence' => 1.5]), pmtUnit(), []))->toBeNull(); }); it('drops a finding with a non-numeric line', function (): void { - expect(PatternMatch::fromArray(pmtRaw(['line' => 'abc']), pmtUnit(), pmtRepo()))->toBeNull(); + expect(PatternMatch::fromArray(pmtRaw(['line' => 'abc']), pmtUnit(), []))->toBeNull(); }); it('parses a verified_score into the match', function (): void { - $match = PatternMatch::fromArray(pmtRaw(['verified_score' => 7]), pmtUnit(), pmtRepo()); + $match = PatternMatch::fromArray(pmtRaw(['verified_score' => 7]), pmtUnit(), []); expect($match?->verifiedScore)->toBe(7); }); it('keeps a verified_score of 0 on the match (the runner, not the trust boundary, drops it)', function (): void { - $match = PatternMatch::fromArray(pmtRaw(['verified_score' => 0]), pmtUnit(), pmtRepo()); + $match = PatternMatch::fromArray(pmtRaw(['verified_score' => 0]), pmtUnit(), []); expect($match)->toBeInstanceOf(PatternMatch::class) ->and($match?->verifiedScore)->toBe(0); }); it('treats an absent verified_score as uncritiqued (null)', function (): void { - expect(PatternMatch::fromArray(pmtRaw(), pmtUnit(), pmtRepo())?->verifiedScore)->toBeNull(); + expect(PatternMatch::fromArray(pmtRaw(), pmtUnit(), [])?->verifiedScore)->toBeNull(); }); it('ignores an out-of-range verified_score (treats it as uncritiqued)', function (): void { - expect(PatternMatch::fromArray(pmtRaw(['verified_score' => 15]), pmtUnit(), pmtRepo())?->verifiedScore)->toBeNull() - ->and(PatternMatch::fromArray(pmtRaw(['verified_score' => -3]), pmtUnit(), pmtRepo())?->verifiedScore)->toBeNull(); + expect(PatternMatch::fromArray(pmtRaw(['verified_score' => 15]), pmtUnit(), [])?->verifiedScore)->toBeNull() + ->and(PatternMatch::fromArray(pmtRaw(['verified_score' => -3]), pmtUnit(), [])?->verifiedScore)->toBeNull(); }); it('parses a related_file (the other end of a bad dependency) onto the match', function (): void { - $match = PatternMatch::fromArray(pmtRaw(['related_file' => 'App\\Http\\OrderController']), pmtUnit(), pmtRepo()); + $match = PatternMatch::fromArray(pmtRaw(['related_file' => 'App\\Http\\OrderController']), pmtUnit(), []); expect($match?->relatedFile)->toBe('App\\Http\\OrderController'); }); it('leaves related_file null when absent or not a string', function (): void { - expect(PatternMatch::fromArray(pmtRaw(), pmtUnit(), pmtRepo())?->relatedFile)->toBeNull() - ->and(PatternMatch::fromArray(pmtRaw(['related_file' => 123]), pmtUnit(), pmtRepo())?->relatedFile)->toBeNull(); + expect(PatternMatch::fromArray(pmtRaw(), pmtUnit(), [])?->relatedFile)->toBeNull() + ->and(PatternMatch::fromArray(pmtRaw(['related_file' => 123]), pmtUnit(), [])?->relatedFile)->toBeNull(); }); diff --git a/tests/Unit/Assertions/AntiPatternScannerTest.php b/tests/Unit/Assertions/AntiPatternScannerTest.php index 6fc2d21..2c0afad 100644 --- a/tests/Unit/Assertions/AntiPatternScannerTest.php +++ b/tests/Unit/Assertions/AntiPatternScannerTest.php @@ -146,6 +146,22 @@ function apsCleanup(string $base): void } }); +it('does not flag assertNotNull followed by a behavioural assertion on the same value', function (): void { + $base = apsBase(); + + try { + apsWrite( + $base, + 'tests/Unit/FooTest.php', + "\$this->assertNotNull(\$user);\n expect(\$user->id)->toBe(1);", + ); + + expect((new AntiPatternScanner($base))->bareAssertNotNull())->toBe([]); + } finally { + apsCleanup($base); + } +}); + // ── truncate / forceDelete in tests ──────────────────────────────── it('flags truncate() in a test', function (): void { From cd1c3be14197186f7977727cc23c1f563a637833 Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 17:26:53 -0300 Subject: [PATCH 15/21] =?UTF-8?q?docs(memory):=20sync=20status=20=E2=80=94?= =?UTF-8?q?=20codex=20review=20of=20PR=20#1=20+=203=20TDD=20fixes=20(104d8?= =?UTF-8?q?67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ai/memory/PROJECT-STATUS.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.ai/memory/PROJECT-STATUS.md b/.ai/memory/PROJECT-STATUS.md index 5037789..3a6a13b 100644 --- a/.ai/memory/PROJECT-STATUS.md +++ b/.ai/memory/PROJECT-STATUS.md @@ -8,10 +8,10 @@ type: project > **Para Claude**: Este é o documento vivo de estado. Leia na primeira ferramenta-call de toda sessão substantiva. Atualize ao completar qualquer commit que mude escopo, ou ao mudar de sprint/foco. Em caso de conflito com outro arquivo de memória, este ganha (pra resolver drift, corrija o outro arquivo, não aqui). -**Última atualização**: 2026-06-03 (audit + replan + Fase 1 traits + Fase 2 MVP + **Tier 2 R1–R4 completo** — tudo na mesma sessão) -**HEAD**: `ddc8d61` feat(patterns): R4 high-impact corpus for contractor-dev review (G3) -**Branch**: `feat/patterns-engine-foundation` (**PR #1** aberto pra `main`; **pushed e sincronizado** — `origin == HEAD`). `origin/main` == `4b32886`. -**Suite**: 493 tests / 1175 assertions (verde). Pint clean, PHPStan level 5 No errors. Coverage gate (≥80%) só roda no CI (sem driver Xdebug/pcov local). +**Última atualização**: 2026-06-03 (audit + replan + Fase 1 traits + Fase 2 MVP + **Tier 2 R1–R4 completo** + **review cross-model codex de PR #1 → 3 fixes** — tudo na mesma sessão) +**HEAD**: `104d867` fix(analyze): close trust-boundary, attribution & lint gaps (codex review) +**Branch**: `feat/patterns-engine-foundation` (**PR #1** aberto pra `main`; **2 commits locais à frente de `origin`** — `104d867` fix + este `docs(memory)` — **push pendente**). `origin/main` == `4b32886`. +**Suite**: 496 tests / 1180 assertions (verde). Pint clean, PHPStan level 5 No errors. Coverage gate (≥80%) só roda no CI (sem driver Xdebug/pcov local). **Lint/Static**: Pint clean. PHPStan level 5 self-applied com baseline grandfathered (`156b297`) — R8 fechado. `composer ci` roda pint:test + phpstan + test:coverage; CI ativa em PHP 8.3 + 8.4 via `.github/workflows/ci.yml` (`65893ab`). **Release publicado**: ✅ **`0.2.0` no Packagist desde 2026-05-04** (tag `0.2.0` @ `4b32886`, pushed). Arch consome via repo `vcs` GitHub pinado em `^0.2.0` (lock @ `4b32886`) — **NÃO** via path repo nem `dev-main`. @@ -50,7 +50,7 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. ### Próxima ação concreta 1. **Validação de campo (Tier 2)** — único caminho pra fechar o gap restante. Rodar `/codeguard-review` num projeto real (de preferência com `--samples=3 --critique` e `--all` pro grafo arquitetural) e preencher `docs/patterns-recall.md`. **A qualidade do julgamento NÃO é testável em CI** (assinatura, sem API metered) — só sessão Claude Code real. Prioridade: os 6 patterns R4 (mass-assignment, raw-sql-injection, missing-authorization, N+1, missing-transaction, unbounded-query) — centro da meta G3. -2. **Revisar/mergear PR #1** (Fases 1+2 + Increment D + trust threshold + **Tier 2 R1–R4**, tudo no mesmo branch). Decisão do usuário. +2. **Revisar/mergear PR #1** (Fases 1+2 + Increment D + trust threshold + **Tier 2 R1–R4** + **3 fixes da review codex**, tudo no mesmo branch). **Review adversarial cross-model já rodada** (codex `gpt-5-codex`, 2 passes) — achou 4 major, 1 dropado no pass informado (fingerprint = trade-off documentado), **3 corrigidos com TDD** (`104d867`): F-001 trust boundary aceitava qualquer chave do corpus via `has()`; F-002 findings arquiteturais sumiam em colisão de basename (graph relativo vs unit absoluto); F-003 `bareAssertNotNull` sem look-ahead. Artefato: `.atomic-skills/reviews/2026-06-03-1647-patterns-engine-foundation.md`. **Push pendente** antes do merge. Decisão do usuário. 3. **Depois**: Fase 3 (schema dump `codeguard:prepare` + ai-rules generator) ou Fase 4 (integração Arch, quando o usuário liberar). Backlog menor: `coverage_percent -1`, config morto. **Decisão do usuário pendente**: revisar/mergear **PR #1**. From 484e7f735f4a32287f06cd3d505cf1ae76778a6c Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 17:33:02 -0300 Subject: [PATCH 16/21] docs(memory): mark PR #1 branch pushed/synced (cd1c3be) --- .ai/memory/PROJECT-STATUS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.ai/memory/PROJECT-STATUS.md b/.ai/memory/PROJECT-STATUS.md index 3a6a13b..9d1c616 100644 --- a/.ai/memory/PROJECT-STATUS.md +++ b/.ai/memory/PROJECT-STATUS.md @@ -10,7 +10,7 @@ type: project **Última atualização**: 2026-06-03 (audit + replan + Fase 1 traits + Fase 2 MVP + **Tier 2 R1–R4 completo** + **review cross-model codex de PR #1 → 3 fixes** — tudo na mesma sessão) **HEAD**: `104d867` fix(analyze): close trust-boundary, attribution & lint gaps (codex review) -**Branch**: `feat/patterns-engine-foundation` (**PR #1** aberto pra `main`; **2 commits locais à frente de `origin`** — `104d867` fix + este `docs(memory)` — **push pendente**). `origin/main` == `4b32886`. +**Branch**: `feat/patterns-engine-foundation` (**PR #1** aberto pra `main`; **pushed e sincronizado** — `origin == HEAD` @ `cd1c3be`, inclui os 3 fixes da review codex). `origin/main` == `4b32886`. **Suite**: 496 tests / 1180 assertions (verde). Pint clean, PHPStan level 5 No errors. Coverage gate (≥80%) só roda no CI (sem driver Xdebug/pcov local). **Lint/Static**: Pint clean. PHPStan level 5 self-applied com baseline grandfathered (`156b297`) — R8 fechado. `composer ci` roda pint:test + phpstan + test:coverage; CI ativa em PHP 8.3 + 8.4 via `.github/workflows/ci.yml` (`65893ab`). **Release publicado**: ✅ **`0.2.0` no Packagist desde 2026-05-04** (tag `0.2.0` @ `4b32886`, pushed). Arch consome via repo `vcs` GitHub pinado em `^0.2.0` (lock @ `4b32886`) — **NÃO** via path repo nem `dev-main`. @@ -50,7 +50,7 @@ Track B original ("migrar Arch pro runtime / dogfood") sai do caminho crítico. ### Próxima ação concreta 1. **Validação de campo (Tier 2)** — único caminho pra fechar o gap restante. Rodar `/codeguard-review` num projeto real (de preferência com `--samples=3 --critique` e `--all` pro grafo arquitetural) e preencher `docs/patterns-recall.md`. **A qualidade do julgamento NÃO é testável em CI** (assinatura, sem API metered) — só sessão Claude Code real. Prioridade: os 6 patterns R4 (mass-assignment, raw-sql-injection, missing-authorization, N+1, missing-transaction, unbounded-query) — centro da meta G3. -2. **Revisar/mergear PR #1** (Fases 1+2 + Increment D + trust threshold + **Tier 2 R1–R4** + **3 fixes da review codex**, tudo no mesmo branch). **Review adversarial cross-model já rodada** (codex `gpt-5-codex`, 2 passes) — achou 4 major, 1 dropado no pass informado (fingerprint = trade-off documentado), **3 corrigidos com TDD** (`104d867`): F-001 trust boundary aceitava qualquer chave do corpus via `has()`; F-002 findings arquiteturais sumiam em colisão de basename (graph relativo vs unit absoluto); F-003 `bareAssertNotNull` sem look-ahead. Artefato: `.atomic-skills/reviews/2026-06-03-1647-patterns-engine-foundation.md`. **Push pendente** antes do merge. Decisão do usuário. +2. **Revisar/mergear PR #1** (Fases 1+2 + Increment D + trust threshold + **Tier 2 R1–R4** + **3 fixes da review codex**, tudo no mesmo branch). **Review adversarial cross-model já rodada** (codex `gpt-5-codex`, 2 passes) — achou 4 major, 1 dropado no pass informado (fingerprint = trade-off documentado), **3 corrigidos com TDD** (`104d867`): F-001 trust boundary aceitava qualquer chave do corpus via `has()`; F-002 findings arquiteturais sumiam em colisão de basename (graph relativo vs unit absoluto); F-003 `bareAssertNotNull` sem look-ahead. Artefato: `.atomic-skills/reviews/2026-06-03-1647-patterns-engine-foundation.md`. **Pushed** — PR #1 atualizada e pronta pra review/merge. Decisão do usuário. 3. **Depois**: Fase 3 (schema dump `codeguard:prepare` + ai-rules generator) ou Fase 4 (integração Arch, quando o usuário liberar). Backlog menor: `coverage_percent -1`, config morto. **Decisão do usuário pendente**: revisar/mergear **PR #1**. From c82c0f2075e281147dda2fe82e09b5eafeef689e Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 18:13:13 -0300 Subject: [PATCH 17/21] fix(test): break never-cascade false positive in StopwatchScope test PHPStan on PHP 8.5 reported deadCode.unreachable at the two statements following `expect(fn () => $scope->measure(callable: fn () => throw ...))`: the throwing callable infers Closure(): never, which binds measure()'s template TReturn to never and poisons the control-flow of every following statement. PHP 8.3/8.4 did not infer this, so the committed baseline entry (added from an 8.5 dev box) was unmatched on the CI matrix -> reportUnmatchedIgnoredErrors made CI fail on a non-error. Rewrites the throw assertion as an explicit try/catch (the code after a try-with-never body is reachable via the catch path), which removes the false positive at the source on all PHP versions. Drops the now-unnecessary phpstan-baseline.neon entry (shrinks the baseline, no masking). Verified: phpstan No errors on 8.5 with the entry removed; 496 tests green. --- phpstan-baseline.neon | 5 ----- tests/Unit/Telemetry/StopwatchScopeTest.php | 18 +++++++++++++----- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c306c75..9d2bdc7 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -294,8 +294,3 @@ parameters: count: 3 path: tests/Unit/Telemetry/MeasuredActionTest.php - - - message: '#^Unreachable statement \- code above always terminates\.$#' - identifier: deadCode.unreachable - count: 2 - path: tests/Unit/Telemetry/StopwatchScopeTest.php diff --git a/tests/Unit/Telemetry/StopwatchScopeTest.php b/tests/Unit/Telemetry/StopwatchScopeTest.php index dd16880..aa10b75 100644 --- a/tests/Unit/Telemetry/StopwatchScopeTest.php +++ b/tests/Unit/Telemetry/StopwatchScopeTest.php @@ -98,11 +98,19 @@ function stopwatchReadLines(string $path): array try { $scope = stopwatchMakeScope($path); - expect(fn () => $scope->measure( - endEvent: EventName::AnalyzeEnded, - extras: ['patterns_checked_count' => 0, 'matches_count' => 0], - callable: static fn () => throw new RuntimeException('boom'), - ))->toThrow(RuntimeException::class, 'boom'); + $thrown = null; + try { + $scope->measure( + endEvent: EventName::AnalyzeEnded, + extras: ['patterns_checked_count' => 0, 'matches_count' => 0], + callable: static fn () => throw new RuntimeException('boom'), + ); + } catch (RuntimeException $e) { + $thrown = $e; + } + + expect($thrown)->toBeInstanceOf(RuntimeException::class) + ->and($thrown->getMessage())->toBe('boom'); $lines = stopwatchReadLines($path); expect($lines)->toHaveCount(1) From 5f457c9deefd83ce17f4b36b1360b7a0f40bef81 Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 18:13:20 -0300 Subject: [PATCH 18/21] chore!: require PHP 8.5+, drop 8.3/8.4 support BREAKING CHANGE: minimum PHP is now 8.5 (composer.json php: ^8.3 -> ^8.5). CI matrix reduced to 8.5 only. README/CLAUDE.md/memory updated. composer.lock content-hash is unaffected (platform requirements are excluded from it), so `composer install` stays valid. Goes out in the next minor. --- .ai/memory/MEMORY.md | 2 +- .github/workflows/ci.yml | 2 +- CLAUDE.md | 2 +- README.md | 4 ++-- composer.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.ai/memory/MEMORY.md b/.ai/memory/MEMORY.md index 36d6c60..c089b42 100644 --- a/.ai/memory/MEMORY.md +++ b/.ai/memory/MEMORY.md @@ -35,7 +35,7 @@ Memória persistente do projeto — leia antes de qualquer trabalho substantivo. - npm registry: `@henryavila/codeguard@0.1.1` continua publicado (não deprecated formalmente) ### Decisões Fixas (não reabrir sem razão forte) -- Stack: PHP 8.3+ / Laravel 11+ / Composer (core PHP-native; preset Full referencia jscpd/Node) +- Stack: PHP 8.5+ / Laravel 11+ / Composer (core PHP-native; preset Full referencia jscpd/Node). **Min PHP subiu 8.3→8.5 em 2026-06-03** (próximo minor; drop 8.3/8.4) - 2 packages: `henryavila/codeguard` (Composer) + `henryavila/codeguard-hooks` (Claude plugin bash) - Namespace: `Henryavila\Codeguard\*` - Commands: `codeguard:*` (install, check, test, prepare, analyze, baseline) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2180bab..335d2c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['8.3', '8.4'] + php-version: ['8.5'] steps: - name: Checkout diff --git a/CLAUDE.md b/CLAUDE.md index 043fbe7..a37c3ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ Depois de ler o status, carregar conforme necessidade: - **NUNCA reintroduza Node.js** — decisão arquitetural após 10 reviews. Se precisar de JS/Node para algo, questione primeiro. - **Trabalhe com o Arch como laboratório** — `/home/henry/arch` é o primeiro consumidor real; use path repository - **Declare `declare(strict_types=1)`** em todo arquivo PHP -- **PHP 8.3+** como mínimo (match, readonly, enum) +- **PHP 8.5+** como mínimo (match, readonly, enum) - **Pest 4** para testes - **Composer scripts** para tooling (não bash ad-hoc) - **Semver** rigoroso — v0.x para dev, v1.0 quando estável em 2+ projetos reais diff --git a/README.md b/README.md index 26438c3..668b09b 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ The installer auto-detects your environment, recommends a preset, shows you exac CodeGuard — Laravel quality gates installer Detecting environment... - PHP 8.3.12 + PHP 8.5.5 Composer 2.7.0 Node.js 20.10.0 package.json found @@ -168,7 +168,7 @@ php artisan codeguard:install --refresh-stubs # update stubs (diff-aware) ## Stack Requirements -- PHP **8.3+** +- PHP **8.5+** - Laravel **11** or **12** - Pest **3** or **4** (dev only) - Composer **2.x** diff --git a/composer.json b/composer.json index 04f7575..86780d2 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ } ], "require": { - "php": "^8.3", + "php": "^8.5", "captainhook/captainhook": "^5.29", "captainhook/hook-installer": "^1.0", "illuminate/console": "^11.0|^12.0", From 2ec571a8abb9f24b95d0867efa3326ce5f734ce1 Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 18:13:50 -0300 Subject: [PATCH 19/21] =?UTF-8?q?docs(memory):=20sync=20status=20=E2=80=94?= =?UTF-8?q?=20CI=20fix=20(PHPStan=208.5=20FP)=20+=20PHP=208.5+=20bump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ai/memory/PROJECT-STATUS.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.ai/memory/PROJECT-STATUS.md b/.ai/memory/PROJECT-STATUS.md index 9d1c616..0b4c7e8 100644 --- a/.ai/memory/PROJECT-STATUS.md +++ b/.ai/memory/PROJECT-STATUS.md @@ -8,11 +8,11 @@ type: project > **Para Claude**: Este é o documento vivo de estado. Leia na primeira ferramenta-call de toda sessão substantiva. Atualize ao completar qualquer commit que mude escopo, ou ao mudar de sprint/foco. Em caso de conflito com outro arquivo de memória, este ganha (pra resolver drift, corrija o outro arquivo, não aqui). -**Última atualização**: 2026-06-03 (audit + replan + Fase 1 traits + Fase 2 MVP + **Tier 2 R1–R4 completo** + **review cross-model codex de PR #1 → 3 fixes** — tudo na mesma sessão) -**HEAD**: `104d867` fix(analyze): close trust-boundary, attribution & lint gaps (codex review) -**Branch**: `feat/patterns-engine-foundation` (**PR #1** aberto pra `main`; **pushed e sincronizado** — `origin == HEAD` @ `cd1c3be`, inclui os 3 fixes da review codex). `origin/main` == `4b32886`. -**Suite**: 496 tests / 1180 assertions (verde). Pint clean, PHPStan level 5 No errors. Coverage gate (≥80%) só roda no CI (sem driver Xdebug/pcov local). -**Lint/Static**: Pint clean. PHPStan level 5 self-applied com baseline grandfathered (`156b297`) — R8 fechado. `composer ci` roda pint:test + phpstan + test:coverage; CI ativa em PHP 8.3 + 8.4 via `.github/workflows/ci.yml` (`65893ab`). +**Última atualização**: 2026-06-03 (audit + replan + Fase 1 traits + Fase 2 MVP + **Tier 2 R1–R4 completo** + **review cross-model codex de PR #1 → 3 fixes** + **CI verde: FP PHPStan-8.5 corrigido + bump PHP 8.5+** — tudo na mesma sessão) +**HEAD**: `5f457c9` chore!: require PHP 8.5+, drop 8.3/8.4 support +**Branch**: `feat/patterns-engine-foundation` (**PR #1** aberto pra `main`; **pushed e sincronizado** — `origin == HEAD`, inclui codex fixes + CI fix + bump 8.5). `origin/main` == `4b32886`. +**Suite**: 496 tests / 1180 assertions (verde). Pint clean, PHPStan level 5 No errors (no PHP 8.5 com a entrada órfã `StopwatchScopeTest` removida do baseline). Coverage gate (≥80%) só roda no CI (sem driver Xdebug/pcov local). +**Lint/Static**: Pint clean. PHPStan level 5 self-applied com baseline grandfathered (`156b297`) — R8 fechado. `composer ci` roda pint:test + phpstan + test:coverage; **CI em PHP 8.5** (min subiu 8.3→8.5 em 2026-06-03, `5f457c9`; matriz 8.3/8.4 removida) via `.github/workflows/ci.yml`. **Release publicado**: ✅ **`0.2.0` no Packagist desde 2026-05-04** (tag `0.2.0` @ `4b32886`, pushed). Arch consome via repo `vcs` GitHub pinado em `^0.2.0` (lock @ `4b32886`) — **NÃO** via path repo nem `dev-main`. > ⚠️ **Correção de drift (2026-06-03)**: este arquivo ficou congelado 30 dias num snapshot pré-release e estava ERRADO em 4 fatos load-bearing (dizia "não publicado", "68 commits ahead", "Arch via path repo/dev-main", "Arch usa codeguard:check produtivamente"). Tudo corrigido abaixo via audit verificado contra git+FS. Detalhe da correção na seção "Drift corrigido". From 627218b49c2dfa7fd291ee6056b8452f6812ed2c Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 18:20:01 -0300 Subject: [PATCH 20/21] test: cover ParallelSafetyAssertions factory-definition checks The analyze/phpstan gate stopped masking the coverage step (it used to abort at phpstan first); coverage was 79.8% < 80%. assertNoDbQueriesInFactoryDefinition and assertNoEagerCreateInFactoryDefinition were the largest untested public-API gap (ParallelSafetyAssertions at 50%). Adds two fixture-backed tests exercising both via the same anonymous-subject pattern as the existing trait tests. Total coverage 79.8% -> 80.1%; ParallelSafetyAssertions 50% -> 100%. --- tests/Unit/Assertions/AssertionTraitsTest.php | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/Unit/Assertions/AssertionTraitsTest.php b/tests/Unit/Assertions/AssertionTraitsTest.php index e175e16..c821ba8 100644 --- a/tests/Unit/Assertions/AssertionTraitsTest.php +++ b/tests/Unit/Assertions/AssertionTraitsTest.php @@ -166,3 +166,51 @@ protected function makeAntiPatternScanner(): AntiPatternScanner aptCleanup($base); } }); + +it('ParallelSafetyAssertions fails on a DB query inside a factory definition()', function (): void { + $base = aptBase(); + + try { + aptWrite( + $base, + 'database/factories/UserFactory.php', + "class UserFactory\n{\n public function definition(): array\n {\n return ['role' => DB::table('roles')->first()];\n }\n}", + ); + $subject = aptParallelSafetySubject($base); + + $threw = false; + try { + $subject->assertNoDbQueriesInFactoryDefinition(); + } catch (ExpectationFailedException) { + $threw = true; + } + + expect($threw)->toBeTrue(); + } finally { + aptCleanup($base); + } +}); + +it('ParallelSafetyAssertions fails on eager create() inside a factory definition()', function (): void { + $base = aptBase(); + + try { + aptWrite( + $base, + 'database/factories/UserFactory.php', + "class UserFactory\n{\n public function definition(): array\n {\n return ['role_id' => Role::factory()->create()->id];\n }\n}", + ); + $subject = aptParallelSafetySubject($base); + + $threw = false; + try { + $subject->assertNoEagerCreateInFactoryDefinition(); + } catch (ExpectationFailedException) { + $threw = true; + } + + expect($threw)->toBeTrue(); + } finally { + aptCleanup($base); + } +}); From 3b1623e6c8b10df8473aa19182c708138c7984fc Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Wed, 3 Jun 2026 18:21:50 -0300 Subject: [PATCH 21/21] =?UTF-8?q?docs(memory):=20sync=20status=20=E2=80=94?= =?UTF-8?q?=20coverage=2080.1%=20(ParallelSafetyAssertions=20covered)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ai/memory/PROJECT-STATUS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.ai/memory/PROJECT-STATUS.md b/.ai/memory/PROJECT-STATUS.md index 0b4c7e8..1503b4f 100644 --- a/.ai/memory/PROJECT-STATUS.md +++ b/.ai/memory/PROJECT-STATUS.md @@ -9,9 +9,9 @@ type: project > **Para Claude**: Este é o documento vivo de estado. Leia na primeira ferramenta-call de toda sessão substantiva. Atualize ao completar qualquer commit que mude escopo, ou ao mudar de sprint/foco. Em caso de conflito com outro arquivo de memória, este ganha (pra resolver drift, corrija o outro arquivo, não aqui). **Última atualização**: 2026-06-03 (audit + replan + Fase 1 traits + Fase 2 MVP + **Tier 2 R1–R4 completo** + **review cross-model codex de PR #1 → 3 fixes** + **CI verde: FP PHPStan-8.5 corrigido + bump PHP 8.5+** — tudo na mesma sessão) -**HEAD**: `5f457c9` chore!: require PHP 8.5+, drop 8.3/8.4 support -**Branch**: `feat/patterns-engine-foundation` (**PR #1** aberto pra `main`; **pushed e sincronizado** — `origin == HEAD`, inclui codex fixes + CI fix + bump 8.5). `origin/main` == `4b32886`. -**Suite**: 496 tests / 1180 assertions (verde). Pint clean, PHPStan level 5 No errors (no PHP 8.5 com a entrada órfã `StopwatchScopeTest` removida do baseline). Coverage gate (≥80%) só roda no CI (sem driver Xdebug/pcov local). +**HEAD**: `627218b` test: cover ParallelSafetyAssertions factory-definition checks +**Branch**: `feat/patterns-engine-foundation` (**PR #1** aberto pra `main`; **pushed e sincronizado** — `origin == HEAD`, inclui codex fixes + CI fix + bump 8.5 + coverage). `origin/main` == `4b32886`. +**Suite**: 498 tests / 1184 assertions (verde). Pint clean, PHPStan level 5 No errors (PHP 8.5, entrada órfã `StopwatchScopeTest` removida do baseline). **Coverage 80.1%** (gate ≥80% — o phpstan abortava antes e mascarava esse passo; com o FP corrigido o gate passou a rodar e estava 79.8% → cobertos os 2 métodos de `ParallelSafetyAssertions`). **Lint/Static**: Pint clean. PHPStan level 5 self-applied com baseline grandfathered (`156b297`) — R8 fechado. `composer ci` roda pint:test + phpstan + test:coverage; **CI em PHP 8.5** (min subiu 8.3→8.5 em 2026-06-03, `5f457c9`; matriz 8.3/8.4 removida) via `.github/workflows/ci.yml`. **Release publicado**: ✅ **`0.2.0` no Packagist desde 2026-05-04** (tag `0.2.0` @ `4b32886`, pushed). Arch consome via repo `vcs` GitHub pinado em `^0.2.0` (lock @ `4b32886`) — **NÃO** via path repo nem `dev-main`.