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
- nikic/php-parser: ^5.0
Requires (Dev)
- phpunit/phpunit: ^10.0|^11.0
This package is auto-updated.
Last update: 2026-07-08 23:04:37 UTC
README
An architecture linter for PHP & Vue, built to drive AI coding agents.
code-commandments judges a PHP and Vue codebase against a set of architectural
disciplines. Every violation (a "sin") is reported as a file:line, grouped under
the skill that teaches the fix.
It's built for AI coding agents: point your agent at a codebase and it reads the skill each sin names, fixes at the source, and re-runs until clean. You can drive it by hand too.
A linter tells you a line is too long. code-commandments tells you this array should be a value object, and here's the discipline that explains why.
Contents
- How it works
- Install
- Usage
- Configuration
- Freezing a file
- Hooks
- How detectors are tested
- Skills
- Sins & detectors
- Auto-fixing
- Scaffolding
- Developing detectors
- License
How it works
The loop is simple:
- Judge:
commandments judgeprints every sin as afile:line, grouped by skill. - Learn: each sin names a skill; you (or your agent) read it.
- Fix: fix at the source, or let
commandments repentauto-fix. - Repeat: re-run until clean (exit code
0).
One pass of that loop, with an agent driving:
Under the hood there are two layers:
- Skills: the teaching layer. One doc per discipline, the source of truth for what "good" looks like.
- Sin detectors: small finders that read the syntax tree. Each finds one kind of sin and names the skill that fixes it. Detectors find, skills teach, scribes auto-fix.
You don't need the packages a rule is about. Detectors match on real types, so a rule for a package your project doesn't use never fires. Nothing to install or configure to keep it quiet.
Install
composer require --dev jessegall/code-commandments vendor/bin/commandments install
Usage
# scan. With no path, judge reads the source roots from .commandments/config.php # (auto-detected from composer.json on the first run) vendor/bin/commandments judge vendor/bin/commandments judge src # or point it at a path # scope to one skill (group) or one sin vendor/bin/commandments judge src --skill=exceptions vendor/bin/commandments judge src --sin=swallow-catch # scope to what you changed vendor/bin/commandments judge src --branch # branch vs main (--branch=BASE to override) vendor/bin/commandments judge src --changes # uncommitted working-tree changes # detectors run across 8 workers by default (capped at CPU cores); --parallel=1 disables vendor/bin/commandments judge src --parallel=4 # skip paths (comma-separated fragments); list everything vendor/bin/commandments judge src --exclude=Generated,Legacy vendor/bin/commandments judge --list # executing an approved plan (see Hooks below) vendor/bin/commandments checks start # run the project's start / phase / complete checks vendor/bin/commandments checks phase vendor/bin/commandments checks complete # full gate: your checks, then `judge --branch` vendor/bin/commandments plan status # is a plan active? (`plan done` ends it)
Exit code is non-zero when sins are found.
Configuration
You don't have to configure anything. Every detector is enabled out of the box. Configuration is opt-out: silence a rule, tune a threshold, or add a detector of your own.
A commented .commandments/config.php is scaffolded on install. It returns a
closure given a Config; no framework required, the CLI loads the file itself:
<?php use JesseGall\CodeCommandments\Config; use JesseGall\CodeCommandments\Detectors\Backend\DataClumpDetector; use JesseGall\CodeCommandments\Detectors\Backend\Laravel\FacadeCallDetector; use JesseGall\CodeCommandments\Detectors\Frontend\DeepNestedDetector; use JesseGall\CodeCommandments\Sins\Backend\Spatie\NonFinalData; use JesseGall\CodeCommandments\Skills\Backend\ValueObjects; return function (Config $config): void { $config // The source roots judge and repent scan (auto-detected on first run). ->paths('app', 'src') // Silence a rule by its Sin class (drops the detector that finds it). ->disable(NonFinalData::class) // ...or by a specific Detector class. ->disable(FacadeCallDetector::class) // ...or by a whole Skill class (every detector that discipline teaches). ->disable(ValueObjects::class) // Add a detector that lives in YOUR codebase. ->detector(\App\Commandments\NoRawSqlDetector::class) // Tune thresholds. Setters chain, so several knobs fit in one closure. ->configure(fn (DeepNestedDetector $d) => $d->maxDepth(10)->maxRemaining(2)) ->configure(fn (DataClumpDetector $d) => $d->minClasses(3)); };
The scaffold also carries two menus, $disabledSkills and $disabledSins: every
shipped skill and sin as a commented-out disable() argument. Remove the // to
turn a rule off. New rules are appended on composer update; your edits are kept.
Each move is named for what it registers: paths, disable, detector,
configure, package (see Developing detectors), hook
and planExecution (see Hooks). configure finds the detector by the
closure's first parameter type. Run commandments config for a summary of what's
in effect.
Freezing a file
Some files must not change even though they carry sins — a frozen graph migration whose body deliberately mirrors its siblings, a snapshot committed for the record, generated code checked into the tree. Freeze such a file:
vendor/bin/commandments freeze database/migrations/V5ToV6.php
vendor/bin/commandments unfreeze database/migrations/V5ToV6.php # lift it
freeze stamps a @code-commandments-frozen comment; you can equally mark a class
by hand with a #[Frozen] attribute or an @frozen docblock tag.
A frozen file is still scanned — the call graph, provenance and type resolution
read it, so findings in other files stay correct — but it is never a target:
it is never flagged by judge, and repent never rewrites it. Freezing is a scope,
compounded into every scope the tools resolve, so a cross-file fix whose edits would
touch a frozen file is dropped whole rather than half-applied.
Freeze only what is genuinely immutable. A finding you disagree with belongs in a
report; a rule you want off belongs in disable — freezing is for files that by
their nature cannot move.
Hooks
install (and every composer update, via sync) wires a set of Claude Code
hooks into .claude/settings.json: the cardinal-rule reminder, the "did you
judge?" nudge, and the plan-execution hooks. They self-heal: a hook change reaches
every project on the next composer update.
Your own hooks are never touched. Every wired command carries a
# @code-commandments-managed stamp, and re-wiring strips only stamped commands.
A hook you wrote by hand is always preserved.
The wired hooks — one dispatcher entry per Claude Code event, each fanning out to these handlers:
| Hook | Events | What it does |
|---|---|---|
Remind |
PostToolUse |
Surfaces the cardinal trace to the source rule once every 25 tool uses. |
JudgeReminder |
Stop, PreToolUse/Bash |
Nudges you to judge what you changed — before a risky Bash command, and on stop. |
PlanReminder |
PostToolUse/ExitPlanMode, Stop |
On plan approval loads the executing-plans skill with your profile; on stop, keeps you going until plan done per the plan mode() (Supervised/Autonomous/BestEffort/Relentless). |
ConstraintReminder |
PostToolUse |
Re-surfaces the active plan's constraints once every 25 tool uses. |
TestingReminder |
PostToolUse |
Re-surfaces the active plan's testing methodology once every 25 tool uses. |
SessionReset |
SessionStart |
On a fresh session (startup/clear) wipes lingering plan state, so a crashed run never nudges a new session. |
SourceReminder |
PreToolUse/Edit, PreToolUse/Write, PreToolUse/MultiEdit |
When you edit a test/stub/fixture (which judge never scans), nudges you to check the real fix belongs at the SOURCE. |
WorkingState |
PostToolUse, PreCompact, SessionStart |
Keeps the plan's working-state record alive across compaction — a refresh heartbeat, a PreCompact flush, and re-injection on compact/resume. |
Plan execution
Optional. When you approve a plan, a PostToolUse/ExitPlanMode hook loads the
executing-plans skill with your project's profile. The agent branches, works
phase by phase (checks phase, commit each), and runs the full gate
(checks complete, which appends judge --branch) once at the end. Opt into
keepGoing() and a Stop hook re-nudges until commandments plan done.
The profile lives in .commandments/config.php, next to everything else. A
starter block is injected on composer update, its onComplete inferred from
your composer/npm scripts. Edit or remove it freely:
use JesseGall\CodeCommandments\PlanExecution; $config->planExecution(fn (PlanExecution $plan) => $plan ->branchFrom('main') // base to cut from + judge --branch base ->branchPrefix('plan/') // the plan branch prefix ->pushEachPhase() // push after every phase (default: once at the end) ->keepGoing() // Stop hook re-nudges until `plan done` ->onStart('composer install') // once, before the first phase ->eachPhase('composer lint') // after each phase (keep it fast) ->onComplete('composer test') // the end gate; judge --branch runs after ->constraint('Every new query is scoped to the current tenant.') ->constraint('Public API responses stay backwards-compatible — no field removed or renamed.') ->enforceConstraintsEachPhase() // check constraints every phase, not just at the end ->testFlow('Write and run the tests for each phase before committing it.') // default test methodology, offered at approval ->trackWorkingState()); // keep a living working-state record that survives context compaction
Every option (from the PlanExecution builder):
| Option | What it does |
|---|---|
->branchFrom(…) |
The branch a plan is cut from and judged against — the base for the new plan branch and the judge --branch=<base> the end gate runs. |
->branchPrefix(…) |
The prefix for the branch a plan auto-creates (plan/ → plan/<slug>). |
->pushEachPhase(…) |
Push after every phase commit, rather than once at the end. |
->mode(…) |
The plan-execution MODE — how autonomously the agent runs an approved plan (confirm-first, supervised, grind-to-finish, or never-stop). |
->keepGoing(…) |
Legacy alias for mode: turn on the keep-going Stop hook. |
->onStart(…) |
Commands to run ONCE before the first phase — environment setup the whole plan needs (composer install, npm ci, a git fetch). |
->eachPhase(…) |
Commands to run after EACH phase's commit — the fast, cheap signal (a linter, a type check) that keeps a phase honest without the full suite. |
->onComplete(…) |
Commands to run ONCE at the very end, after the last phase — the exhaustive gate: the full test suite, a lint, a static analysis. |
->constraint(…) |
A CONSTRAINT the agent must respect for every plan run — a natural-language architectural invariant judge can't decide (e.g. "the frontend is presentation-only"). |
->enforceConstraintsEachPhase(…) |
Force the constraint diff-check after EVERY phase, not just at completion. |
->testFlow(…) |
The project's DEFAULT testing methodology for a plan run — how tests are written and run as the agent grinds a plan (e.g. "write and run the tests for each phase before committing it"). |
->trackWorkingState(…) |
Keep a living WORKING-STATE record while a plan runs — an opt-in discipline where the agent writes its progress and, above all, the conversational deltas (decisions and their rejected alternatives, plan changes agreed in chat, hard-won gotchas, the exact next step) to .commandments/.plan-working-state, refreshed after each phase and each important event. |
Working state (trackWorkingState()) is opt-in: the agent maintains a living record at
.commandments/.plan-working-state — the decisions, conversational plan changes, gotchas, and next step
that live only in the conversation — refreshed after each phase and each important event. A PreCompact
hook flushes it before context compaction and it is re-injected on compact/resume, so a compacted agent
resumes with the full picture. It captures only what git log + the plan can't reconstruct.
Constraints are natural-language architectural invariants a detector can't decide
from the AST — "every new query is scoped to the current tenant", "public API responses
stay backwards-compatible", "new endpoints run behind the existing authorization checks".
Where a detector reads a sin off the code's shape, a constraint captures intent only a
reading of the change can confirm: the agent verifies each by reviewing its own branch
diff, and the completion gate blocks commandments plan done until they hold. By default
a phase gets a soft reminder and completion is the hard gate;
enforceConstraintsEachPhase() makes every phase a hard check too. Declare project-wide
ones here; a single run can add its own with commandments constraints add "…".
A plan run in the terminal:
The completion gate verifying the declared constraints against the branch diff before
plan done is allowed:
Register your own hook
Hooks are an open set, like detectors. Write a Hook, declare where it binds, and
register it. It's wired and run exactly like a built-in, with the same stamp:
use JesseGall\CodeCommandments\Cli\Hook; use JesseGall\CodeCommandments\Cli\HookBinding; use JesseGall\CodeCommandments\Cli\HookEvent; final class AnnounceOnStop extends Hook { public function bindings(): array { return [new HookBinding('Stop')]; } protected function onStop(HookEvent $event): int { // …your logic; use $this->block()/$this->inject()/$this->pass() return $this->pass(); } }
// in .commandments/config.php $config->hook(\App\Hooks\AnnounceOnStop::class);
How detectors are tested
Every detector is proven against a self-checking fixture: a small, deliberately imperfect example app that is never run, only scanned.
You mark the exact spots where a detector should fire by naming the sin (naming the detector class works too):
- in PHP, a
#[Sinful(...)]attribute; - in Vue, a
<!-- @sin ... -->comment.
// tests/Fixtures/backend/app/Orders/RefundService.php use JesseGall\CodeCommandments\Sins\Backend\SwallowCatch; use JesseGall\CodeCommandments\Testing\Sinful; final class RefundService { // the marker IS the assertion: the SwallowCatch detector must flag this method. // if it doesn't fire here, the fixture test fails. #[Sinful(SwallowCatch::class)] public function refund(Order $order): void { try { $this->gateway->refund($order->id); } catch (\Throwable) { // swallowed into silence: the sin } } }
<!-- tests/Fixtures/frontend/components/UserBadge.vue --> <template> <!-- the marker IS the assertion: the next element must be flagged --> <!-- @sin ControlFlowOnElement --> <div v-if="user">{{ user.name }}</div> <!-- the good-code example; if this gets flagged, the test fails --> <!-- @righteous ControlFlowOnElement --> <template v-if="user"> <div>{{ user.name }}</div> </template> </template>
Those markers are the test spec. The harness runs every detector over the whole fixture and fails if either:
- a marked spot is missed (the detector has a hole), or
- an unmarked spot is flagged (a false positive).
Each detector must also fire on ≥3 genuinely different examples.
Any unmarked code is already "righteous", so the whole rest of the fixture guards
against false positives; you don't mark good code. One #[Righteous] /
<!-- @righteous --> per detector is required: it sources the good-code example
for the generated skill docs.
Testing your own detectors
The same harness proves the detectors you write. A custom detector declares where
its marked fixture files live by implementing HasFixture:
use JesseGall\CodeCommandments\Backend\Detector; use JesseGall\CodeCommandments\Testing\HasFixture; final class NoRawSqlDetector implements Detector, HasFixture { // the directory of .php files carrying #[Sinful(NoRawSql::class)] / #[Righteous] markers public function fixturePath(): string { return __DIR__ . '/fixtures'; } // sin() + find() as usual … }
Then a one-class test hands your detectors to a DeclaredFixture and extends the
shipped FixtureTestCase. You get the exact checks the package runs on itself:
use JesseGall\CodeCommandments\Testing\DeclaredFixture; use JesseGall\CodeCommandments\Testing\Fixture; use JesseGall\CodeCommandments\Testing\FixtureTestCase; final class MyDetectorsTest extends FixtureTestCase { protected function fixture(): Fixture { return new DeclaredFixture([ new NoRawSqlDetector(), // a Frontend\Detector: .vue fixtures with <!-- @sin --> markers new NoDatePickerDetector(), ]); } }
Frontend detectors work identically: point fixturePath() at a directory of
.vue files with <!-- @sin --> markers. DeclaredFixture routes each detector
to its engine.
Skills
The teaching layer: one discipline each, the doc an agent reads to fix a sin.
Every SKILL.md is generated from its class (composer sins).
21 skills. Full table in README.skills.md.
Sins & detectors
Every sin (the --sin= key) and what it flags. Each sin has one detector that
finds it, named <Sin>Detector (e.g. SwallowCatch → SwallowCatchDetector).
92 sins across 21 skills. Full tables in README.sins.md.
Auto-fixing
Most fixes are domain-specific: the skill teaches, your agent applies the fix at the source. But some sins have a single mechanical fix, and for those the tool ships a scribe.
A scribe is a small deterministic rewriter. It edits the parsed syntax tree, not
the text, so the change is exact and formatting-safe. There are two kinds,
whole-tree maintenance passes and per-sin fixes, both listed in
README.scribes.md. The repent command runs them all until
nothing changes.
For example, a backend LoopInvertedGuard (a whole loop body wrapped in an if)
is rewritten to a continue guard:
// before // after (repent) foreach ($rows as $row) { foreach ($rows as $row) { if ($row->valid()) { if (! $row->valid()) { $this->import($row); continue; } } } $this->import($row); }
…and a frontend SwitchCase (a v-if/v-else-if chain re-testing one value) is
hoisted into a <SwitchCase>, one slot per case:
<!-- before --> <span v-if="status === 'paid'" class="badge badge-green">Paid</span> <span v-else-if="status === 'pending'" class="badge badge-amber">Pending</span> <span v-else class="badge">Unknown</span> <!-- after (repent) --> <SwitchCase :value="status"> <template #paid> <span class="badge badge-green">Paid</span> </template> <template #pending> <span class="badge badge-amber">Pending</span> </template> <template #default> <span class="badge">Unknown</span> </template> </SwitchCase>
<SwitchCase> is a utility component the package provides; repent scaffolds it
automatically when a fix introduces it, so the rewritten tree compiles (see
Scaffolding).
Running repent
# preview every auto-fix as a unified diff; nothing is written vendor/bin/commandments repent src --dry-run # apply them vendor/bin/commandments repent src vendor/bin/commandments repent resources/js
repent auto-fixes 24 sins, plus 2 whole-tree maintenance passes. Full tables in README.scribes.md.
repent keeps applying scribes until nothing changes, so one run fully converges.
It takes the same scope flags as judge:
vendor/bin/commandments repent src --changes # only working-tree changes vendor/bin/commandments repent src --branch # only branch changes vs main vendor/bin/commandments repent src --branch=develop # ...vs a different base
The whole tree is still parsed (cross-file rewrites stay correct); only the files that get written are scoped.
Prop types when extracting components
When an extract scribe lifts a chunk into its own component, it types every prop
it generates. Types are resolved by sound AST inference first: a ref/computed
literal, a homogeneous literal array ([50, 100, 200] → number[]), a
destructured composable's return field, a loop variable's element type, a prop
traced up the render tree. Nothing is guessed; what only a real type checker could
resolve stays unknown.
If the project already ships vue-tsc, a last rung asks it to resolve what the
AST couldn't. It is never a dependency of the package. The checker runs
--incremental with --skipLibCheck, batched to one run per component.
Scaffolding
Some fixes need a reusable construct to point at: a no-op invokable to default an
optional callback to, a <SwitchCase> component to hoist a v-if chain into. The
package ships these as stubs and generates them into your project:
# generate every helper the applicable sins need (idempotent; existing files are skipped) vendor/bin/commandments scaffold # ...or just one sin's construct vendor/bin/commandments scaffold --sin=switch-case vendor/bin/commandments scaffold --sin=nullable-callback --dry-run
A scaffold lands in the right root for its kind (a PHP helper under your PSR-4
source root with your namespace injected, a Vue component under resources/js)
and is never overwritten, so it's safe to re-run and safe to edit.
repent runs scaffold for you: when a fix introduces a construct, it's minted
in the same run. Run scaffold yourself only when you want the construct before
repenting.
Developing detectors
A rule of your own is three small classes: a skill that teaches the fix, a
detector that finds the sin, and optionally your own AST vocabulary.
Before you start, load the project skills via Claude Code's Skill tool:
writing-detectors, detector-engine, detector-fixtures. The rule
throughout: classify by what the AST/type is, never by a name or a hardcoded
list.
A skill
The teaching half. Each skill is its own class under Skills/{Backend,Frontend}/
(auto-discovered); composer sins renders it to a SKILL.md, with the bad→good
block pulled from the fixture:
namespace App\Commandments; use JesseGall\CodeCommandments\Skills\Skill; use JesseGall\CodeCommandments\Skills\Tier; final class VehicleAssembly extends Skill { public function __construct() { parent::__construct(slug: 'vehicle-assembly', tier: Tier::Mandatory, order: 1); } public function title(): string { return 'Vehicle assembly — wire the wheels'; } public function trigger(): string { return 'WHEN to build a vehicle clause: always through Vehicle::assemble(), which attaches its wheels and defaults.'; } public function intro(): string { return 'A clause is only whole once it has wheels — building one raw skips the assembler that attaches them.'; } public function summary(): string { return 'assemble clauses via Vehicle::assemble(); never `new` them raw.'; } public function principle(): string { return 'The assembler is the single place a clause becomes road-worthy: it wires the wheels, the defaults, the invariants. A raw `new` ships a clause that looks built but rolls on nothing.'; } }
A detector
A detector is a few lines of fluent AST query: a selector opens it, where/reject
narrow it (one check per line), a terminal returns the matches. It references a
sin (its own class under Sins/, which names the skill). FacadeCallDetector
flags a Laravel facade call, then peels off every legitimate exception:
namespace App\Commandments; use JesseGall\CodeCommandments\Ast\AstNode; use JesseGall\CodeCommandments\Ast\Codebase; // a PHP detector; a Vue one implements JesseGall\CodeCommandments\Frontend\Detector use JesseGall\CodeCommandments\Backend\Detector; use JesseGall\CodeCommandments\Sins\Sin; final class FacadeCallDetector implements Detector { // the sin it points at (names the skill + description) public function sin(): Sin { return new FacadeCall(); } public function find(Codebase $codebase): array { return $codebase // every `X::y(...)` ->whereStaticCall() // ...that's a facade ->where(fn (AstNode $n) => $n->staticCallClassStartsWith('Illuminate\\Support\\Facades\\')) // not `Mail::fake()`, a test double ->reject(fn (AstNode $n) => $n->staticCallMethodIs('fake')) // not in a route/config file ->reject(fn (AstNode $n) => $n->isOutsideClass()) // not in a provider ->reject(fn (AstNode $n) => $codebase->extends($n->enclosingClassName(), 'Illuminate\\Support\\ServiceProvider')) ->get(); } }
No list of facade names; it matches the framework's facade namespace, resolved from the file's imports.
A rule that only makes sense with a particular package declares that on its
sin, via RequiresPackage. Without the package the rule is filtered out
entirely (never runs, never shows in --list):
namespace App\Commandments; use JesseGall\CodeCommandments\Ast\AstNode; use JesseGall\CodeCommandments\Ast\Codebase; use JesseGall\CodeCommandments\Backend\Detector; use JesseGall\CodeCommandments\Sins\RequiresPackage; use JesseGall\CodeCommandments\Sins\Sin; // RequiresPackage lives on the SIN, not the detector final class RawCarbonParse extends Sin implements RequiresPackage { public function __construct() { parent::__construct( name: 'raw-carbon-parse', skill: DateHandling::class, description: 'Carbon::parse() on a raw string — build the date through a typed factory instead', rule: 'Build dates with CarbonImmutable::createFromFormat(); never Carbon::parse() untrusted input.', ); } // a Composer name for a backend sin, an npm name for a frontend one public function requiredPackage(): string { return 'nesbot/carbon'; } } final class RawCarbonParseDetector implements Detector { public function sin(): Sin { return new RawCarbonParse(); } public function find(Codebase $codebase): array { return $codebase ->whereStaticCall() ->where(fn (AstNode $n) => $n->staticCallClassStartsWith('Carbon\\')) ->where(fn (AstNode $n) => $n->staticCallMethodIs('parse')) ->get(); } }
Your own AST vocabulary
Want $n->isBareVehicleClause() to read like a built-in predicate? Subclass
NodeMatch, add the domain predicate, and type-hint it in the where closure.
Define as many decorator nodes as you like; each detector gets whichever it
type-hints (the built-in LaravelNode, SpatieDataNode, … work the same way).
The node also carries the Codebase, so a predicate can answer whole-program
questions:
namespace App\Commandments; use JesseGall\CodeCommandments\Ast\Codebase; use JesseGall\CodeCommandments\Ast\NodeMatch; use JesseGall\CodeCommandments\Backend\Detector; use JesseGall\CodeCommandments\Sins\Sin; // the node: a domain predicate composed from the engine's helpers final class VehicleNode extends NodeMatch { public function isBareVehicleClause(): bool { // a `new App\Vehicles\…Clause(...)`, built raw, so it never declares its wheels $class = $this->newClassName() ?? ''; return str_starts_with($class, 'App\\Vehicles\\') && str_ends_with($class, 'Clause'); } } // the sin: points at the VehicleAssembly skill above final class BareVehicleClause extends Sin { public function __construct() { parent::__construct( name: 'bare-vehicle-clause', skill: VehicleAssembly::class, description: 'A vehicle clause built with `new` — it never declares its wheels', rule: 'Assemble a clause with `Vehicle::assemble()` so its wheels are wired; never `new` it raw.', ); } } // the detector: composes the decorated node's predicate final class BareVehicleClauseDetector implements Detector { public function sin(): Sin { return new BareVehicleClause(); } public function find(Codebase $codebase): array { return $codebase ->whereNew() // reads like a built-in; the VehicleNode type-hint is all it takes, // the engine injects the custom node via reflection ->where(fn (VehicleNode $n) => $n->isBareVehicleClause()) ->get(); } }
Teaching the engine about a package
A general rule sometimes needs a framework fact (this class is a request handler,
an entry point) so it doesn't false-positive on it, but it may not name the
framework. A Package declares those facts as exemptions, keyed by a
tag: the rule reads the tag, the package registers against it, and neither
imports the other.
The built-in tags (Packages\Tags\*), each with a slug you can pass to
exempt(...) instead of the FQCN. Run commandments exemptions to print this
list:
| Tag (slug) | What it means | Read by (and what it exempts) |
|---|---|---|
Boundary (boundary) |
A framework entry point: an HTTP/RPC request, where raw input crosses into your domain. | feature-envy (don't move behaviour onto a request) · pass-the-object (a method taking one may unpack input from it). |
ContractMethod (contract-method) |
A method a subclass must declare, whose shape the framework dictates (rules, schema, casts). |
near-duplicate (the shared skeleton is inherent) · array-return-bag (the mandated array isn't a bag). |
ArrayReturning (array-returning) |
A class whose whole job is handing the framework arrays (a FormRequest, an MCP tool). |
array-return-bag (its array returns are contractual). |
NoContainer (no-container) |
A type the framework instantiates itself, no DI (an Eloquent cast, a Spatie DataPipe/Cast). |
array-bag (a loose array parameter is the framework's calling convention) · container-reach (with no DI, per-call app() is its only way to obtain collaborators). |
CompositionRoot (composition-root) |
A service provider's register()/boot() — the composition root, where config() is wired into the typed objects it binds. |
config-read (a provider can't inject its own config; reading it here is the wiring's job). |
Register in register(): classes() for whole classes, on(class, ...methods)
for specific methods, methods() for a name ignored everywhere:
namespace App\Commandments; use JesseGall\CodeCommandments\Packages\Exemptions; use JesseGall\CodeCommandments\Packages\Package; use JesseGall\CodeCommandments\Packages\Tags\Boundary; use JesseGall\CodeCommandments\Packages\Tags\ContractMethod; final class AcmePackage extends Package { public function register(Exemptions $exemptions): void { // any method taking one of these is a boundary; feature-envy et al. leave it alone $exemptions->exempt(Boundary::class) ->classes(\Acme\Rpc\Endpoint::class, \Acme\Rpc\Handler::class); // an Acme handler's schema() is an array by contract $exemptions->exempt(ContractMethod::class) ->on(\Acme\Rpc\Handler::class, 'schema'); } }
exempt('boundary') equals exempt(Boundary::class). The built-in
LaravelPackage works exactly this way and auto-enrols; your own Package lives
in your codebase, so register it in .commandments/config.php:
// .commandments/config.php return fn (Config $config) => $config ->package(\App\Commandments\AcmePackage::class);
Your own exemption tags
A tag is always an Exemption, so a custom one is its own subclass with a
slug() (what packages register against) and a description() (what
commandments exemptions prints):
use JesseGall\CodeCommandments\Packages\Exemption; final class AcmeEntrypoint extends Exemption { public function slug(): string { return 'acme-entrypoint'; } public function description(): string { return 'An Acme RPC endpoint — exempt from feature-envy.'; } }
Your detector reads it, any package registers against it, and neither imports the
other. Declare each tag and WHAT to match it against (an ExemptBy scope); the
engine applies the reject centrally, so find() just passes its results through
$this->exempt(...) — no hand-written Exemptions::has(...):
use JesseGall\CodeCommandments\Packages\{AppliesExemptions, ExemptBy, Exemptable}; final class AcmeFeatureEnvyDetector implements Detector, Exemptable { use AppliesExemptions; public function exemptions(): array { return [AcmeEntrypoint::class => [ExemptBy::EnclosingClass]]; // tag => where to match it } public function find(Codebase $codebase): array { return $this->exempt($codebase->whereMethodDeclaration()->where(/* … */)->get(), $codebase); } }
ExemptBy::EnclosingClass matches the finding's class, ExemptBy::EnclosingMethod
its class + method. A tag mapped to [] is one the detector enforces itself (a
bespoke subject) — declared only so commandments exemptions still lists it.
License
MIT.