Tooling Recipes
A separate class from process recipes and agentic (task) recipes. A tooling recipe puts one tool on disk for one framework and says how to invoke it. That is its whole scope.
The other two classes name their tools constantly and assume they are already there. A project with no test runner cannot observe a failing test, so test-first is unfalsifiable. A review that wants a static-analysis reading with no analyser present reports the tool as absent, permanently, because nothing installs it. This class closes that gap and nothing else.
A tooling recipe is named for its tool, and whatever needs the tool refers to it by that name. It knows nothing about the project's pages, its layers, or its design.
Why three headings
| Heading | What it holds |
|---|---|
Goal |
What the tool is, and what a project gets by having it |
Install |
The commands that add it, in order |
Run |
The command that invokes it, and where the result appears |
Run is also the check. Whoever needs the tool runs the Run command, and a "not found" is the answer that the tool is missing — install, then run it again. Nothing records whether a tool is present, because a stored answer goes stale the moment someone removes the package.
There is no preconditions section. If an install step needs Composer and Composer is absent, the step fails with Composer's own message, which is more useful than anything written here in advance and one less thing to keep true.
How install steps run
Each step runs as arguments, never through a shell. A step containing a shell metacharacter is refused rather than run to mean something its author did not intend — write two steps instead of joining them with &&.
Every step is safe to run twice, because a project may already have part of what the tool needs.
What a fence says it is
A tooling recipe is read by a machine as well as by a person, so a fence is labelled rather than counted. Under ## Install, every block tagged sh holds commands, one per line, read in order — a block tagged anything else is read and not run, which is what makes a configuration example safe to put beside the commands. Under ## Run, exactly one block is tagged sh and it holds a single command; a worked example of the same tool invoked another way is prose or a text block, because it is not a second thing to run.
The tag names what the block holds, not how it is executed — steps still run as arguments, never through a shell.
Nothing reads inside a block, so nothing guesses. validate_recipes.py rejects an untagged fence under either heading, an ## Install with no sh block, a ## Run with anything other than exactly one, and an sh block under ## Run holding more than one command. Reading by position instead — "the first fence under Install" — is what shipped a composer config without its composer require and called it installed.
What the build produces
On deploy, scripts/generate_tooling_recipes.py emits one routing line per recipe into tooling-recipes.txt, plus tooling-recipes.hash:
- <name> [tool=<tool> framework=<framework>] (sha:XXXXXXXX): <when-to-use> — <site-url>
Two keys, because there are two lookups. Tool name serves a caller that already knows which tool it wants — the name is the whole contract, since a tooling recipe is named for its tool and whatever needs it refers to it by that name. Framework is on the line because the same tool installs differently per stack: PHPUnit on Drupal runs through Composer inside a container and PHPUnit on a PHP CLI project does not, so those are two recipes rather than one. A caller that does not yet know the tool matches search words against the description instead.
Without this listing the pages exist and nothing can find them. A process recipe names the tools its method needs with requires_tooling:, and scripts/validate_recipes.py checks each name resolves to a recipe for that recipe's own framework — so a name that resolves to nothing fails at publish rather than on somebody's machine.
Catalog
| Framework | Tool | Recipe |
|---|---|---|
drupal |
phpcs |
PHP_CodeSniffer |
drupal |
phpstan |
PHPStan |