Releases: rigortype/steins
Release list
v0.1.2
Steins learns arrays. Until now an array was a fact only when every one of its elements was already known, which meant a @param array{id: int, name?: string} told the analyzer nothing it could use. This release makes the shape itself a fact — which keys are present, which are optional, whether it is a list, what each slot holds — carried through reads, isset/array_key_exists/?? guards, writes, and the array builtins. Two new offset diagnostics and a fourth built-in profile, strict, are built on it.
Around that: guard narrowing over the is_* family and strict in_array(), a handful more builtins whose result is carried forward, a much wider set of phpdoc type spellings that are now checked rather than merely parsed, and the two conditional-purity tags. This is still a preview, so the 0.1.x series continues rather than jumping a minor — read the entries, not the version number. Several of these change what your CI reports, in both directions: claims that were always true and merely withheld can start firing, and a phpdoc type that names a class shadowing a pseudo-type stops being misread.
Added
- Array shapes are a fact Steins carries, not just a spelling it parses.
array{a: int, b?: string},list{int, string},list<T>,non-empty-list<T>,array<K, V>andT[]in phpdoc are read, propagated through the function body, and printed back in the same vocabulary byannotateanddumpType(). A read of$row['id']answers from the declared shape instead of widening to silence, so everything downstream of that read — argument checks, return checks, contract checks — has something to work with for the first time.- Guards narrow a shape and writes update it.
isset($a['k']),array_key_exists('k', $a),!empty($a['k'])and$a['k'] ?? $defaulteach promote an optional key to present on the branch where they hold, and subtract it on the branch where they do not; a disjunction of guards (array_key_exists('a', $x) || array_key_exists('b', $x)) records the disjunctive fact rather than dropping to nothing. An assignment$a['k'] = …adds the key; a write Steins cannot follow widens the shape rather than keeping a stale one. - Array builtins project through the shape.
count()andarray_is_list()answer from it;array_values(),array_keys(),array_flip(),array_reverse(),array_key_first()andarray_key_last()produce the projected shape;array_all()andarray_any()carry the non-emptiness legs, pinned on both vacuous cases.array_slice()is deliberately not among them — its offset and length arguments govern the result's key structure, and a shape-only answer would say no more than the reflectedarrayenvelope already does. - Declaration order is never trusted. Where a result depends on the order of an array's keys, Steins computes it only over a value whose order it actually witnessed, and takes the sound widening over a shape that merely declares its keys in some order. This is a deliberate divergence from PHPStan, which reads declaration order as runtime order and reports a real false-positive class from it (
phpstan/phpstan#14940); it is registered indocs/type-specification/divergence-registry.mdalong with the two others this work added. - This can surface findings that were not firing before. Knowing what an array holds is exactly what lets an argument, return or property check reach a verdict it previously declined; a green run may go red on claims that were always true. Nothing here is a guess — a key Steins cannot prove present stays unproven, and an unprovable element widens the whole shape.
- Guards narrow a shape and writes update it.
offset.undeclared— a read of a key the declared shape proves is not there. Contract layer, on thecontractssurface and above. It reached that surface by measurement rather than by argument: a sweep over 99,522 files of clean corpus code produced zero findings before it was promoted off the strict rung.offset.maybe-missing— a read of a key the declared shape says is optional, with no guard on the path discharging it. On thestrictsurface only, because its corpus sweep found three findings and all three were one discharge gap rather than three real defects; it is not promoted until the ladder covers that case.- A fourth built-in profile,
strict.steins check --profile strictiscontractsplus the strict-floor ids. Profile selection is now a strictly cumulative rung ladder —default ⊂ contracts ⊂ strict, withthrows-directsitting beside it asdefaultplus one faceted id — and every diagnostic id carries the lowest rung that admits it, which is what lets a single layer hold ids at two rungs.strictwas a reserved name in 0.1.1 and is now selectable and extendable (extends = "strict");boundarystays reserved. Thedefault,contractsandthrows-directsurfaces are unchanged, id for id. - Guard narrowing over the
is_*family and strictin_array().is_int,is_integer,is_long,is_float,is_double,is_string,is_bool,is_array,is_object,is_null,is_numeric,is_scalar,is_callableandis_iterablenarrow on both polarities, as doesin_array($x, [...], true)against a literal list. A fact the guard refutes is dropped rather than carried on, which is the case a one-sided implementation gets wrong.- This is the largest single source of newly-surfaced true positives in the release: on one 85,000-file codebase it moved the contract-layer count by ten, every one of them a request-parameter claim that was true and previously withheld.
- More builtins carry their answer forward —
explode(),range(),var_export(),preg_replace(),get_debug_type()andspl_object_hash()now produce a usable fact about their result instead of widening.json_decode()anddirname()were deliberately refused rather than approximated, with the counterexamples recorded beside the rows that were accepted. - A much wider phpdoc vocabulary is enforced, not merely accepted.
lowercase-string,uppercase-string,non-empty-lowercase-string,non-empty-uppercase-string,decimal-int-string,non-decimal-int-string,non-null-mixed,non-empty-mixed,non-empty-scalar,associative-array,non-zero-int,number,key-of<…>andvalue-of<…>constrained nothing in 0.1.1 —lowercase-string,uppercase-string,key-ofandvalue-ofwere recognized and then treated as opaque, and the rest were not in the vocabulary at all and fell through to being read as a class name. Each is now a checked refinement, so a value that violates one is a finding where before it was a silence. No spelling was removed or re-read:numeric-int-stringand the rest of the opaque string family are exactly as they were. The casing pair is the identitystrtolower($s) === $s(so an uncased string satisfies both), anddecimal-int-stringis the engine's own array-key-cast rule rather than an imitation of it:'123'becomes an int key,'007','+1','-0'and anything pastPHP_INT_MAXdo not. Each spelling's exact reading, including where Steins deliberately decides one PHPStan leaves open, is indocs/type-specification/contract-types.md. - The two conditional-purity tags are read —
@pure-unless-callable-is-impure $callbackmakes a function's effect the join of the callables actually bound at the flagged positions, and@pure-unless-parameter-passed $matchesdeclares a userland by-ref out-parameter that is colored by its argument at each call site. Bare or@phpstan--prefixed, in the spelling merged upstream inphpstan/phpdoc-parser2.3.3. The unconditional@phpstan-pureand@phpstan-impurestay unread on purpose: Steins spells unconditional purity#[\Steins\Pure], where inference can check it rather than take it on trust. mutate.local— a new effect label for a by-ref write whose target is a binding of the calling frame (preg_match($p, $s, $matches),sort($localRows)). Nothing escapes the frame, so no caller can observe it, and every envelope tolerates it —#[\Steins\Pure]included. The label is decided per call site from the argument's lvalue root: a frame-private binding earnsmutate.local, a superglobal earnsglobal.write, and a property, static property or unclassifiable target earns the conservative parentmutate.- A
callableposition can carry a purity obligation, checked against the closure actually bound there rather than assumed. - Your own assertion helpers discharge a guard. A call to a userland function carrying
@phpstan-assert/@phpstan-assert-if-true/@phpstan-assert-if-false(or the@psalm-spellings) now discharges the obligation at the call site, so wrappingisset()in a helper no longer loses the proof that the direct call would have kept. This closed the onlyoffset.maybe-missingresidue the corpus sweep found — three findings, all one gap, now zero.
Changed
- A phpdoc type naming a class that shadows a pseudo-type now resolves to the class. PHP reserves its native type words, so
int,string,bool,callable,iterableand the rest can never be a class name and the keyword always wins. Every other spelling Steins knows —integer,boolean,double,numeric,scalar,closure— is a legal PHP class name, and 0.1.1 read it as the pseudo-type unconditionally. A project with its ownIntegerorScalarclass had every@param Integer $xmisread asint, which is a contract-layer verdict about the wrong type entirely. Hyphenated keywords (positive-int,non-empty-string) are not legal identifiers, so nothing can shadow them and they are unaffected. - A baseline entry may record the profile rung it was captured at. The entry gains an optional
"surface"field, and an entry counts as unmatched only on a run whose rung is at or above its o...
v0.1.1
The first release after v0.1.0, and the first one that can change what your CI reports. Three entries below move findings — folding now reaches array arguments, class_alias no longer silences absence claims project-wide, and array literals with a negative key are read by your PHP's version rules — so a green run may go red on claims that were always true and merely withheld. Steins is still a preview with plenty unimplemented, so the 0.1.x series continues rather than jumping a minor; read the entries rather than the version number to know what changes.
Alongside those, the binary now carries its own legal notices and can say what version it is, doctor reports the code it declines to reason about instead of leaving a quiet run unexplained, and Steins is installable through Composer.
Added
steins versionandsteins license— the binary now tells you what it is and carries its own legal notices.steins version(also-v,--version) prints the version, the date and commit it was built from, the copyright, and where to read the licenses.steins licenseprints Steins' full Apache-2.0 terms followed by every bundled dependency's notice.- Both texts are compiled into the executable, which matters because nothing downstream keeps them beside it:
brew installputs the binary on yourPATHwithoutLICENSE, and so doescargo install --git. Apache-2.0 §4(a) entitles you to a copy of the licence and the bundled MIT/BSD/ISC dependencies require their notices to accompany a binary — now the binary carries both itself, whatever your packager installed. - One licence reads as one entry, not one per copyright holder: the MIT permission notice is printed once with every dependency's copyright line above it — which is what "the above copyright notice and this permission notice shall be included in all copies" asks for — and bodies differing only in typography (centred versus flush-left) count as the same licence. That is 877 lines rather than the roughly 1,900 a per-crate listing would print, with no attribution lost.
- Both texts are compiled into the executable, which matters because nothing downstream keeps them beside it:
steins doctornow says what a quiet run was silent about — a Coverage posture section inventories the code the analyzer parses and then declines to reason about, so a clean run is a measured claim rather than an unexplained silence.- Poisoned scopes as a share of all scopes, with the constructs that caused them broken down by kind:
eval,include/require,extract,compact, variable variables, reference assignment,global,static, and by-ref capture. Every local in such a scope is unknown by design — that is why Steins does not report false positives there, and now it says so. - Dam sites broken down by
eval/ unproven include / runtime-nameclass_alias: the sites where an absence claim about a function or class stays silent because runtime code could mint the name. - Reflection-driven invocation sites (
->invoke*(),->newInstance*(),Closure::bindwith a computed scope,func_get_args()under a typed signature), reported as an explicitly incomplete guess: these silence nothing on their own, and the list exists to be corrected against real code. - The section reports; it never fails. Nothing here is a diagnostic id, nothing enters a baseline, and
doctorstill exits 0 on every environment fact.
- Poisoned scopes as a share of all scopes, with the constructs that caused them broken down by kind:
- A Composer channel —
composer require --dev typedduck/steinspins the analyzer incomposer.lockbeside the code it analyzes, so CI and every developer resolve the same version.- What Composer installs is a PHP shim, not the analyzer: on first use it downloads the release binary matching the installed version, checks it against the sha256 published with that release, and runs it. Later runs use the cached binary and touch no network.
- Requires PHP 8.1 or newer. A platform with no prebuilt binary — notably arm64 musl — is refused by name and pointed at a source build, rather than handed an archive that cannot run.
- The effect vocabulary as its own package —
typedduck/steins-attributessupplies#[\Steins\Pure]and#[\Steins\Effect], and the Composer package requires it, so one install leaves you able to declare an envelope.- MIT and inert at runtime, separate from the analyzer because it is vocabulary rather than tooling (ADR-0025). The Homebrew and release-binary channels are unchanged; the attributes were always yours to install there.
count,in_arrayandimplodenow compute their value when you write the array out. Steins evaluates a small set of pure builtins on your own PHP and carries the answer forward as a known value; until now it could only pass scalars to them, so these three — the ones whose whole job is to take an array — never actually ran.count([1, 2, 3])is3,implode(",", ["a", "b"])is"a,b",in_array(2, [1, 2, 3])istrue, and any check downstream of that value now has something to check. Nested literals count too:count([[1, 2], [3]])is2.- This can surface findings that were not firing before, in the ordinary way that knowing a value does: a folded result flows into argument, return and contract checks like any other proven value.
- The array has to be written out in full. One element Steins cannot prove — a variable, a call, an offset read — and the whole array is unknown, because
count([1, $x])is not2when$xmight be an array. A literal of more than 256 entries or nested more than 8 deep is left alone as well. - The keys are your PHP's, not an imitation of them: the array is built by the same engine that evaluates the call, so a repeated key, an omitted key after an explicit one, and the negative-key rule PHP 8.3 changed all behave exactly as they do when you run the code.
Changed
THIRD-PARTY-LICENSES.mdnow reads as one entry per licence, not one per copyright holder. The file ships inside every release archive, and in 0.1.0 it repeated the MIT permission notice once for each of the 39 dependencies that ship it, because the copyright line above each one differs. Those sections are now grouped by the permission notice, with every crate's copyright notice listed above it — which is exactly what "the above copyright notice and this permission notice shall be included in all copies" describes. The file goes from 1,897 lines and 49 sections to 671 and 9, with no holder dropped: the regrouping is checked by a test comparing the before and after sets of copyright notices rather than trusting the eye. The same treatment applies to any licence family whose text carries a per-crate copyright line; MIT is simply the only one in this tree with more than one holder.
Fixed
class_alias(X::class, 'Name')no longer silences the absence family across your whole project.X::classis resolved by the PHP compiler — it is a plain string constant, it autoloads nothing, andXneed not even exist — but Steins was reading it as a name minted at run time and raising the runtime-definition dam on it. That dam is a single project-wide switch, so one such call anywhere in the analyzed universe madecall.undefined-function,class.undefined, and the guarded legs ofcall.undefined-methodgo quiet in every file. Vendoring one package that writesclass_alias(Thing::class, 'Legacy_Thing')once per class was enough to do it; on one 85,000-file codebase this accounted for 32,749 of 32,914 dam sites. The call now contributes a class-alias edge to the index, exactly as the two-string-literal form already did, and the alias name resolves.- This can surface findings that were being suppressed — that is the point of the fix. If your project's dam is now clear where it was not, absence findings you have never seen may appear; they are claims that were always true and always withheld. Everything else about the dam is unchanged: a genuinely computed name (a variable, a concatenation, a function call, a constant) still dams, as do
self::class,static::class, andparent::class. - The
X::classspelling is resolved against the file'suseimports (including groupeduse A\{B, C}), its namespace, and thenamespace\Xrelative form — not taken as written — so the alias points at the class PHP would point it at. A string-literal argument keeps its existing meaning as a runtime FQN spelled out in full.
- This can surface findings that were being suppressed — that is the point of the fix. If your project's dam is now clear where it was not, absence findings you have never seen may appear; they are claims that were always true and always withheld. Everything else about the dam is unchanged: a genuinely computed name (a variable, a concatenation, a function call, a constant) still dams, as do
- A long report piped to
headorlessno longer crashes.steins checkon a large tree,annotateon a long file,transform's diff anddoctor's report could all outrun a pipe buffer, and when the reader went away — which is exactly what| head,| grep -m1and quittinglessearly do — the command died withfailed printing to stdout: Broken pipe (os error 32). Reading a long report through a pager is ordinary use, not abuse. All user-facing output now goes through one writer that treats a closed reader as the reader's decision rather than an error.- The command's own verdict is untouched:
steins check | headstill exits 1 when the tree has findings and 0 when it does not, so a pipeline underset -o pipefailreports what the analysis found rather than what the pager did. What a closed pipe can no longer do is invent a failure — no panic, no exit 101, and no failure exit on a run that succeeded.
- The command's own verdict is untouched:
- Array literals with a negative key are read by your PHP's rules, not one fixed version's. PHP 8.3 changed where an omitted key lands after a negative one:
[-5 => 'a', 'b']puts'b'at0before 8.3 and at-4from 8.3 on. Steins applied the pre-8.3 rule unconditionally, so on any supported PHP it could hold the wrong key for such a literal — and a key is what===and==compare, so a wrong key is a wrong verdict about whether two arrays are the same. The rule is now chosen from the PHP that runs your project, and both sides of the 8.3 boundary are served, because the supported floor is 8.1.- This can change findings either way on code with such a literal: a comparison Steins got wrong now d...
v0.1.0
The first public release. Steins is a static analyzer for PHP built on one commitment: a bare steins check reports only what provably breaks at runtime, and stays quiet about everything else. That is enforced, not aspirational — the release gate runs the analyzer over roughly 100,000 files of real, clean PHP and fails if it emits a single proof-layer finding. This release ships that gate green.
The trade is deliberate. Steins finds less than a conventional analyzer, and what it does report you should not have to argue with. Where it cannot prove something, it widens and says nothing rather than guessing — and where its own coverage is degraded, it says so out loud instead of quietly reporting less.
Added
steins check— the analyzer. Default output is text;--format jsonemits the same findings machine-readably, with the accounting envelope (vendor-suppressed, suppressed, baselined counts).- A two-layer diagnostic model. The proof layer carries the zero-false-positive guarantee and is on by default; the contract layer judges what your phpdoc claims against what the code proves (
phpdoc.param-mismatch,phpdoc.return-mismatch,phpdoc.property-mismatch,phpdoc.undefined-method, thethrow.*family) and is opted into by profile. Sixteen ids are on the default surface, among themtype.argument-mismatch,type.return-mismatch,type.property-mismatch,readonly.reassigned,call.on-null,call.too-few-arguments,call.unknown-named-argument,call.undefined-function,call.undefined-method,class.undefined,offset.missing, andoffset.on-unsupported. - Diagnostic ids are the contract, not message wording. Every finding carries a stable
family.rule-nameid you can suppress, baseline, and script against; the sentence it prints may be reworded in any release. - Value-precise inference via a PHP sidecar. Steins types literals by executing your project's own PHP over IPC — its version, its extensions, its autoload — so a folded value is what your code actually produces on the runtime it actually runs on, not what a model of PHP guesses.
- Honest degradation when PHP is absent. With no reachable
php, or with--no-php, the run drops to a documented sound subset, prints that it has done so, and names the findings that go silent (the absence family:call.undefined-function,class.undefined,call.undefined-method). The zero-false-positive bar still holds — nothing false is added, some true things are omitted. - Profiles and a baseline ratchet. Three built-in profiles select which layers and ids are surfaced —
default(proof + mechanics),contracts(adds the contract layer), andthrows-direct— and you can define your own insteins.toml, optionally extending a built-in.check --set-baselinecaptures the current findings so an existing codebase can adopt Steins at zero and ratchet down; the baseline records the profile and id set it was captured under, and says so loudly when the active surface has since grown past it. - Three suppression channels and no fourth: the baseline, inline
@steins-ignore, and config policy. Vendor code is suppressed by default (--vendor-diagnosticsto see it). steins doctor— a posture report: whichphpresolved and what it reports, the active profile's surface, written-but-unchecked throw envelopes, baseline health, catalog freshness. It runs no checks and never fails on a merely degraded environment, so it is safe as an install smoke test.steins annotate— reprints a file with a right-margin column of the facts Steins actually proved, for seeing the inference rather than only its complaints.steins transform— two verified codemods:phpdoc-to-nativepromotes phpdoc types to native declarations, andphpdoc-honestycorrects phpdoc that the code contradicts. Both are gated on preconditions proven across every call site, and--applyis opt-in; without it you get a diff.steins.toml— optional configuration for profiles, path sets, policy, and runtime pseudo-constants. There is noinit: a zero-config run infers everything fromcomposer.jsonand the autoloader.- Prebuilt binaries for five targets —
x86_64/aarch64Linux (glibc),x86_64Linux (musl, static), andx86_64/aarch64macOS — each with a.sha256sidecar, and each archive carryingLICENSEandTHIRD-PARTY-LICENSES.mdbeside the binary. Also installable withbrew install rigortype/tap/steins.
Notes
- Licensed under Apache-2.0. You may embed Steins in a proprietary tool, run it as a hosted service, and redistribute it, with no source-disclosure obligation, plus an express patent grant. The separate
steins-attributesvocabulary package is MIT. - Requirements: PHP 8.x for the sidecar (discovered as
phponPATH); building from source needs Rust 1.97 or newer. - There is no crates.io package, and this is structural rather than an oversight: the parser backend is a rev-pinned fork and crates.io rejects crates with git dependencies. Install from a release archive, from Homebrew, or with
cargo install --git https://github.com/rigortype/steins steins-cli. - Windows is not shipped. The sidecar spawns PHP through a temp-dir path that is unverified there, and a binary that mis-spawned it would degrade silently to the sound subset — worse than not shipping.
- What Steins does not do yet is written down rather than left to discovery: see
docs/type-specification/not-implemented.md. It is also not a linter or a formatter, and will not become one.