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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
410 changes: 341 additions & 69 deletions .claude/hooks/gruff-code-quality.sh

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
"hooks": [
{
"type": "command",
"command": "gcd=\"$(git rev-parse --git-common-dir 2>/dev/null)\" || { printf 'BLOCKED: Guard cannot start: git repository root unavailable.\\n' >&2; exit 2; }; case \"$gcd\" in /*) root=\"$(dirname \"$gcd\")\" ;; *) root=\"$(git rev-parse --show-toplevel)\" ;; esac; bash \"$root/.claude/hooks/deny-dangerous.sh\""
"command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || { printf 'BLOCKED: Guard cannot start: git repository root unavailable.\\n' >&2; exit 2; }; bash \"$root/.claude/hooks/deny-dangerous.sh\""
}
]
}
Expand All @@ -83,7 +83,7 @@
"hooks": [
{
"type": "command",
"command": "gcd=\"$(git rev-parse --git-common-dir 2>/dev/null)\" || { printf 'BLOCKED: Guard cannot start: git repository root unavailable.\\n' >&2; exit 2; }; case \"$gcd\" in /*) root=\"$(dirname \"$gcd\")\" ;; *) root=\"$(git rev-parse --show-toplevel)\" ;; esac; bash \"$root/.claude/hooks/gruff-code-quality.sh\""
"command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || { printf 'gruff-code-quality: git repository root unavailable; skipping\\n' >&2; exit 0; }; bash \"$root/.claude/hooks/gruff-code-quality.sh\""
}
]
},
Expand All @@ -92,7 +92,7 @@
"hooks": [
{
"type": "command",
"command": "gcd=\"$(git rev-parse --git-common-dir 2>/dev/null)\" || { printf 'BLOCKED: Guard cannot start: git repository root unavailable.\\n' >&2; exit 2; }; case \"$gcd\" in /*) root=\"$(dirname \"$gcd\")\" ;; *) root=\"$(git rev-parse --show-toplevel)\" ;; esac; bash \"$root/.claude/hooks/gruff-code-quality.sh\""
"command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || { printf 'gruff-code-quality: git repository root unavailable; skipping\\n' >&2; exit 0; }; bash \"$root/.claude/hooks/gruff-code-quality.sh\""
}
]
},
Expand All @@ -101,7 +101,7 @@
"hooks": [
{
"type": "command",
"command": "gcd=\"$(git rev-parse --git-common-dir 2>/dev/null)\" || { printf 'BLOCKED: Guard cannot start: git repository root unavailable.\\n' >&2; exit 2; }; case \"$gcd\" in /*) root=\"$(dirname \"$gcd\")\" ;; *) root=\"$(git rev-parse --show-toplevel)\" ;; esac; bash \"$root/.claude/hooks/gruff-code-quality.sh\""
"command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || { printf 'gruff-code-quality: git repository root unavailable; skipping\\n' >&2; exit 0; }; bash \"$root/.claude/hooks/gruff-code-quality.sh\""
}
]
}
Expand Down
410 changes: 341 additions & 69 deletions .codex/hooks/gruff-code-quality.sh

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions .goat-flow/architecture.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Architecture - gruff-php

Last reviewed 2026-06-01. All claims map to a real file in `src/`, `tests/`, or top-level config; cross-check before broadening any of them.
Last reviewed 2026-06-03. All claims map to a real file in `src/`, `tests/`, or top-level config; cross-check before broadening any of them.

## System Overview

