jessegall / code-commandments
An architecture compiler for PHP — single-concern discipline prophets that judge a codebase against a configurable style and architecture spec
Requires
- php: ^8.4
- jessegall/php-types: ^1.12.0
- nikic/php-parser: ^5.0
Requires (Dev)
- phpunit/phpunit: ^10.0|^11.0
This package is auto-updated.
Last update: 2026-06-29 21:22:40 UTC
README
A compiler for architecture.
code-commandments judges a PHP codebase against a set of architectural
disciplines and reports each violation — a "sin" — as a file:line that points at
the skill which teaches the fix. It's built for driving an AI coding agent:
the agent reads the skill, fixes at the source, and re-runs until clean.
It pairs two layers:
- Skills — the teaching layer, one per architectural subject (
absence,value-objects,spatie-data,exceptions,enums-with-behaviour,laravel-idioms,role-vocabulary,concurrent-state,documentation,fix-at-the-source). The source of truth for what good looks like. - Sin Detectors — thin finders over a fluent AST engine. Each finds one sin and names the skill that fixes it; it carries no fix logic.
Every detector is proven against a self-checking fixture where #[Sinful]
attributes are the test spec, and must fire on ≥3 genuinely-different scenarios
while leaving a righteous look-alike untouched.
Install
composer require --dev jessegall/code-commandments
Usage
# scan a codebase — sins grouped by the skill that fixes them vendor/bin/commandments judge src # scope to one skill (group) or one detector vendor/bin/commandments judge src --skill=exceptions vendor/bin/commandments judge src --detector=SwallowCatch # scope to what you changed: only your branch's files vs main, or just the working tree vendor/bin/commandments judge src --branch # new/changed on this branch vs main (--branch=BASE to override) vendor/bin/commandments judge src --changes # uncommitted working-tree changes only (alias: --git) # detectors run across 8 workers by default (capped at CPU cores); --parallel=1 disables vendor/bin/commandments judge src --parallel=4 # skip paths; list everything vendor/bin/commandments judge src --exclude=Generated,vendor vendor/bin/commandments judge --list
Exit code is non-zero when sins are found. Files marked
@code-commandments-generated are skipped automatically.
Detectors
50 detectors across 14 skills.
absence
| Detector | What it flags |
|---|---|
DeNulledFinderDetector |
A ?T finder whose result TRAVELS and is de-nulled at every stop — checked (finder()?->…, === null, ?? default) at two or more call sites. |
NullableCallbackDetector |
A nullable callback (?callable $cb = null) that the body null-normalises before calling — if ($cb !== null) { $cb(…); }, ($cb ?? fn () => …)(…). |
NullableCollectionReturnDetector |
A method declared to return ?array / array | null — a collection modelled as "the list, or null", forcing every caller to guard before iterating. |
OptionAsNullableDetector |
An Option worn as a nullable — ?Option / Option | null, or unwrapOr(null) collapsing it straight back to a null. |
concurrent-state
| Detector | What it flags |
|---|---|
ConcurrentSubclassDetector |
A class that extends Concurrent. |
documentation
| Detector | What it flags |
|---|---|
ArchaeologyCommentDetector |
A comment that narrates the code's past — // previously..., // changed from..., // now it returns.... |
BloatedDocblockDetector |
A class whose docblock runs to multiple paragraphs. |
CeremonyDocblockDetector |
A docblock that only restates the typed signature — @param Type $x with no description on an already-typed parameter, plus maybe a bare @return Type. |
enums-with-behaviour
| Detector | What it flags |
|---|---|
ConstClassEnumDetector |
A class that is nothing but scalar constants — a closed set of values hand- rolled as const STATUS_PENDING = 'pending' instead of a native backed enum. |
EnumCaseOrChainDetector |
$x === Status::Pending || $x === Status::Paid — a hand-rolled membership test against two-or-more cases of the same backed enum. |
EnumValueMatchDetector |
A match/switch over a backed enum's ->value at a call site — the enum unwrapped to a scalar so it can be dispatched on out here. |
InArrayMirrorsEnumDetector |
in_array($x, ['a', 'b', …]) whose literals ARE an existing backed enum's case values — testing membership of a set the type already seals. |
MatchDefaultReturnsNullDetector |
A match whose default arm returns null/false/[] instead of throwing. |
StringMatchMirrorsEnumDetector |
A match/switch whose arm conditions are string/int literals that ARE an existing backed enum's case values — dispatching on the loose strings instead of the type that already seals them. |
exceptions
| Detector | What it flags |
|---|---|
GenericExceptionDetector |
Throwing a generic SPL/base exception (throw new \RuntimeException(...)) instead of a named domain exception. |
MessageAtThrowDetector |
throw new X("…message…") — the failure described with a prose string at the throw site instead of a named factory carrying domain VALUES (throw OrderNotFound::forId($id)). |
SwallowCatchDetector |
A catch that swallows the failure into absence — an empty body, or whose only effect is return null/false/[]. |
WrappingWithoutCauseDetector |
Throwing a new exception inside a catch without passing the caught one on as its cause (previous) — the original failure and its stack trace are dropped, so the wrapped error lies about where it came from. |
fix-at-the-source
| Detector | What it flags |
|---|---|
DuplicateFunctionDetector |
Two-or-more functions/methods with an identical AST — the same code copy-pasted, down to a formatting-blind structural hash (spacing, newlines, and comments are ignored; only real code differences count). |
ManufacturedFakeFillDetector |
Filling an argument with a manufactured fake on absence — name: $row['name'] ?? '', (int) ($row['id'] ?? 0). |
NearDuplicateFunctionDetector |
Two-or-more functions/methods with the same SHAPE but not identical text — the same control-flow skeleton differing only in variable names or literal values (a type-2 clone). |
guard-clauses-and-flow
| Detector | What it flags |
|---|---|
DeepNestingDetector |
An if nested three-deep — a pyramid of conditions. |
IfElseLadderDetector |
An if/elseif ladder of four-plus branches — a chain of conditions doing the job of a match, a method on the type, or polymorphic dispatch. |
InlineThrowDetector |
A ?? throw buried inside a larger expression — fed into a call or dereferenced on the same line instead of guarded at the top. |
LoopInvertedGuardDetector |
A loop whose entire body is wrapped in one if — the iteration's real work pushed a level deep behind a condition. |
NestedTernaryDetector |
A nested / chained ternary — $a ? $b : ($c ? $d : $e) — folds a branching decision into one unreadable expression where the operator precedence is a trap. |
RedundantElseDetector |
An else after an if branch that already exits (return/throw/continue/ break). |
laravel-idioms
| Detector | What it flags |
|---|---|
ConfigReadDetector |
Reading configuration with config(...) inside a class instead of injecting a typed config object. |
ContainerReachDetector |
Reaching into the container with app() / resolve() from a class the container itself resolves — the dependency belongs in the constructor. |
FacadeCallDetector |
A Laravel facade call — Cache::get(...), Log::info(...), Mail::raw(...). |
MassUpdateAtCallSiteDetector |
A bare $model->update([...]) on an Eloquent model at a call site — an anonymous array of column writes with no name and no home. |
ModelMutationAtCallSiteDetector |
Setting an Eloquent model's properties then calling ->save() at a call site — $order->status = 'paid'; $order->save();. |
RawRequestInputDetector |
Raw, untyped request reads (->input()/->get()/->query()/->post()) on a request from outside the request class — use a typed accessor instead (->string(), ->integer(), …). |
RequestAccessorRecastDetector |
Re-coercing a typed request accessor at a CALL SITE — $request->string('id')->toString() (or (string) $request->string('id')) in a handler/tool/service. |
pass-the-object
| Detector | What it flags |
|---|---|
ParamResolvedFromParamDetector |
A method that UNPACKS its target out of a container parameter — takes a container object AND a scalar key, resolves the key against the container (request(Workflow $workflow, string $nodeId) doing $workflow->graph->nodeById($nodeId)), and works on the resolved target while the container is only ever packaging. |
role-vocabulary
| Detector | What it flags |
|---|---|
NullableRegistryLookupDetector |
A class's own keyed store handing back null on a miss — return $this->items[$key] ?? null. |
spatie-data
| Detector | What it flags |
|---|---|
AllNullableDataDetector |
A Spatie Data class whose every promoted field is optional — nullable or defaulted. |
DataMethodHintCollisionDetector |
A Spatie Data class with a @method docblock tag that names a method the class ACTUALLY declares — e.g. |
ManualHydrationLoopDetector |
<Data>::from(...) called per item of a collection — inside a foreach/for/ while loop, or as an array_map callback (array_map(X::from(...), $rows), array_map(fn ($r) => X::from($r), $rows)). |
NewDataObjectDetector |
Constructing a RICH Spatie Data object with new instead of ::from() — the raw new skips the work ::from() does: a cast, a name map, a nested-Data hydration, or a magic fromX() factory. |
NonFinalDataDetector |
A Spatie Data class that is not declared final. |
tell-dont-ask
| Detector | What it flags |
|---|---|
FeatureEnvyDetector |
Exiled behaviour (feature envy) — a method that reaches THROUGH one other owned object's structure, iterating its collection, to do work that belongs ON that object ($node->edges(), not EdgeDetector::detect($node)). |
KeyedLookupEnvyDetector |
Feature envy through an indirect lookup — a method that uses an owned object's identity as a KEY to fetch data about it through a collaborator, then reads a fact back ($this->registry->get($node->key)->reservedOutputNames). |
type-honesty
| Detector | What it flags |
|---|---|
MaskedInvariantDetector |
$this->scratch?->call() ?? false — defaulting a reach into the object's own TRANSIENT nullable state. |
ScratchStateRestoreDetector |
A method that SAVES one of its own properties to a local and RESTORES it afterwards — $prev = $this->scope; … $this->scope = $prev;. |
value-objects
| Detector | What it flags |
|---|---|
ArrayBagDetector |
An array parameter read by a string-literal key ($bag['total']) — a structured bag that should be a typed value object. |
ArrayReturnBagDetector |
Returning a multi-field, string-keyed array literal — a structured bag that should be a typed value object. |
DataClumpDetector |
The same three-or-more value parameters (string $shopId, string $userId, string $channelId) threaded through two-or-more signatures in different classes. |
PositionalTupleReturnDetector |
Returning a positional TUPLE — return [$node, $key, $inputs, $outputs] (also from a closure / arrow fn) — bundles several independent values as a keyless list the caller must destructure by position. |
RawDecodedArrayReturnDetector |
Returning a freshly-decoded payload straight out of a boundary — the raw array from json_decode(...) crossing back into the app untyped. |
Developing detectors
A detector is a few lines of fluent AST query. Before writing one, load the
project skills (via Claude Code's Skill tool): writing-detectors,
detector-engine, detector-fixtures. The cardinal rule is AST/semantic
detection over name matching. See CLAUDE.md and the roadmap in
SINS.md.
final class FacadeCallDetector implements Detector { public function skill(): string { return 'laravel-idioms'; } public function find(Codebase $codebase): array { return $codebase ->whereStaticCall() ->where(fn (AstNode $n): bool => str_starts_with($n->staticCallClass() ?? '', self::FACADE_NS)) ->get(); } }
License
MIT.