Read README.md first: the stub-shadowing pattern, the sync machinery, and
the seven Design rules for new ports there are binding. This file is the
operational checklist on top of them.
Ports pay off by absorbing call frames, priced at roughly 40ns per absorbed
userland frame. Count calls first (SPX: SPX_ENABLED=1 on a self-analysis
run) and multiply — a site called 400× per run can never pay; a site absorbing
millions of tiny calls can. Three finished, correct, all-tests-green tier-1
ports were reverted because they measured ≈0% — being correct is not the bar,
being ≥0.5% faster is. When the estimate is marginal, don't port.
-
Extract the PHP code into a dedicated class (static methods are fine) under
src/, called unconditionally from the original sites — no turbo-conditional branches in callers. Find all call sites (beware:grep "\$this->foo"in double quotes sends\$to grep and silently matches nothing — use single quotes). Run the full test suite now, before any native work. -
Check it has no parent class — the stub shell extends the native class and PHP is single-inheritance, so the collector rejects it. If it is a DI service, its native
__constructarginfo must declare the real parameter class names (rule 6 in README): Nette autowires by reflecting the constructor, and erased types fail container compilation for every shadowed service at once. -
Implement natively: one class per
.cppinsrc/, namespacePHPStanTurbo, class non-final,instanceof-style checks instead of exact class-entry comparisons. Hot classes are registered with the raw Zend API inmain.cpp's MINIT (raw handler pointers, nothing that allocates per call). Reuse thept_*helpers insupport.h/support.cppbefore writing new ones.Style: the logic lives in a C++ handle class in
namespace phpstan_turbothat mirrors the PHP twin method for method (seeTrinaryLogic.cppas the reference;and/orkeyword clashes get a trailing underscore); registration goes through thereg::Classbuilder inreg.h— onecls.method("name", flags, requiredArgs, { args... }, lambda)declaration per method, where the lambda body is only ZEND_PARSE_PARAMETERS glue + one delegation line (see TrinaryLogic.cpp). Never introduce per-call argument boxing in a registration path — raw handler pointers only. Use the zero-cost wrappers inzv.h— borrowedzv::Refviews vs owned move-onlyzv::ValRAII values (UNDEFVal= pending exception),zv::ArrRefrange-for instead of hand-rolledZEND_HASH_FOREACH(it handles the packed layout of PHP 8.2+ — never walk Buckets by hand). Zero-cost is the bar: no virtuals, no exceptions, no allocations the raw form would not make; where an abstraction is not provably free, keep the raw zend form and say so in a comment. New generic helpers go intozv.hfollowing its conventions, never as one-offs. -
Class names the native code needs go through
Runtime::configure(), fed from the generatedvendor/turbo-class-map.php: add the key topt_class_refsinsupport.cppand mark the referenced class with#[ReferencedByTurboExtension(key: '...')](vendored PhpParser classes are hardcoded inbuild/TurboAttributeCollector.phpinstead —tests/smoke.phpholds the map against the real compiled table viaRuntime::classRefs()). A referenced class that is itself shadowed is one the native code instantiates: its table entry gets no default name, and the resolved name is the stub subclass, so created instances satisfy the original type hints. -
Mark the class with
#[ShadowedByTurboExtension(turboClass: 'PHPStanTurbo\Foo', implementation: __DIR__ . '/../turbo-ext/src/Foo.cpp')]and runcomposer dump-autoload—build/generate-turbo-stubs.phpregenerates the stub shells invendor/turbo-stubs.php, the manifest of shadowed pairs invendor/turbo-shadowed-classes.jsonand the class map invendor/turbo-class-map.phpfrom the attributes (shadowed classes living in vendor/ cannot carry the attribute and are hardcoded inbuild/TurboAttributeCollector.php). -
Check method parity:
php bin/side-by-side.phpmust pass (it also re-derives the generatedvendor/turbo-*files from the attributes and byte-compares them, so a stale autoloader dump fails there). -
Extend
tests/smoke.phpwith differential coverage (native result must equal the PHP implementation's result on the same inputs) and register the class in$coverednext to its checks — the completeness check at the end fails for any shadowed class with no registered coverage. -
Verify: strict build, smoke test,
php -d extension=$PWD/turbo-ext/phpstan_turbo.so turbo-ext/tests/signature-parity.php(arginfo parameter names must match the PHP twin exactly — named arguments), fullmake testswith the extension loaded, and byte-identical analysis output with the extension on vsPHPSTAN_TURBO=0. Anything touchingsrc/parser/additionally runsturbo-ext/tests/parser-corpus.php(byte-identical ASTs over the whole corpus); a php-parser version bump in composer.lock requires the same. -
Benchmark (protocol below). ≤0.5% → revert the port, keep the PHP extraction only if it stands on its own.
-
Version bump (only when
turbo-ext/src/changed): commit the change, then runmake bump-turbo— it setsTurboExtensionEnabler::EXPECTED_EXTENSION_VERSIONtogit log -1 --format=%H -- turbo-ext/src | cut -c1-7and commits the follow-up (rerun it after a rebase: it refreshes an unpushed bump commit in place). The binary's own version is baked from git at build time (builds outside the monorepo read the VERSION.txt that subsplit-turbo-ext.yml generates into phpstan/turbo-ext — never create that file here), so only the PHP constant is maintained. The bump cannot be part of the same commit — the SHA would change under it — and must be recomputed after the change lands on the target branch (a pull request commit gets a new SHA when rebased). A PHP-twin-only edit needs no bump, but still needs the parity checks and the port.
cd turbo-ext
make WARN_FLAGS="-Wall -Wextra -Werror -Wno-assume -Wno-unused-parameter -Wno-unicode"
php -d extension=$(pwd)/phpstan_turbo.so tests/smoke.php # must print ALL OKThe three -Wno- exemptions are for zend macro expansions only (documented in
.github/workflows/phar.yml); new warnings in our code are fixed, not
exempted. Zend header noise is handled by the pragma guards in support.h.
Judge user CPU, never wall clock, on interleaved A/B pairs (the machine drifts ±1s thermally — never run all A then all B):
bin/phpstan clear-result-cache -c build/phpstan.neon -q
/usr/bin/time php -d memory_limit=6G bin/phpstan analyse -c build/phpstan.neon --debug -q src
# baseline runs: prefix with PHPSTAN_TURBO=0 (the ini loads the extension globally)Output identity: --error-format=raw runs in both modes must diff empty.
- PHP literal
[]is the read-onlyzend_empty_arrayin RODATA:ZVAL_ARR/RETVAL_ARRforce refcounted flags and a later addref SIGBUSes. CheckGC_FLAGS(ht) & IS_ARRAY_IMMUTABLEfirst (seept_*array helpers). - Expression-table keys can be numeric strings that PHP coerced to int keys:
always use
zend_symtable_*/ the dual string+indexpt_ht_*helpers, never plainzend_hash_findon user-derived keys. - Private userland methods are callable from C via
ce->function_tablelookup +zend_call_known_function— no visibility check applies. - Globals are plain statics; keep new state in
pt_globals. This holds in ZTS builds too — PHPStan's CLI processes are single-threaded (parallelism is worker processes, not threads) — but only EG()/CG() access is truly thread-aware (via the TSRMLS cache inmain.cpp), so never introduce actual multi-threaded use. - Fibers can suspend from internal frames — native code may be re-entered.
- Cloned scopes must reset per-instance memo properties to constructor
defaults, and native factories must instantiate the
…Implstub classes.
Follow README.md's "Updating php-parser" procedure. The agent-relevant traps:
- The reduce actions are GENERATED — never hand-edit
ParserRunnerActions{1,2,3}.cpporParserRunnerActionsSplit.h(CI regenerates and diffs). After anyPhp8.phpchange, runphp turbo-ext/bin/generate-parser-actions.php; it fails listing any closure whose body changed upstream and has no override — port those (usually by updating the matchingsrc/parser/action-overrides/<sha1>.inc; overrides are keyed by body content, so pure renumbering never needs hand work), re-run, then corpus-verify. ParserEngine::reducedispatches viaPN_REDUCE_SPLIT_1/2from the generatedParserRunnerActionsSplit.h; the generator rebalances the three action files automatically.- The corpus differential (
tests/parser-corpus.php) is the acceptance bar — byte-identical serialized ASTs, errors, and token streams — and it only proves what the corpus contains: new syntax needs fixtures in the repo before the check means anything for it. - Finish with both pins:
SUPPORTED_PHP_PARSER_VERSIONin.github/workflows/phar.ymlplus the extension version bump inTurboExtensionEnabler(src/parser/changed).
- No
matchexpressions insrc/(the PHP downgrade tooling cannot handle them) — useswitchor if/else. - The PHP twin stays the reference implementation: fix behavior there first, port second, and keep both sides structurally parallel so they stay reviewable next to each other.