Expand Down Expand Up @@ -57,7 +57,7 @@ Static finding baselines default to `gruff-baseline.json` at the project root: `

## Rule Catalogue

The default registry-backed static rule set covers 11 emitted pillars (`Size`, `Complexity`, `Maintainability`, `DeadCode`, `Naming`, `Documentation`, `Modernisation`, `Security`, `SensitiveData`, `TestQuality`, `Design`) and currently exposes 132 rule ids through `list-rules --format json`. `waste.*` rule ids are historical names that emit either `DeadCode` or `Maintainability` findings. Infection ingestion can also emit `Mutation` pillar findings. All emitted rules are tier `v0.1`; `Coupling` and `Architecture` remain reserved.
The default registry-backed static rule set covers 11 emitted pillars (`Size`, `Complexity`, `Maintainability`, `DeadCode`, `Naming`, `Documentation`, `Modernisation`, `Security`, `SensitiveData`, `TestQuality`, `Design`) and currently exposes 133 rule ids through `list-rules --format json`. `waste.*` rule ids are historical names that emit either `DeadCode` or `Maintainability` findings. Infection ingestion can also emit `Mutation` pillar findings. All emitted rules are tier `v0.1`; `Coupling` and `Architecture` remain reserved.

| Family | Rule ids | Notes |
| --- | --- | --- |
Expand All @@ -70,7 +70,7 @@ The default registry-backed static rule set covers 11 emitted pillars (`Size`, `
| Modernisation | `modernisation.constructor-promotion-candidate`, `modernisation.enum-candidate`, `modernisation.first-class-callable-candidate`, `modernisation.forbidden-global-access`, `modernisation.match-expression-candidate`, `modernisation.mixed-type-overuse`, `modernisation.named-argument-opportunity`, `modernisation.phpdoc-mixed-overuse`, `modernisation.public-property`, `modernisation.readonly-property-candidate` | PHP-version-gated opportunity checks where syntax support matters; no autofix behavior; `modernisation.phpdoc-mixed-overuse` covers PHPDoc contracts that signatures cannot express; `ModernisationNodeHelper` is shared infrastructure |
| Security | `security.dangerous-function-call`, `security.disabled-ssl-verification`, `security.error-suppression`, `security.extract-compact-user-input`, `security.github-actions-risky-workflow`, `security.header-injection`, `security.insecure-random`, `security.path-traversal-file-access`, `security.process-command-construction`, `security.request-controlled-url`, `security.sensitive-data-logging`, `security.silent-catch`, `security.sql-concatenation`, `security.unsafe-archive-extraction`, `security.unsafe-xml-loading`, `security.unsafe-unserialize`, `security.variable-include`, `security.weak-crypto` | Mostly heuristic AST checks; `security.github-actions-risky-workflow` is a source-text workflow YAML check scoped to `.github/workflows`; `SecurityNodeHelper` is shared infrastructure |
| SensitiveData | `sensitive-data.api-key-pattern`, `sensitive-data.aws-access-key`, `sensitive-data.database-url-password`, `sensitive-data.gcp-service-account-key`, `sensitive-data.hardcoded-env-value`, `sensitive-data.high-entropy-string`, `sensitive-data.jwt-token`, `sensitive-data.phi-pattern`, `sensitive-data.pii-test-fixture`, `sensitive-data.private-key`, `sensitive-data.url-credentials` | All implement `SourceTextRuleInterface`, so they also scan JSON/YAML/INI/.env-style files; provider/token findings carry deterministic redacted previews, and `SecretScannerHelper` is shared infrastructure |
| TestQuality | Source-test rules: `test-quality.no-assertions`, `test-quality.trivial-assertion`, `test-quality.conditional-logic`, `test-quality.loop-assertion-without-message`, `test-quality.test-longer-than-sut`, `test-quality.test-method-too-long`, `test-quality.eager-test`, `test-quality.mystery-guest`, `test-quality.excessive-mocking`, `test-quality.mock-only-test`, `test-quality.mock-without-expectation`, `test-quality.mocking-domain-object`, `test-quality.multiple-aaa-cycles`, `test-quality.unused-mock`, `test-quality.sleep-in-test`, `test-quality.naming-consistency`, `test-quality.magic-number-assertion`, `test-quality.private-reflection`, `test-quality.data-provider-annotation`, `test-quality.empty-data-provider`, `test-quality.trivial-snapshot`, `test-quality.sut-not-called`, `test-quality.setup-bloat`, `test-quality.skipped-without-reason`, `test-quality.extends-production-class`, `test-quality.tautological-type-assertion`, `test-quality.testdox-readability`, `test-quality.exception-type-only`, `test-quality.global-state-mutation`, `test-quality.repeated-structure-missing-data-provider`. `test-quality.mocking-domain-object` is enabled but emits only when `domainNamespaces` patterns are configured. Project-config rules (one finding per analyse run, read from `phpunit.xml`/`phpunit.xml.dist`/`phpunit.dist.xml`): `test-quality.phpunit-strict-flags-missing`, `test-quality.phpunit-deprecations-not-fatal`, `test-quality.phpunit-coverage-source-missing`. PHPUnit/Pest AST heuristics scoped to detected test methods or closures; confidence labels identify noisier smells; the `error` hard-gates are the "this test proves nothing" signals — `test-quality.no-assertions`, `test-quality.sut-not-called`, `test-quality.tautological-type-assertion`, `test-quality.empty-data-provider`, and `test-quality.extends-production-class` (ADR-022) — while the style/ceremony smells stay warning/advisory; `TestQualityNodeHelper` is shared infrastructure |
| TestQuality | Source-test rules: `test-quality.no-assertions`, `test-quality.trivial-assertion`, `test-quality.conditional-logic`, `test-quality.loop-assertion-without-message`, `test-quality.test-longer-than-sut`, `test-quality.test-method-too-long`, `test-quality.eager-test`, `test-quality.mystery-guest`, `test-quality.excessive-mocking`, `test-quality.mock-only-test`, `test-quality.mock-without-expectation`, `test-quality.mocking-domain-object`, `test-quality.multiple-aaa-cycles`, `test-quality.unused-mock`, `test-quality.sleep-in-test`, `test-quality.naming-consistency`, `test-quality.magic-number-assertion`, `test-quality.private-reflection`, `test-quality.data-provider-annotation`, `test-quality.empty-data-provider`, `test-quality.trivial-snapshot`, `test-quality.sut-not-called`, `test-quality.setup-bloat`, `test-quality.skipped-without-reason`, `test-quality.extends-production-class`, `test-quality.tautological-type-assertion`, `test-quality.static-analysis-redundant-test`, `test-quality.testdox-readability`, `test-quality.exception-type-only`, `test-quality.global-state-mutation`, `test-quality.repeated-structure-missing-data-provider`. `test-quality.mocking-domain-object` is enabled but emits only when `domainNamespaces` patterns are configured. Project-config rules (one finding per analyse run, read from `phpunit.xml`/`phpunit.xml.dist`/`phpunit.dist.xml`): `test-quality.phpunit-strict-flags-missing`, `test-quality.phpunit-deprecations-not-fatal`, `test-quality.phpunit-coverage-source-missing`. PHPUnit/Pest AST heuristics scoped to detected test methods or closures; confidence labels identify noisier smells; the `error` hard-gates are the "this test proves nothing" signals — `test-quality.no-assertions`, `test-quality.sut-not-called`, `test-quality.tautological-type-assertion`, `test-quality.empty-data-provider`, and `test-quality.extends-production-class` (ADR-022) — while shape-only candidate tests, style, and ceremony smells stay warning/advisory; `TestQualityNodeHelper` is shared infrastructure |
| Design | `design.single-implementor-interface` | Project rule that flags internal interfaces with one implementor and no external type-hint usage |
| Mutation | `mutation.survived-mutant`, `mutation.budget-exceeded`, `mutation.msi-regression` | Not registry-backed static rules; emitted only from optional Infection JSON ingestion |

