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/.ai/memory/PROJECT-STATUS.md b/.ai/memory/PROJECT-STATUS.md index 714e633..1503b4f 100644 --- a/.ai/memory/PROJECT-STATUS.md +++ b/.ai/memory/PROJECT-STATUS.md @@ -8,278 +8,165 @@ 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 + 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**: `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`. ---- - -## 🎯 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 — **construção completa; aguarda validação de campo**) -**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) | ❌ | ✅ 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) | -- `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) +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`. **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. -### Backlog pós-alpha (itens empurrados) +**Decisão do usuário pendente**: revisar/mergear **PR #1**. -- **#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) +**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). --- ## 🟢 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` | ✅ | 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 | — | ### 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\*` | ✅ | AntiPatternScanner + 2 traits implementados (7 checks reais, 21 tests). `0dfb953`/`4c662a0`. | +| `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. | -### 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` | ✅ **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) | --- -## 📚 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) | **~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 | --- -## 🚦 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~~ | **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~~ | **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) | --- -## 📝 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) +- **▶ 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) +- **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/.ai/memory/SESSION-HANDOFF.md b/.ai/memory/SESSION-HANDOFF.md new file mode 100644 index 0000000..4f038a7 --- /dev/null +++ b/.ai/memory/SESSION-HANDOFF.md @@ -0,0 +1,65 @@ +--- +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, **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)** — 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) + +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). + +## 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. **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). + +## 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. 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/.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", diff --git a/docs/patterns-recall.md b/docs/patterns-recall.md new file mode 100644 index 0000000..0c85828 --- /dev/null +++ b/docs/patterns-recall.md @@ -0,0 +1,51 @@ +# 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_ | | +| … | | | | + +### 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/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. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 2e97319..9d2bdc7 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 @@ -306,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/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/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/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..e45c160 --- /dev/null +++ b/resources/skills/codeguard-review/SKILL.md @@ -0,0 +1,259 @@ +--- +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 +``` + +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", + "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. 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 + +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). + +**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 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 +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. + +**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 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 +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). +- **`--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. +- **`--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. +- **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/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/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/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 new file mode 100644 index 0000000..66b6318 --- /dev/null +++ b/src/Analyze/AnalyzeResult.php @@ -0,0 +1,55 @@ + $matches + */ + public function __construct( + public int $patternsChecked, + public array $matches, + public int $durationMs, + public bool $adjudicated = true, + public int $suppressedCount = 0, + ) {} + + /** + * 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..2276a53 --- /dev/null +++ b/src/Analyze/AnalyzeRunner.php @@ -0,0 +1,405 @@ + $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); + + $units = $this->units($files, $presets); + $graphKeys = $this->graphLevelKeys($this->patterns->forPresets($presets)); + $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, $graphKeys); + if ($match !== null) { + $matches[] = $match; + } + } + } + } + + 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 + * @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, 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, + 'patterns' => array_map( + static fn (Pattern $pattern): array => $pattern->toPromptArray(), + $unit->patterns, + ), + ], + $this->matcher->match($files, $patterns), + ); + + $architecturePatterns = array_map( + static fn (Pattern $pattern): array => $pattern->toPromptArray(), + $this->matcher->graphLevel($patterns), + ); + + return [ + 'system_prompt' => $this->systemPrompt(), + 'finding_schema' => FindingSchema::jsonSchema(), + 'samples' => max(1, $samples), + 'critique' => $critique, + 'graph' => (new NamespaceGraph)->build($files, $this->matcher->workingDirectory()), + 'architecture' => ['patterns' => $architecturePatterns], + '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); + + $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, $this->graphLevelKeys($patterns)); + + 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); + + $patterns = $this->patterns->forPresets($presets); + $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, $graphKeys), + $sampleSets, + ); + + $matches = (new FindingVoter)->tally($validatedSamples, $minVotes); + + 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 + * sample of {@see ingestSamples()}. + * + * @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 $graphKeys): array + { + $matches = []; + foreach ($rawFindings as $raw) { + if (! is_array($raw)) { + continue; + } + + $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, $graphKeys); + if ($match !== null) { + $matches[] = $match; + } + } + + 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)); + } + + /** + * 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 + * @return list + */ + private function units(array $files, array $presets): array + { + return $this->matcher->match($files, $this->patterns->forPresets($presets)); + } + + /** + * 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 + */ + private function findUnit(array $units, string $file): ?AnalysisUnit + { + foreach ($units as $unit) { + if ($unit->file === $file) { + return $unit; + } + } + + $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, + static fn (AnalysisUnit $unit): bool => basename($unit->file) === $base, + )); + + 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 + */ + private function finish(array $matches, int $checks, float $start, ?Severity $failOn, bool $adjudicated): AnalyzeResult + { + $fresh = []; + $suppressed = 0; + foreach ($this->surviveCritique($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: $fresh, + durationMs: $durationMs, + adjudicated: $adjudicated, + suppressedCount: $suppressed, + ); + + $this->recorder->record( + event: EventName::AnalyzeEnded, + status: $this->status($result, $failOn), + durationMs: $durationMs, + extras: [ + 'patterns_checked_count' => $checks, + 'matches_count' => count($fresh), + ], + ); + + 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..d69bbd3 --- /dev/null +++ b/src/Analyze/FindingSchema.php @@ -0,0 +1,66 @@ + + */ + 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'], + // 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/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/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/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/Pattern.php b/src/Analyze/Pattern.php new file mode 100644 index 0000000..98b6f1a --- /dev/null +++ b/src/Analyze/Pattern.php @@ -0,0 +1,109 @@ + $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, + ); + } + + /** + * 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 + */ + 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..d0060a1 --- /dev/null +++ b/src/Analyze/PatternMatch.php @@ -0,0 +1,117 @@ +patternKey, + $this->file, + $this->line, + $this->message, + $this->severity, + $confidence, + $this->verifiedScore, + $this->relatedFile, + ); + } + + /** + * @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, array $extraAllowedKeys = []): ?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 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; + } + + // 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, + verifiedScore: self::parseVerifiedScore($raw[FindingSchema::KEY_VERIFIED_SCORE] ?? null), + relatedFile: is_string($raw[FindingSchema::KEY_RELATED_FILE] ?? null) ? $raw[FindingSchema::KEY_RELATED_FILE] : 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/Analyze/PatternMatcher.php b/src/Analyze/PatternMatcher.php new file mode 100644 index 0000000..1b8623f --- /dev/null +++ b/src/Analyze/PatternMatcher.php @@ -0,0 +1,208 @@ + + */ + private const CLASS_STRUCTURE_PATTERNS = [ + 'no-god-object', + 'single-responsibility', + 'no-deep-inheritance', + '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 + * @return list + */ + public function match(array $files, array $patterns): array + { + $units = []; + + 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, + 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 !== []) { + $units[] = new AnalysisUnit($file, $matched); + } + } + + return $units; + } + + /** + * @param list $imports + */ + private function patternApplies(Pattern $pattern, string $relativePath, array $imports): bool + { + foreach ($pattern->detectionSignals as $signal) { + if ($this->signalMatches($signal, $relativePath, $imports)) { + return true; + } + } + + return false; + } + + /** + * @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' => $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 (in_array($value, self::CATCH_ALL_IMPORTS, true)) { + 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. + $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 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/PhpFileInspector.php b/src/Analyze/PhpFileInspector.php new file mode 100644 index 0000000..898eb73 --- /dev/null +++ b/src/Analyze/PhpFileInspector.php @@ -0,0 +1,112 @@ + + */ + 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; + } + + /** + * 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 + */ + 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/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/Assertions/AntiPatternScanner.php b/src/Assertions/AntiPatternScanner.php new file mode 100644 index 0000000..5dfc204 --- /dev/null +++ b/src/Assertions/AntiPatternScanner.php @@ -0,0 +1,364 @@ + */ + 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 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. 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 + { + $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; + } + + /** + * `->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/src/CodeguardServiceProvider.php b/src/CodeguardServiceProvider.php index 8b65d98..a21c605 100644 --- a/src/CodeguardServiceProvider.php +++ b/src/CodeguardServiceProvider.php @@ -4,6 +4,15 @@ namespace Henryavila\Codeguard; +use Henryavila\Codeguard\Analyze\AnalyzeBaseline; +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 +75,7 @@ public function register(): void $this->registerInstallServices(); $this->registerTelemetryServices(); $this->registerTestingServices(); + $this->registerAnalyzeServices(); } public function boot(): void @@ -297,6 +307,77 @@ 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(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), + 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), + baseline: $app->make(AnalyzeBaseline::class), + systemPromptPath: $systemPromptPath, + ); + }); + } + private function bootConsole(): void { $this->commands([ @@ -304,6 +385,7 @@ private function bootConsole(): void CodeguardInstallOverrideCommand::class, CodeguardCheckCommand::class, CodeguardTestCommand::class, + CodeguardAnalyzeCommand::class, TelemetryEnableCommand::class, TelemetryDisableCommand::class, TelemetryClearCommand::class, @@ -320,5 +402,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 new file mode 100644 index 0000000..48e7b71 --- /dev/null +++ b/src/Commands/CodeguardAnalyzeCommand.php @@ -0,0 +1,343 @@ +option('emit')) { + return $this->handleEmit($config, $runner, $scope); + } + + $ingest = $this->option('ingest'); + if (is_string($ingest) && $ingest !== '') { + return $this->handleIngest($config, $runner, $scope, $recorder, $baseline, $ingest); + } + + $context = $this->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->maybeAccept($baseline, $result); + $this->renderFindings($result); + + $exitCode = $result->failed($failOn) ? self::FAILURE : self::SUCCESS; + $this->emitCommandEnd($recorder, $exitCode, $startHrtime); + + 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 + { + $samples = $this->resolveSamples(); + $critique = (bool) $this->option('critique'); + $files = $this->resolveFiles($scope); + $workOrder = $runner->buildWorkOrder($files, $config->enabledPresets, $samples, $critique); + + $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)%s to %s', + count($workOrder['units']), + $samples > 1 ? sprintf(' (×%d voting samples)', $samples) : '', + $out, + )); + + return self::SUCCESS; + } + + private function handleIngest( + CodeguardConfig $config, + AnalyzeRunner $runner, + FileScopeResolver $scope, + Recorder $recorder, + AnalyzeBaseline $baseline, + 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); + $decoded = $contents === false ? null : json_decode($contents, true); + + $files = $this->resolveFiles($scope); + + $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); + + $exitCode = $result->failed($failOn) ? self::FAILURE : self::SUCCESS; + $this->emitCommandEnd($recorder, $exitCode, $startHrtime); + + 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. + * + * @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 + */ + 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).%s', + $result->patternsChecked, + $this->suppressedSuffix($result), + )); + + return; + } + + foreach ($result->matches as $match) { + $this->line(sprintf( + ' %s %s:%d · %s · %s (%.2f)%s%s', + $this->glyph($match->severity), + $match->file, + $match->line, + $match->patternKey, + $match->message, + $match->confidence, + $match->verifiedScore !== null ? sprintf(' [score %d/10]', $match->verifiedScore) : '', + $match->relatedFile !== null ? sprintf(' → %s', $match->relatedFile) : '', + )); + } + + $this->line(''); + $this->components->info(sprintf( + '%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) { + 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..cb023f9 --- /dev/null +++ b/tests/Feature/CodeguardAnalyzeCommandTest.php @@ -0,0 +1,463 @@ + 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); + + // 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); +} + +/** + * @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); + } +}); + +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); + } +}); + +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('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'; + $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/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/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/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/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/AnalyzeRunnerTest.php b/tests/Unit/Analyze/AnalyzeRunnerTest.php new file mode 100644 index 0000000..b55c344 --- /dev/null +++ b/tests/Unit/Analyze/AnalyzeRunnerTest.php @@ -0,0 +1,178 @@ + $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('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']); + + expect($order['units'])->toHaveCount(1) + ->and($order['units'][0]['file'])->toBe('/work/app/Foo.php') + ->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('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']); + $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/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/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); +}); diff --git a/tests/Unit/Analyze/LlmContractTest.php b/tests/Unit/Analyze/LlmContractTest.php new file mode 100644 index 0000000..7c91eba --- /dev/null +++ b/tests/Unit/Analyze/LlmContractTest.php @@ -0,0 +1,41 @@ +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, + ); +}); + +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); +}); + +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 new file mode 100644 index 0000000..040958f --- /dev/null +++ b/tests/Unit/Analyze/PatternMatchTest.php @@ -0,0 +1,114 @@ + ['signals' => [['type' => 'file', 'value' => '**/*.php']]], + 'verification' => ['rules' => ['r']], + 'examples' => ['correct' => '', 'violation' => ''], + 'severity' => 'critical', + ]); + + return new AnalysisUnit('/work/app/Foo.php', [$pattern]); +} + +/** + * @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(), []); + + 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 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'); +}); + +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(), []))->toBeNull(); +}); + +it('drops a finding pointing at a different file (anti-hallucination)', function (): void { + 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(), []))->toBeNull(); +}); + +it('drops a finding with out-of-range confidence', function (): void { + 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(), []))->toBeNull(); +}); + +it('parses a verified_score into the match', function (): void { + $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(), []); + + 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(), [])?->verifiedScore)->toBeNull(); +}); + +it('ignores an out-of-range verified_score (treats it as uncritiqued)', function (): void { + 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(), []); + + 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(), [])?->relatedFile)->toBeNull() + ->and(PatternMatch::fromArray(pmtRaw(['related_file' => 123]), pmtUnit(), [])?->relatedFile)->toBeNull(); +}); diff --git a/tests/Unit/Analyze/PatternMatcherTest.php b/tests/Unit/Analyze/PatternMatcherTest.php new file mode 100644 index 0000000..799c093 --- /dev/null +++ b/tests/Unit/Analyze/PatternMatcherTest.php @@ -0,0 +1,158 @@ + $signals + */ +function pmPattern(string $key, array $signals): Pattern +{ + return Pattern::fromArray($key, [ + 'detection' => ['signals' => $signals], + 'verification' => ['rules' => ['r']], + 'examples' => ['correct' => '', 'violation' => ''], + 'severity' => 'warning', + ]); +} + +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('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(['dry']); +}); + +it('matches brace-expansion globs at the repo root too', function (): void { + $matcher = new PatternMatcher('/work'); + $pattern = pmPattern('dry', [['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('dry', [['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('produces no unit when nothing matches', function (): void { + $matcher = new PatternMatcher('/work'); + $pattern = pmPattern('dry', [['type' => 'directory', 'value' => 'app/Nope']]); + + expect($matcher->match(['/work/app/Foo.php'], [$pattern]))->toBe([]); +}); + +// ── 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('gates class-structure patterns to files that declare a class', function (): void { + $base = pmBase(); + + 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); + } +}); + +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/PatternSelectionCoverageTest.php b/tests/Unit/Analyze/PatternSelectionCoverageTest.php new file mode 100644 index 0000000..3a9a4ad --- /dev/null +++ b/tests/Unit/Analyze/PatternSelectionCoverageTest.php @@ -0,0 +1,115 @@ +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); + } +}); + +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); + } +}); 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/PhpFileInspectorTest.php b/tests/Unit/Analyze/PhpFileInspectorTest.php new file mode 100644 index 0000000..11c50eb --- /dev/null +++ b/tests/Unit/Analyze/PhpFileInspectorTest.php @@ -0,0 +1,69 @@ +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(); +}); + +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(); +}); 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); + } +}); diff --git a/tests/Unit/Assertions/AntiPatternScannerTest.php b/tests/Unit/Assertions/AntiPatternScannerTest.php new file mode 100644 index 0000000..2c0afad --- /dev/null +++ b/tests/Unit/Assertions/AntiPatternScannerTest.php @@ -0,0 +1,333 @@ +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); + } +}); + +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 { + $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..c821ba8 --- /dev/null +++ b/tests/Unit/Assertions/AssertionTraitsTest.php @@ -0,0 +1,216 @@ +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); + } +}); + +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); + } +}); 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)