psalm
AGENTS.md5.9k starsChanged yesterdayMITView on GitHub
master is the 7.x development branch and the base of feature PRs; 6.x is maintained separately and periodically merged into master (skill merge-6x-into-master). Remotes: origin = vimeo/psalm. PR branches go to your own fork's remote (find it with git remote -v and gh api user --jq .login; add it if missing). Never push PR branches to origin. Domain skills (load them before touching the area): - taint-analysis: taint graph and resolver, sinks, taint repros and TaintTest pitfalls - purity-capabilities: capability…
- Commits and pushes
We split this AGENTS.md into the rules it gives an agent
30 rules. About 0 are common, 12 are distinctive, and 18 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈12
Distinctive · estimate
This author's own choices. Worth a look.
18
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 32
Never run the PHPUnit suite locally to validate a branch.
- Very specific: line 53
When briefing a subagent, spell all of this out ("single test cases only, no test files or suites, no sweeps, timeout 300 on …
- Very specific: line 60
Add a release:* label (release:fix, release:feature, release:internal, release:docs, …) or the label check fails.
- Distinctive: line 64
Prefer a PR even when asked to fix something on master.
- Very specific: line 66
Description: follow skill pr-description (one-line bug + Before/After code blocks from a test case of the PR that asserts the fix, nothing …
- Very specific: line 69
"Check backward compatibility" fails: follow skill plugin-api-bc.
- Very specific: line 83
Never use --admin to bypass red required checks unless asked for that specific PR.
- Very specific: line 90
Follow the file's bugbot gating, then merge them all into one temporary branch, check it contains each PR's current head, and fast-forward …
- Distinctive: line 92
yourself in the main session: Don't relay the push authorization to a subagent.
- Distinctive: line 95
If the auto-mode classifier denies the master push ("Merge Without Review"), stop and ask for approval rather than retrying.
- Distinctive: line 100
Do fixes, reviews and PR work in the main session.
- Distinctive: line 104
Never use the Workflow tool unless explicitly asked for a workflow.
- Distinctive: line 111
Never hide an issue instead of fixing it:
- Very specific: line 114
no skipping stub docblock issues, no COMPOSER_NO_SECURITY_BLOCKING-style env bypasses.
- Very specific: line 115
no regenerating a baseline to paper over a Psalm bug.
- Very specific: line 116
no changing fixtures to dodge behaviour, and no reading Psalm internals via reflection because an API is missing.
- Distinctive: line 156
Tests must assert the change: Every test case a PR adds or changes for the bug or feature itself must fail on the base branch and pass on …
- Very specific: line 178
Test cases analyse PHP 7.4 by default, and native symbols are version-gated, so tests using newer classes/methods need php_version.
- Very specific: line 180
Put each docblock tag on its own line.
- Very specific: line 182
Never start a docblock line in src/ with an example @psalm-...
- Very specific: line 185
When a change makes one location report several kinds, check every case and docs example that reaches it, and assert multi-issue results in …
- Distinctive: line 201
don't restate the code in prose.
- Distinctive: line 202
don't mention approaches that were tried, considered, replaced or removed, or anything that only makes sense in the context of an agent's …
- Distinctive: line 204
prefer deleting an unnecessary comment over expanding or rewording it.
- Distinctive: line 205
document an idea once, where it naturally belongs.
- Very specific: line 227
Write commands that work in both zsh (macOS default) and bash (usual on Linux).
- Very specific: line 244
All worktrees of this repository (sibling ../psalm-* checkouts, /tmp/psalm-*, …) share one stash list.
- Very specific: line 246
Never use git stash there, and never pop or drop entries you didn't create.
- Very specific: line 249
Put scratch worktrees and probe dirs under /tmp (on macOS it is /private/tmp.
- Distinctive: line 273
Every agent on one machine must use the same lock command, so pick it by OS, not by whatever happens to be installed.
≈ “Common” is an estimate: at least 1 in 5 of the 36,368 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Psalm — working conventions
master is the 7.x development branch and the base of feature PRs; 6.x is maintained
separately and periodically merged into master (skill merge-6x-into-master).
Remotes: origin = vimeo/psalm. PR branches go to your own fork's remote (find it with
git remote -v and gh api user --jq .login; add it if missing). Never push PR branches
to origin.
Domain skills (load them before touching the area):
taint-analysis: taint graph and resolver, sinks, taint repros and TaintTest pitfallspurity-capabilities: capability bitmask, purity templates, builtin/SPL purity stubs, memoizationhack-conformance: HHVM oracle harness, Hack coeffect facts, type-variable rulespsalm-repro: scratch-project repros, comparing against master/old releases, timing and memorymerge-6x-into-master: the 6.x → master merge procedurepr-description: the PR description format, and reformatting existing descriptionsplugin-api-bc: a failing BC check, the roave XML, and the psalm-plugin-api PR
Fixing bugs and writing features
These come first, before every other rule here:
- Fix the root cause. Find why the bug happens and fix that, not the symptom or the one input that showed it. If the same mistake exists elsewhere (sibling classes, other call sites), fix it there too.
- Think through every way the change could cause bugs, in edge cases and ordinary ones: other callers of the changed code, subclasses and overrides, both analysis modes, other PHP versions, caching, ordering. Check each one by adding a test.
- Add an explicit test case for each of those cases, not only for the reported bug.
Validating changes: CI, not local test runs
- Never run the PHPUnit suite locally to validate a branch. That includes whole test
files, directories or broad
--filterpatterns, re-running batches of existing provider cases through the CLI, and sweeps over provider snippets. Commit, push, and let the PR's CI run it. Local runs are slow (~7 min) and noisy: ~73InternalCallMapHandlerTestcases andReportOutputTest::testSarifReportfail locally for environment reasons. Running a single test case is fine, and recommended for reproducing an issue or showing that a new test fails on the base and passes on the branch. Run just that one case, never the whole file:
- provider case:
vendor/bin/phpunit tests/CapabilitiesTest.php --filter 'testValidCode@caseName'; - TaintTest provider case (sharded over
tests/Taint/TaintCases{1..8}Test.php):vendor/bin/phpunit tests/Taint --filter '@caseName'. This runs the case in both modes (plain andTrackingUnusedVariables); - test method:
vendor/bin/phpunit tests/TaintTest.php --filter 'testMethodName$'.
@caseNamematches the data-set name exactly; don't append$(it then matches nothing). "No tests executed!" means a wrong filter, or a stalevendor/(PHPUnit must be ≥ 10.5: checkvendor/bin/phpunit --version, and runcomposer installif it's older).- provider case:
- Also fine locally: targeted
./psalm --no-cacherepros on a scratch project (skillpsalm-repro), single-file self-analysis, and one full self-analysis. - When briefing a subagent, spell all of this out ("single test cases only, no test files
or suites, no sweeps,
timeout 300on self-analysis"); a vague "don't run phpunit" was not enough.
Pull requests
- Push the branch to your fork, then
gh pr create --repo vimeo/psalm --base master. - Add a
release:*label (release:fix,release:feature,release:internal,release:docs, …) or thelabelcheck fails. - Then poll
gh pr checks <n>. Forgh run list,--commit <sha>is reliable;--branchsometimes returns stale runs. - Prefer a PR even when asked to fix something on master. A direct push to master was once reverted and redone as a PR.
- Description: follow skill
pr-description(one-line bug + Before/After code blocks from a test case of the PR that asserts the fix, nothing else). Use it to write new descriptions and to reformat existing ones when asked. - "Check backward compatibility" fails: follow skill
plugin-api-bc. Real BC breaks get a PR to psalm/psalm-plugin-api that bumps its version and adds the changelog entry. False positives (e.g. bit-constant values and aggregates that change because a member was added, which is not a BC issue) are check bugs: fix them in.roave-backward-compatibility-check.xmlin the same PR. Never leave the check red as "expected".
Merging and releasing (maintainers with merge rights)
- In any repo of github.com/psalm (plugins, codeception-psalm-module, psalm-plugin-api, …), review the diff yourself, wait for green CI, merge, and tag a release of every plugin you changed (fixture/CI/dependency-only changes too), following each repo's versioning. This includes PRs whose maintainer of record is someone else. It does not extend to third-party orgs (php-stubs, …).
- Never use
--adminto bypass red required checks unless asked for that specific PR. - Shipping a plugin whose users see InvalidDocblock errors from broken upstream stubs is fine: users report them upstream. Don't hold releases for upstream stub fixes.
Task lists (actions.md, actions_2.md: untracked, in the repo root)
- PRs in the Merge section have already been reviewed by whoever wrote the list.
Follow the file's bugbot gating, then merge them all into one temporary branch, check
it contains each PR's current head, and fast-forward push it to
origin/masteryourself in the main session. Don't relay the push authorization to a subagent. - Modify section PRs are not authorized for merge: make the changes in the current chat and open the PR in Brave (see the platform table).
- If the auto-mode classifier denies the master push ("Merge Without Review"), stop and ask for approval rather than retrying.
Agents and subagents
- Do fixes, reviews and PR work in the main session. Spawn a subagent only when asked to or for a genuinely independent, bounded task, and say so first.
- To review several PRs, use one subagent with an ordered target list (most urgent first, saying which to drop if time runs short), not one subagent per PR.
- Never use the Workflow tool unless explicitly asked for a workflow.
- Parallel agents running Psalm contend for RAM (one self-analysis can take most of an 18 GB machine): follow the self-analysis lock rule below, and tell subagents the testing rules above explicitly.
No workarounds
Never hide an issue instead of fixing it:
- no
@psalm-suppress,issueHandlersor baseline entries added to dodge a problem; - no
ignoreFilesto dodgeDuplicateClass, and noBeforeAddIssuefiltering plugins; - no skipping stub docblock issues, no
COMPOSER_NO_SECURITY_BLOCKING-style env bypasses; - no regenerating a baseline to paper over a Psalm bug;
- no changing fixtures to dodge behaviour, and no reading Psalm internals via reflection because an API is missing.
Fix the root cause in the code, the stubs (send stub bugs upstream) or Psalm itself, or add the missing plugin API. If a proper fix is out of reach, say so and ask. List any workaround explicitly in the report.
Self-analysis (./psalm on this repo)
psalm.xml.disthasrunTaintAnalysis="true", so self-analysis includes taint resolution. Removing the attribute doesn't turn it off; set it to"false". Shepherd (./psalm --shepherd) is the only CI job that runs it.- Full run:
timeout 300 ./psalm --no-cache --output-format=compact. Don't pass--threads: Psalm detects the CPU count itself. Master takes roughly 30–180 s. A run past ~5 min is a hang, not slowness: in Shepherd's log a "runner has received a shutdown signal" during "Resolving taint graph" means a resolver blowup, not issues. - One self-analysis can use many GB of RAM (up to ~16 GB has been seen), so never run two
at once. Parallel agents serialize on the lock file
/tmp/psalm-self-analysis.lockwith the platform's lock command (see the table):flock /tmp/psalm-self-analysis.lock timeout 300 ./psalm ...on Linux,lockf -k /tmp/psalm-self-analysis.lock timeout 300 ./psalm ...on macOS. - A file-subset run (
./psalm <files>) misses class-level issues (MissingImmutableAnnotation) and reports spuriousUnusedPsalmSuppress/UnusedBaselineEntry. Before removing a suppression or claiming self-analysis is clean, do a full run and diff it against a full run onorigin/master. ComplexMethod/ComplexFunctionare computed from the variable-use graph's edge stats (StatementsAnalyzer,getEdgeStats(): size > 200, mean path > 70, convergence > 1.1). A data-flow change can flip them on methods the PR never touched, making an existing@psalm-suppress ComplexMethodunused. Print the method'sgetEdgeStats()under base and branch to confirm no flow was lost before touching the suppression.- Master enforces purity annotations on its own code (
MissingPureAnnotation, enabled bysuggestPurityAnnotationsinpsalm.xml.dist; off by default for users)../psalm --alter --issues=MissingPureAnnotation <files>adds them. --set-baselinestill printing new issues and exiting 2 is deliberate (#5838, a "fix" was reverted in #6051); the written baseline is complete.--update-baselineonly shrinks entries.
Tests
- Tests must assert the change. Every test case a PR adds or changes for the bug or
feature itself must fail on the base branch and pass on the PR, because it checks the
exact behaviour being fixed or added. Prove it by running that single test case on
both (see above). Edge-case tests that guard the change may pass on the base. Ways a
test silently asserts nothing:
- a valid-code case for a missed-issue fix (it passes on master too): use an invalid case asserting the issue;
- an inferred-type fix without
'assertions' => ['$x' => 'expected type'](key'$x==='compares the exact type id); error_messageonly matches the first issue raised, as a substring ('InvalidReturnType'matches any line). If master already reports that issue type somewhere in the snippet, pin it with the location ('Issue - src' . DIRECTORY_SEPARATOR . 'somefile.php:13:31') or message text;ignored_issues/error_levelssuppressing the issue under test, or a higherphp_version/missing extension making the code path not run;- harness differences: e.g. TaintTest provider cases don't initialize plugins (skill
taint-analysis).
- Data-provider entries are passed by position; string keys are ignored. To pin a PHP
version write
'assertions' => [], 'ignored_issues' => [], 'php_version' => '8.1'(valid) or'error_levels' => [], 'php_version' => ...(invalid).'php_version'alone becomes$assertionsand TypeErrors. TaintTest is the exception:testInvalidCode(string $code, string $error_message, string $php_version = '8.0'). - Test cases analyse PHP 7.4 by default, and native symbols are version-gated, so tests
using newer classes/methods need
php_version. - Put each docblock tag on its own line.
/** @psalm-pure @param ... */silently drops every tag after the first. - Never start a docblock line in
src/with an example@psalm-...tag; Psalm parses it as a real annotation. DocumentationTestand invalid-code provider cases only see the first issue raised. When a change makes one location report several kinds, check every case and docs example that reaches it, and assert multi-issue results in a dedicated test method (throw_exception = false+IssueBuffer::getIssuesDataForFile()).IssueBuffer::finish()callsexit(2)when errors remain; test reported errors end to end (tests/EndToEnd/PsalmEndToEndTest.php).- Resolving merge conflicts in big provider files (TaintTest especially): git factors
shared trailing lines (
'error_message' => ...,,],) out of the hunk, so "keep both" can drop an entry'serror_messageor duplicate a],, andphp -lstill passes. Afterwards, check with a script that every 12-space-indented'name' => [ ... ]entry of both parents appears verbatim in the result, unless the other side changed it on purpose (compare with the merge base).
Code style
- Match the surrounding code: comment density, naming, idiom.
- Comments are concise and only explain what isn't obvious from the code:
- don't restate the code in prose;
- don't mention approaches that were tried, considered, replaced or removed, or anything that only makes sense in the context of an agent's iteration history;
- prefer deleting an unnecessary comment over expanding or rewording it;
- document an idea once, where it naturally belongs.
- Documentation (
docs/, issue pages, user-facing docblocks): more examples, less text. Show behaviour with a code example whose lines end in their result (// TaintedNosql,// safe,// no issue), cases that stay reported included, and keep the prose around it to a sentence. Check each line's result on the branch before writing it. - Hot loops (
TaintFlowGraph::connectSinksAndSources(),TaintFlowResolution): keep the branch structure inline and callgetChildNodes()directly in each branch. Don't collect nodes into intermediate arrays (allocation per iteration), and avoid new single-use helpers (they make diffs harder to read). Keep the order of side-effecting calls. Verify refactors by diffing full--monochromeoutput, traces included, on probe projects.
Debugging pitfalls
- STDERR from analysis gets swallowed by
--output-format=compact/grep. Debug withfile_put_contents('/path', ..., FILE_APPEND). - Empty Psalm output means it didn't run (wrong cwd, missing
vendor/, crash), not "clean". Only trust "No errors found" or an errors summary. Detect crashes (exit 11, segfault) when counting issues.
Shell, worktrees and git
- Write commands that work in both zsh (macOS default) and bash (usual on Linux); check
with
echo $0if unsure:- Quote globs meant for the tool, not the shell (
grep --include='*.php'). In zsh an unmatched glob aborts the whole&&chain ("no matches found"); in bash it is passed on literally, so the command quietly acts on a path that doesn't exist. Either way the files you expect may not be there. Clean scratch dirs withfind dir -name '*.php' -delete, andlsrepro files before concluding anything from an absent issue. - Brace variables next to a colon:
"${b}:branch","${r}:tests/TaintTest.php". In zsh$b:l,$r:t, … are history modifiers and mangle refspecs; braces are harmless in bash. - Word-splitting a variable of args: zsh needs
${=args}, bash splits unquoted$args. Prefer an array (args=(a b); cmd "${args[@]}"), which behaves the same in both.
- Quote globs meant for the tool, not the shell (
- The working directory resets between Bash calls. Start every command that edits,
commits or runs Psalm in a worktree with
cd <worktree> &&(or use absolute paths), and checkgit statusin the intended worktree before committing. - All worktrees of this repository (sibling
../psalm-*checkouts,/tmp/psalm-*, …) share one stash list. Never usegit stashthere, and never pop or drop entries you didn't create; the list may hold other people's old stashes. To compare with and without a change, usegit diff > f.patch+git apply -R, or a second worktree. - Put scratch worktrees and probe dirs under
/tmp(on macOS it is/private/tmp; both paths work) or the session scratchpad./tmpmay be wiped on reboot on both platforms, so don't keep anything there that must survive. - A worktree that runs Psalm needs its own
vendor/. Copy it withcp -R; a symlink makes composer's autoloader load the main checkout's classes. If the worktree will run phpunit or the mainvendor/may be stale againstcomposer.json,composer installin it.
Platform differences
The instructions here and in .agents/skills/ target macOS and Linux. Where a command
differs, use the right column for the current OS (uname prints Darwin or Linux):
| Task | macOS | Linux |
| Serialize self-analysis runs | lockf -k /tmp/psalm-self-analysis.lock CMD | flock /tmp/psalm-self-analysis.lock CMD (util-linux) |
| Time limit | timeout from Homebrew coreutils (gtimeout if only that is on PATH) | timeout (coreutils, preinstalled) |
| Peak RSS of a command | /usr/bin/time -l CMD ("maximum resident set size", bytes) | /usr/bin/time -v CMD ("Maximum resident set size (kbytes)"; GNU time package, not the shell builtin) |
| RAM | sysctl -n hw.memsize | free -g |
| On AC power? | pmset -g batt | cat /sys/class/power_supply/*/online (or upower -d) |
| Start Docker | open -a Docker, then wait for docker info | sudo systemctl start docker (user in the docker group), or Docker Desktop |
| Open a URL in Brave | open -a "Brave Browser" URL | brave-browser URL (or xdg-open URL for the default browser) |
| In-place sed | sed -i '' ... | sed -i ... (or use a short PHP/Python script on both) |
| Temp dir | /tmp (= /private/tmp) | /tmp |
Every agent on one machine must use the same lock command, so pick it by OS, not by whatever happens to be installed.
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 28,693 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository8 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- hack-conformanceSkill
- merge-6x-into-masterSkill
- plugin-api-bcSkill
- pr-descriptionSkill
- psalm-reproSkill
- purity-capabilitiesSkill
- taint-analysisSkill
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