Expand Down
3 changes: 2 additions & 1 deletion .goat-flow/code-map.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Code Map - gruff-php

Last reviewed 2026-06-01. Captures the v0.3.0 surface as wired in `composer.json`, `bin/gruff-php`, `src/`, and `tests/`. Treat directory listings as authoritative for scope, but always re-grep before claiming behaviour.
Last reviewed 2026-06-03. Captures the v0.3.1 surface as wired in `composer.json`, `bin/gruff-php`, `src/`, and `tests/`. Treat directory listings as authoritative for scope, but always re-grep before claiming behaviour.

## Top-level layout

Expand Down Expand Up @@ -246,6 +246,7 @@ src/
| | |-- SetupBloatRule.php = `test-quality.setup-bloat`
| | |-- SkippedWithoutReasonRule.php = `test-quality.skipped-without-reason`
| | |-- SleepInTestRule.php = `test-quality.sleep-in-test` (covers `sleep`/`usleep` family + `time`/`microtime` + `new DateTime('now')`/`DateTimeImmutable()`)
| | |-- StaticAnalysisRedundantTestRule.php = `test-quality.static-analysis-redundant-test` (advisory candidate for tests that assert same-file static declarations such as class_exists/method_exists/property_exists)
| | |-- SutNotCalledRule.php = `test-quality.sut-not-called` (skips subprocess-execution tests; matches verb-without-trailing-`s` candidates so `testLoadsX` matches `load()`)
| | |-- TautologicalTypeAssertionRule.php = `test-quality.tautological-type-assertion` (only when local static evidence proves the asserted type)
| | |-- TestdoxReadabilityRule.php = `test-quality.testdox-readability` (`minWords` threshold)
Expand Down
11 changes: 10 additions & 1 deletion .goat-flow/decisions/ADR-022-test-quality-gate-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
**Status:** Implemented
**Date:** 2026-05-30
**Author(s):** gruff maintainers
**Updated:** 2026-05-30 — amends ADR-010 (severity calibration); extends ADR-017 (mission corollary)
**Updated:** 2026-06-03 — amends ADR-010 (severity calibration); extends ADR-017 (mission corollary); records advisory static-analysis-redundant candidates

