purity-capabilities
Skill5.9k starsChanged yesterdayMITView on GitHub
Use when working on Psalm's purity model, i.e. capabilities (@psalm-capabilities, @psalm-pure/@psalm-impure), purity templates (@psalm-purity-template, @psalm-purity-from-template, Closure[P], Traversable[pure]<K, V>, the `_` wildcard), purity of builtin/SPL stubs, memoized method results, or MissingPureAnnotation/ImpureMethodCall behaviour. Records the maintainers' design decisions, which must not be relitigated.
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 0 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 118
When making a builtin reader mutation-free, probe drain-then-check patterns: if ($q->isEmpty()) return.
- Very specific: line 132
Test cases: one docblock tag per line.
- Very specific: line 137
No opaque user-defined capabilities (Hack newctx).
- Very specific: line 152
Before adding a purity feature, check Hack's behaviour first (skill hack-conformance).
≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill 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
Purity and capabilities
Model (where things live)
src/Psalm/Storage/Capabilities.php: a bitmaskREAD_PROPS,WRITE_THIS_PROPS,WRITE_PROPS,READ_GLOBALS,WRITE_GLOBALS,WRITE_REFS,IO.ALL= impure.- Closure/callable atomics carry
Union $purity, which is aTCapabilitiesor a purityTTemplateParam. @psalm-purity-template Pdesugars to a template bounded byimpure(covariant on classes). Bounds syntax:lower <= Name(default) <= upper.@psalm-purity-from-template Pis resolved per call byCallPurityResolver(getOwnCapabilities(),getCallCapabilities()) for functions, methods, static calls andnew. Unbound purity templates are defaulted to their bound byArgumentAnalyzerwitharg_offset === null, and the resolver skips those.- Conditional capabilities:
@psalm-capabilities ($p is null ? io : pure).FunctionLikeStorage::$conditional_capabilitiesholds the branches, and$capabilitiestheir union: the body is checked against it, and it applies where there's no call.- Parsed in
FunctionLikeDocblockScannerlike conditional return types.
- Docs:
docs/annotating_code/purity_model.mdand supported_annotations.md ("Purity and capabilities"). Tests:tests/CapabilitiesTest.php,tests/PurityTemplateTest.php. BC list:UPGRADING.md.
Maintainer decisions: settled, don't propose reverting them
- Naming:
mutation-free/external-mutation-free(aliases and the@psalm-mutation-free/@psalm-external-mutation-freetags) are parse-only. Never print them (Capabilities::toString), suggest them, or document them; they're listed inDocumentationTest::INTENTIONALLY_UNDOCUMENTED_ANNOTATIONS.@psalm-pureand@psalm-impurestay first-class in docs and output. Docs use@psalm-capabilities read-props/write-this-propswhere the aliases used to be. - One bit per name: each name in
Capabilities::NAMESis only its own bit (write-propsdoesn't implyread-props,write-globalsdoesn't implyread-globals), andtoStringprints one name per bit. Consequences:- global writes charge
write-globalsalone (static $xcharges both); - a plain
S::$n = ...target isn't charged as a read; - using
$thisneedsread-propsunless the context haswrite-this-props; - a foreign receiver's
write-this-propsbecomeswrite-props(MethodCallPurityAnalyzer::getCapabilitiesForReceiver; also for the receiver's class purity template args), but this is never waived for fresh receivers.
- global writes charge
- Bracket syntax (Hack-style):
- Purity args come before type args:
Traversable[pure]<K, V>,Doer[io],iterable[P]<K, V>,Closure[io](int): void. Commas separate purity args,|unions capabilities within one. []alone is always the array type. Purity inside<...>is an InvalidDocblock, andClosure<P>is rejected.- Internally a
TGenericObjectkeeps purity args as trailingtype_params(purity templates are sorted after type templates). Printing splits them out withCapabilities::isPurityArgument, andPurityArguments::alignfills type args forFoo[pure]. TIterablehas a separatepurityproperty (default impure, not printed).- Code that strips keys for name matching must cut at
<or[(strcspn).
- Purity args come before type args:
- Wildcard
_: desugars exactly like an explicit@psalm-purity-templateat any depth of a@paramtype (nested closures,Traversable[_]<K,V>, contravariant positions,@methodparams). Never reject a position the explicit form accepts. The bound template is named_$param. Everywhere else_is rejected (TypeParser$allow_purity_wildcard, true only for@paramand alias validation). No[ctx $f]is needed (unlike Hack).@methodsignatures are re-printed viatoNamespacedStringinto a fake docblock, so anything printing withgetId()there breaks re-parsing. - Captured objects are closure state: a captured value that was fresh in the
enclosing scope (
reference_free && allow_mutations) getsUnion::$closure_state(set byCapturedValueVisitor, plumbed likereference_free). Writing it, or calling a$this-writing method on it, needswrite-this-props, notwrite-props. This also covers by-ref captures, objects in captured arrays and nested closures. Only non-fresh captures (parameters) staywrite-props. - Contracted-object reads cost
read-props: reading a property of an object whose class has a contract costsread-propsunless the caller created it fresh. This deliberately breaks 6.x compatibility; don't propose relaxing it. - Memoization: a callee with write capabilities but no recorded
this_property_mutationsforgets memoized$x->m()results (removeMutableObjectVars(true)), includingALLcallees (an unannotatedreset() { $this->clear(); }). Excluding ALL was rejected as overkill. Closureis not stubbed:Reflection::PURE_METHODSmakes bind/bindTo/fromCallable pure,ClosureReturnTypeProviderkeeps the closure type through bind/bindTo, andClosure::callis analysed as a call of the closure (ExistingAtomicMethodCallAnalyzer::analyzeClosureCall).- SPL: every method SPL declares is annotated, ignoring user subclasses (they must satisfy the annotations or get ImmutableDependency).
- Unannotated (
ALL) function-likes may usenewin parameter defaults for free (DI code); annotated ones evaluate defaults as pure (orwrite-globals).
Builtin stubs
- Call-map functions: a stub's purity declaration (
@psalm-pureincluded) is authoritative inFunctions::getCallMapFunctionCapabilities(). Without one, the heuristic applies: ImpureFunctionsList, else pure + the purity of each callable arg +write-refsper by-ref arg. Callable-string conversions usegetWorstCaseCapabilities(). - Methods are storage-driven: reflected native methods are
ALL, so a stub must give both@psalm-capabilitiesand any purity template. - For a builtin that calls a closure, prefer
@psalm-purity-template P+callable[P]+@psalm-purity-from-template P(an explicit template, notcallable[_]), unless the function has a params provider (array_map/filter/reduce/walk/u*/find* rely on ArrayFunctionArgumentsAnalyzer and are left alone on purpose). - Wrapper iterators use the
IteratorIteratorpattern: classTPurity, methods@psalm-pure+@psalm-purity-from-template TPurity. Generator methods are@psalm-pure+@psalm-purity-from-template TPurity(position is engine state). - Containers (SplDoublyLinkedList/Queue/Stack, SplFixedArray, SplHeap*, SplPriorityQueue,
SplObjectStorage) have a class-level
@psalm-capabilities read-props|write-this-propscontract, so a freshnewmay mutate them freely. Contracts aren't inherited: every stub subclass declares its own. - Stub pitfalls:
- A method the native class declares but the stub doesn't is reflected as
ALLand shadows the annotated stub parent. Inventory withReflectionMethod::getDeclaringClass()against the stub's declarations. @since 8.xon a stub method makes Psalm swap in an impure placeholder on older versions, causing ImmutableDependency against annotated interfaces. Don't gate added methods: the call map already reports UndefinedMethod.- Runtime reflection merges interfaces the stub omits, without template args; declare them in the stub.
- Stubbed methods take params from the stub, not the call map. Omit the return type to keep the call map's per-version one.
- A method the native class declares but the stub doesn't is reflected as
- When making a builtin reader mutation-free, probe drain-then-check patterns:
if ($q->isEmpty()) return; $q->dequeue(); if ($q->isEmpty()) .... @varon$x = new X()dropsreference_free(general Psalm behaviour).
Implementation pitfalls
- Issues on virtual method calls (
__get,__invoke,offsetGet) need a located node: use$stmt->name, not the VirtualMethodCall (giving it attributes breaks the UndefinedMagic issue types). MissingPureAnnotation/MissingImmutableAnnotationsuggestions are made at named-level granularity (Capabilities::toNamedLevel) to avoid noise.- Type-only fake
current/key/getIteratorcalls are silenced viaContext::$inside_type_only_call. Foreach charges the iterator methods (ForeachAnalyzer::getIterationCapabilities). - Test cases: one docblock tag per line; CapabilitiesTest valid cases run at PHP 7.4
unless
php_versionis set (positional provider args, see CLAUDE.md).
Known gaps (left on purpose or open)
- No opaque user-defined capabilities (Hack
newctx);impureis a fixed top, so an impure function may call a purity-polymorphic closure (Hack rejects that from[defaults]). @psalm-purity-from-template Trejects a type template bound to a class with a purity template (HackT::C). Workaround:@param A<P> $twith a purity templateP.exit(int)/die()are free; only exit with a non-int message costsio.MethodComparatortreats a class purity template that a subclass forwards to its own open template as its upper bound (an override can do anything whilenewbinds P to pure).- Static calls to reflected (location-less) native methods are never charged.
new IteratorIterator(new ArrayIterator(...))charges the inner's derivedwrite-this-propsto the caller.- Accepted warts:
@psalm-external-mutation-freegrantswrite-refs;global $xalways needswrite-globals;isset($a[$k])charges both offsetExists and offsetGet.
Before adding a purity feature, check Hack's behaviour first (skill hack-conformance).
In this repository8 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- hack-conformanceSkill
- merge-6x-into-masterSkill
- plugin-api-bcSkill
- pr-descriptionSkill
- psalm-reproSkill
- 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.