## Context

Expand Down Expand Up @@ -49,6 +49,15 @@ over-fire: `mock-only-test`, `mock-without-expectation`, `trivial-assertion`,
`excessive-mocking`, `setup-bloat`, `magic-number-assertion`, naming/readability). Forcing
those would manufacture ceremony — the opposite of the mission.

Add `test-quality.static-analysis-redundant-test` as an **advisory** candidate rule, not a
hard gate. It flags direct static-shape assertions such as
`assertTrue(class_exists(Foo::class))` or `assertTrue(method_exists(Foo::class, 'bar'))`
only when the declaration is visible in the same parsed file. These findings must use
candidate language and recommend behavioral evidence, because public compatibility tests
and runtime contract probes can be legitimate even when they mention source shape. Promotion
to `error` would require the same false-positive-clean evidence standard used for
`test-quality.tautological-type-assertion`.

Severity is metadata, not schema: `gruff.analysis.v2` / `gruff.baseline.v1` are unchanged.
The two stability snapshots (rule-definition digest, fixture-finding digest) are refreshed
in the same change.
Expand Down
12 changes: 11 additions & 1 deletion .goat-flow/footguns/commands.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
category: commands
last_reviewed: 2026-05-31
last_reviewed: 2026-06-04
---

# CLI Command Footguns
Expand All @@ -25,6 +25,16 @@ The default-applied `gruff-baseline.json` matches accepted-debt findings to live

**Prevention:** When refactoring a file that carries baseline-suppressed findings, first run `grep <ClassName> gruff-baseline.json` to learn which findings it has accepted, then either (a) add the new code *below* every suppressed finding and keep any edit above them net-zero in line count — the trick used to keep `stripTopLevelNullUnion` from shifting `PhpDocMixedOveruseRule`'s baselined methods — or (b) fix the resurfaced finding for real, or (c) regenerate with `gruff-php analyse --generate-baseline gruff-baseline.json` after reviewing the movement diff.

## Footgun: Finding-scope filters must treat an empty target set as drop-all, not pass-through

**Status:** active | **Created:** 2026-06-04 | **Evidence:** OBSERVED

`src/Command/AnalysisFindingSupport.php` holds sibling finding filters with deliberately different — and easy-to-confuse — empty-set semantics. `filterFindingsToChangedFiles` (search: `intentional drop-all`) returns `[]` on an empty changed set because "nothing changed" means nothing qualifies. `filterProjectRuleFindingsToFiles` (search: `filterProjectRuleFindingsToFiles`) originally short-circuited `if ($projectRuleIds === [] || $filePaths === []) return $findings;`, returning ALL findings when the requested path set discovered zero files. Because the legacy pipeline runs project rules over the whole-tree context regardless of the narrow request (`src/Rule/RuleRegistry.php`, search: `$projectUnits ?? $units`), a scoped run whose path matched no source files (e.g. `analyse some/dir-with-no-php --diff-vs main` with a project rule like `dead-code.unused-internal-class`) leaked whole-repo project findings into a run the user scoped to nothing.

**Evidence:** PR #8 review (CodeRabbit, "Don't treat an empty discovered-file set as unscoped"). Both callers pass `AnalysisSourceSet::displayPaths()` (search: `displayPaths`), which is empty exactly when the requested paths discovered no files. The fix drops project-rule findings on an empty set (search: `nothing is in scope`) while still returning unchanged when there are simply no project rules to scope. `tests/Command/AnalysisFindingSupportTest.php` (search: `WhenNoFilesDiscovered`) locks the behaviour.

**Prevention:** For any filter whose job is "keep findings inside scope set S", an empty S means "nothing is in scope" → drop the in-scope-only findings, not "no filter" → keep everything. Only return the input unchanged when the FILTER itself is inactive (no rule ids, no allowlist) — a different condition from an empty scope set. When adding a finding filter, write the empty-scope-set case as an explicit test before the happy path; the `=== [] return $findings` shortcut reads as a harmless guard but silently inverts the filter.

## Resolved Entries

## Footgun: Dispatching a sub-command loses the caller's project-root context
Expand Down
Loading
Loading