diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-07-21 02:54:18 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-07-23 22:07:16 +0900 |
| commit | 765b7749d31675d3cbf541b146ead1aafd6d6ae9 (patch) | |
| tree | 11f8a07cd9e3d4ef92bb2f75a0811e41b1d928ba | |
| parent | 770013c7097b6b5c0c49c7fa46d9235c308c4ad5 (diff) | |
| download | php-shirabe-765b7749d31675d3cbf541b146ead1aafd6d6ae9.tar.gz php-shirabe-765b7749d31675d3cbf541b146ead1aafd6d6ae9.tar.zst php-shirabe-765b7749d31675d3cbf541b146ead1aafd6d6ae9.zip | |
feat(plugin-class-classifier): add deterministic plugin-boundary classifier
Decides, for every composer/composer class, how it is treated at the
plugin boundary (rust-proxy / rust-snapshot / contract / two-world /
php-native / unsupported) so that upstream updates re-classify new or
rewritten classes without re-deriving the design by hand. Rules and
category definitions live in docs/dev/plugin-class-classification.md;
the tool (PHP + nikic/PHP-Parser) implements them as a reachability
closure with direction marks, per-method pure/mutator analysis, and a
leaf-first fixed point for unreachable classes, with three small
versioned exception lists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
23 files changed, 3204 insertions, 0 deletions
diff --git a/docs/dev/plugin-class-classification.md b/docs/dev/plugin-class-classification.md new file mode 100644 index 00000000..db48880f --- /dev/null +++ b/docs/dev/plugin-class-classification.md @@ -0,0 +1,466 @@ +# Plugin boundary class classification + +## Purpose + +Shirabe's plugin mechanism runs Composer plugins as real PHP code in a child +process, connected to the Rust core by a bidirectional RPC channel. The +entity of each object is fixed to one side: the Composer object graph lives +in Rust and is proxied into the child process as thin same-FQCN stub +classes, while code that shares no state with the graph is loaded into the +child as the real PHP implementation. This document defines, for *every* +class in composer/composer and its vendor dependencies, which treatment it +receives — and defines the decision as a deterministic procedure, so that +new or rewritten classes in future Composer releases are classified the same +way without a human re-deriving the design. + +The classification decides three practical things per class: + +* what the stub generator must emit for the PHP child process (proxy stub, + snapshot class, real source passthrough, or nothing), +* what the Rust side must reproduce with full fidelity (method set, argument + order, class hierarchy, subclassing behavior) versus where internal + refactoring is free, +* which classes need a reverse adapter so that plugin-provided objects can be + called back from Rust. + +A small, versioned exception list is permitted. Everything not on it must be +decided by the rules below, from the PHP sources alone. + +## Classification output + +### Categories + +Every class or interface that can appear at the plugin boundary is assigned +exactly one category. + +| Category | Entity lives | PHP child process sees | Rust obligation | +|---|---|---|---| +| `rust-proxy` | Rust | generated proxy stub (methods RPC to Rust) | full-fidelity reproduction; every public/protected method needs an RPC handler | +| `rust-snapshot` | Rust | generated snapshot class (`__rhandle` + eagerly copied fields, getters answer locally) | full-fidelity reproduction; snapshot serializer | +| `contract` | n/a (interface / abstract type) | generated declaration preserving the `extends`/`implements` hierarchy | depends on direction attributes | +| `two-world` | both, independent siblings | the real PHP implementation (same FQCN, re-defined or vendor-loaded) | independent Rust implementation; only the seam objects (composer / io / dispatcher) are shared | +| `php-native` | PHP | the real, unmodified PHP source | none — Rust may or may not have its own port for internal use, and that port is free to diverge in shape | +| `unsupported` | n/a | nothing; any reference raises an explicit error | none, until explicitly promoted | + +#### rust-proxy + +The living services: `Composer`, `Config`, `RepositoryManager`, +`InstallationManager`, `EventDispatcher`, `Locker`, `PluginManager`, +`DownloadManager`, `ArchiveManager`, `Loop`, `HttpDownloader`, the +`IOInterface` implementations, and the Event objects passed to listeners. +State sharing is their essence; every method call round-trips to the Rust +entity. + +#### rust-snapshot + +Immutable value objects, e.g. `Link` and the security-advisory family. The +child receives the field values together with an interned `__rhandle`, so +identity (`===`) is preserved while getters answer locally with zero +round-trips. + +#### contract + +Interfaces and abstract classes themselves; concrete classes get one of the +other categories. A contract's *direction* decides the artifacts: a +`provided` contract (instances flow Rust→PHP) needs the PHP declaration so +`instanceof` works; a `consumed` contract (plugins implement it and Composer +calls it back: `PluginInterface`, `EventSubscriberInterface`, +`InstallerInterface`, `Capability` and its descendants) additionally needs a +Rust-side reverse adapter that wraps a PHP object handle and implements the +corresponding Rust trait. Contracts can be both (`InstallerInterface` is +registered by plugins *and* returned by +`InstallationManager::getInstaller()`). + +For abstract *classes* with concrete state and bodies (`BaseIO`, +`BasePackage`, `LibraryInstaller`) a declaration-only stub is not enough: +real plugins subclass them relying on the inherited behavior, so the stub +must carry the concrete members (proxy-dispatched or operating on snapshot +state) — the per-method rows in the report cover exactly these members. + +#### two-world + +An exception-list category (it cannot be inferred from the sources): +`Composer\Console\*`, `Composer\Command\*`, and the whole symfony/console +package. Each world runs its own full implementation; instances do not cross +the boundary. + +#### php-native + +Composer-plugin-api's pure type definitions, the stateless utility classes +(`TlsHelper`, `Platform\Version`, `ClassMapGenerator`, …), exception +classes, constants-only classes, the state-decoupled vendor packages +(composer/pcre, composer/semver, seld/jsonlint, justinrainbow/json-schema, +…) — and *reachable* classes that are stateless and pure (`VersionParser`, +`Auditor`, `Platform\Runtime`): with no instance state to share, the real +code answers identically in both worlds, and a snapshot would have nothing +to copy. + +#### unsupported + +Not a design failure; it is the no-silent-accuracy-tradeoff principle +applied to classes nobody has needed yet. Plugins touching them get an +explicit error, and the class can be promoted later. + +### Attributes + +Categories alone are not enough for the stub generator. The classifier also +emits per-class and per-method attributes. + +#### direction + +`provided` / `consumed` / both: whether instances flow Rust→PHP (return +positions, event getters, callback arguments) or PHP→Rust (parameter +positions of methods on shared objects, plugin-implemented contracts). +Drives which side needs stubs and which needs adapters. + +#### plugin-constructible + +A non-abstract class with a public constructor that is also reachable from +the graph (e.g. `JsonFile`, obtainable via `Locker::getJsonFile()` *and* +freely `new`ed by plugins). These need a constructor story on the stub (the +stub ctor must RPC a `NewObject` so the entity is allocated Rust-side); the +classifier surfaces them because they are individually design-sensitive. + +#### mutable-static + +The class writes to `static` properties. Sub-classified by the disposition +list (see exception lists): `memo-cache` (pure memoization, each world may +compute its own: `Git::$version`, `Platform::$isDocker`, …), `seed-once` +(copied from the Rust side once at child startup: +`ProcessExecutor::$timeout`, which Composer seeds from config), +`needs-sync` (genuinely shared process state: `Platform`'s env table), or +`needs-review` (default for newly appearing ones — the run fails loudly +until a human files it). + +#### throwable + +Subclasses of `\Throwable`. These always need a real PHP class definition in +the child (so `catch`/`instanceof` work) regardless of category, and a wire +mapping for the `Throw` message. + +#### pure/mutator (per method) + +Drives epoch invalidation of the child-side getter caches: after a mutator +runs Rust-side, the affected proxies' caches must be dropped. A method is a +mutator iff it assigns to `$this->…` (directly, via compound assignment, +`unset`, increment), passes a `$this`-rooted expression into a by-reference +parameter position (builtin signatures resolved from +jetbrains/phpstorm-stubs; `&` read syntactically for user-land signatures), +or transitively calls a mutator on the same object. Statically unresolvable +calls (dynamic method names, `call_user_func` and other +callback-forwarding builtins receiving `$this`) are conservatively +mutators. + +#### by-ref parameters + +Positions of `&$param` in public methods (the protocol's out-parameter +positions). + +#### callable parameters + +Positions whose native type is `callable` or `\Closure` +(`IOInterface::askAndValidate`, promise callbacks). These are the places +the callback-handle machinery must cover; the stub generator gets an +explicit signal instead of discovering them at runtime. + +#### public static properties + +On proxied/snapshotted classes (`FileDownloader::$downloadMetadata`, +`BasePackage::$stabilities`). PHP has no `__getStatic`, so a stub cannot +intercept static property access — each one needs an explicit decision +(materialize as a constant initializer when immutable, push-sync or +explicit-error when mutable). + +## The decision procedure + +The classifier runs the following pipeline. Every step is deterministic; the +only free inputs are the versioned exception lists. + +### Inputs + +The parsed sources of `composer/src/Composer`, plus the vendor packages +named in composer/composer's `require`. Vendor packages are first classified +as a whole (see the vendor rule under "Unreachable types"); only +symfony/console and react/promise need class-level treatment. +`Composer\PHPStan\*` is excluded entirely: dev-only tooling, never shipped +at runtime. + +### Seed set + +The boundary starts where Composer hands objects to plugin code: + +* every type declared under `Composer\Plugin\` (the plugin API namespace), +* every subclass of `Composer\EventDispatcher\Event`, +* `Composer\EventDispatcher\EventSubscriberInterface`. + +Nothing else is seeded by hand: `Composer` itself enters through +`PluginInterface::activate(Composer, IOInterface)`, `BaseCommand` through +`CommandProvider::getCommands()`, `InstallerInterface` through +`InstallationManager::addInstaller()` once `InstallationManager` is reached, +and so on. + +### Reachability closure + +For every type `T` in the set, add: + +* class-typed native parameter and return types of `T`'s public and protected + methods (including inherited ones), +* element types from `@param` / `@return` / `@var` docblocks where the native + type is `array`, `iterable`, `mixed`, `object`, or absent (Composer's + PHPStan-checked docblocks make this reliable), +* types of `T`'s public and protected properties (a plugin subclassing + `LibraryInstaller` sees `$this->downloadManager`), +* `@throws` types, +* `T`'s ancestors (parent classes and interfaces), +* when `T` is an interface or abstract class: every concrete subtype declared + in the inputs (any of them can be the runtime instance behind the + abstraction). + +Iterate to a fixed point. Private members and method bodies do not extend the +closure — the boundary is the declared API surface, not the implementation. +Two-world classes do not extend it either: the child process carries their +real implementation wholesale, so a `Command` subclass's protected fields are +world-2-local, not graph seams. + +### Direction marking + +During the closure, propagate direction: on a `provided` type, plugins call +the methods — parameter types become `consumed`, return/throws types become +`provided`. The inverse expansion (parameters `provided`, returns +`consumed`) models Composer calling the plugin's implementation, and +therefore applies **only to plugin-implementable types** (interfaces and +abstract classes) marked `consumed`. A concrete class also gains a +`consumed` mark when it appears in a parameter position (a plugin can +construct one and pass it in), but its method bodies are still Composer's +own, so it always expands provided-style. Without this restriction nearly +every direction degenerates to `both` through self-feedback. + +### Categories for reachable types + +Assigned in order: + +1. On the two-world exception list → `two-world`. +2. Subclass of `\Throwable` → `php-native` with the `throwable` attribute + (exception classes are flat data; the child gets real definitions, the + wire carries them by value). +3. Interface or abstract class → `contract` (+ direction attributes). This + outranks the constants-only rule below: a marker interface such as + `Capability` still needs direction attributes. +4. Constants-only class (no methods, no properties, no hierarchy: + `ScriptEvents`, `PluginEvents`, …) → `php-native`; the definition is + pure data. +5. Belongs to a vendor package classified `php-native` as a whole → the + type is `php-native` (its appearance in signatures does not force a proxy; + instances are plain values or PHP-local objects). react/promise is the + exception: `PromiseInterface` is a `contract` bridged to Rust promises. +6. Stateless pure class → `php-native`: no instance property anywhere in + the hierarchy, every public/protected instance method `pure` per the + purity analysis, and the method bodies are locally satisfiable (they + construct nothing that will be a proxied service). With no state to + share there is nothing to proxy or snapshot; the real code, run against + injected proxies, behaves identically in both worlds. This catches + `VersionParser`, `Auditor`, `Platform\Runtime`, `NoopInstaller`. +7. Value-object test → `rust-snapshot`: same purity and body-locality + conditions, plus (a) at least one instance property (something to + copy), and (b) no property — including inherited ones — and no + constructor parameter typed as a blocking type (a reachable type that is + not itself a candidate, a throwable, or a php-native vendor type). The + candidate sets grow as one fixed point, and after the unreachable pass + below, candidates whose bodies reference `unsupported` classes are + demoted and everything reruns until stable. This is a strict *immutable + value object* detector: it finds `Link` and the advisory family — but + not `Package`/`CompletePackage`, which carry setters and a + `RepositoryInterface` back-reference (see "Known deviations"). +8. Everything else → `rust-proxy`. + +### Unreachable types + +Classes never touched by the closure are not part of the shared graph, but +plugins may still reference them (`new Composer\Util\Filesystem()`, +`JsonFile::parseJson()`, any vendor helper). The rule is a leaf-first fixed +point over *hard* body references — `new X`, `X::method()`, writes to +`X::$prop` — while `instanceof`, `catch`, and `X::class` are satisfied by a +mere declaration and never demote. + +What a hard reference may legally target from real PHP running in the child +process: + +* `X::method()` works when X has any executable presence there: a php-native + class, real vendor code, real console code (two-world), or a generated + stub — proxy stubs carry static methods as RPC forwarders, which is what + makes the ubiquitous `Platform::getEnv()` call sites loadable. Only + `unsupported` peers and unknown types demote. +* `new X` additionally requires local constructibility: php-native, + two-world, vendor, and builtin classes are real code; `rust-snapshot` + values may be built locally (they are values — they become Rust-backed + when they cross the boundary). Constructing a `rust-proxy` service is the + unresolved dual-instantiation case and demotes, explicitly and visibly. +* a `needs-sync` static disposition (and an unfiled one) also demotes. + +A class every hard reference of which passes is `php-native`; otherwise it +is `unsupported` — the class embeds orchestration over shared state (e.g. +`Composer\Installer`, `Factory`, the solver), and silently running the real +PHP implementation against proxies would fork the state the Rust side +believes it owns. Explicit error until a human decides. Demotions cascade, +and every demotion records its concrete reason in the report. Classes with +an `overrides.list` entry take their category from the override and do not +participate in the fixed point. + +Vendor packages are classified wholesale by the same criterion applied +package-level: a package is `php-native` if no class in it references +composer/composer types or shared static state (true for composer/pcre, +composer/semver, seld/jsonlint, justinrainbow/json-schema, +composer/ca-bundle, composer/spdx-licenses, composer/metadata-minifier, +composer/class-map-generator, composer/xdebug-handler, seld/signal-handler, +psr/log, symfony/filesystem, symfony/finder, symfony/process, +seld/phar-utils, the polyfills); symfony/console is `two-world`; +react/promise ships as real code while `PromiseInterface` is bridged. + +### Exception lists + +Three, all versioned next to the tool, all expected to stay short: + +* `two-world.list` — `Composer\Console\*`, `Composer\Command\*`, + symfony/console. +* `static-state.list` — disposition per mutable-static class + (`memo-cache` / `seed-once` / `needs-sync`); anything not listed fails + the run. +* `overrides.list` — per-class category corrections. Every entry must carry + a reason. Initial content: `Platform` → `rust-proxy` (its env table is + shared state; the child's Platform stub RPCs env access so both worlds + see the same environment), the bootstrap classes (see "Known + deviations"), and two phantom-reachability corrections (`HhvmDetector`, + `VersionGuesser`). + +### Failure mode + +When the rules cannot decide (a new mutable-static class, a docblock the +type extractor cannot parse in a position that matters, a class whose +category changed between Composer versions), the classifier fails the run +and names the class. It never silently defaults — the +default-to-`unsupported` rule for unreachable classes is itself an explicit, +reviewable outcome in the report, not a silent guess. + +## Known deviations and open questions + +The mechanical rules surfaced several points where earlier design prose was +incomplete or a decision is still owed. Each needs an explicit user +decision; the tool keeps them visible instead of resolving them silently. + +### ProcessExecutor and HttpDownloader are reachable + +Earlier design analysis assumed no public getter returns a +`ProcessExecutor`; it had checked `Composer.php`'s getters only. In fact +`Composer::getLoop()` → `Loop::getProcessExecutor(): ?ProcessExecutor` / +`Loop::getHttpDownloader(): HttpDownloader` make both reachable. The +graph-owned `ProcessExecutor` instance must therefore be proxied (its job +queue is driven by the Rust loop), while the design intent — plugins using +it as a stateless utility — survives only for plugin-`new`ed instances. +This is exactly the dual-instantiation situation the +`plugin-constructible` attribute exists to surface. + +### Dual instantiation + +`Locker::getJsonFile(): JsonFile` makes `JsonFile` reachable, so it is +`rust-proxy` + `plugin-constructible` — and plugins `new JsonFile(...)` +constantly. The question is not cosmetic: `ProcessExecutor`, `JsonFile`, +and `Util\Filesystem` being `rust-proxy` is what demotes the VCS/auth +utility belt (`Git`, `GitHub`, `GitLab`, `Bitbucket`, `Svn`, `AuthHelper`, +`RemoteFilesystem`) to `unsupported` — each of them constructs one of those +three internally. `ArrayLoader` is a fourth member: plugins `new +ArrayLoader` constantly, and it drives constructor-plus-setters on the +package classes, so its fate follows theirs. These utilities can become +php-native the moment plugin-`new`ed instances of the trio may live +PHP-locally (or the stub `NewObject` constructor story lands); until the +user decides, the tool reports them as `unsupported` with the constructing +site named. + +### Package and CompletePackage + +They classify as `rust-proxy` mechanically: they carry setters +(`setRepository`, `setInstallationSource`, …), so the strict immutability +test rightly rejects them. The plugin architecture plans a +snapshot-with-writeback treatment for packages ("essentially immutable" as +a pragmatic call); enacting it is an `overrides.list` entry awaiting +explicit confirmation, including for the `RootPackage`/`AliasPackage` +variants. + +### Bootstrap classes cannot be stub-shadowed + +The child process necessarily `require`s the project's real +`vendor/composer/ClassLoader.php` (and `installed.php` / +`InstalledVersions`) to autoload plugin code, before any stub could load. A +same-FQCN stub cannot coexist; both are overridden to `php-native`. +`InstalledVersions::$installed` is nonetheless genuinely shared state — +Rust rewrites `installed.php` on every dump — so the Rust side must push a +reload (`InstalledVersions::reload()`) after installs, or post-install +event handlers read stale data. + +### ConsoleIO leaks world-2 objects + +`ConsoleIO` (rust-proxy) returns real symfony/console instances through the +seam: `getTable(): Table` / `getProgressBar(): ProgressBar`, and its +constructor takes `InputInterface`/`OutputInterface`/`HelperSet` — none of +which can cross the wire as values. The stub needs a bespoke story (e.g. a +local Table bound to a proxying `OutputInterface`), or these members become +explicit errors. Undecided. + +### Proxy clone semantics + +`clone $package` is a common plugin idiom (and `NoopInstaller::install` +does `$repo->addPackage(clone $package)`), but PHP `clone` on a proxy stub +copies the handle, not the Rust entity. The stub generator needs a +`__clone` that RPCs a clone of the entity. Undecided. + +## The classifier tool + +### Dependencies and layout + +`scripts/plugin-class-classifier/` implements the pipeline in PHP. Two +Composer dependencies: nikic/PHP-Parser (parsing) and +jetbrains/phpstorm-stubs (builtin function signatures — by-ref parameter +positions are read from the stubs instead of a hand-maintained table). +`vendor/` and `report.json` are git-ignored; `composer.lock` is committed. +The exception lists live in `lists/`. + +### Running + + scripts/plugin-class-classifier/classify + +reads `composer/src/Composer` and writes `report.json` (machine-readable, +stable ordering) plus a human-readable Markdown summary to stdout. Per-class +rows carry category, direction, attributes (including public static +properties on stub categories), demotion reasons, and — for stub-relevant +categories — the per-method purity verdicts, by-ref parameter positions, +and callable parameter positions the stub generator needs. The wholesale +vendor package table and the vendor types actually reached by the closure +are listed separately. The run exits non-zero when a rule cannot decide (an +unfiled mutable-static class, a reachable type that resolves to nothing +known); the report is still written so the violation can be reviewed and +filed. + +`report.json` is generated output and not tracked in git. The intended +workflow on a Composer upgrade: run the classifier before updating the +`composer/` checkout, keep that report aside, re-run after the update, and +diff the two files — review only the changed rows. A brand-new class lands +in a category (or in the violation list) without any human re-derivation. + +### Analysis limits + +All conservative: + +* dynamic calls (`$this->$m()`, `call_user_func` and other + callback-forwarding builtins with `$this`-rooted arguments) force + `mutator`; +* a call to a method that is abstract at the analyzed level + (`$this->getVersion()` from `BasePackage::getUniqueName`) forces + `mutator` even when every concrete implementation is a pure read — this + costs spurious epoch invalidations, not correctness, and can be refined + by resolving abstract callees over all concrete subtypes; +* `parent::m()` purity resolves against the own hierarchy rather than the + declaring parent; +* vendor by-ref signatures are tabled only for composer/pcre + (jetbrains/phpstorm-stubs covers PHP itself, not Composer's vendor + packages; other vendor APIs composer calls expose no by-ref parameters); +* docblock type extraction tokenizes rather than fully parsing phpdoc + (constants, `@template` names, and phpstan aliases are filtered out). diff --git a/scripts/plugin-class-classifier/.gitignore b/scripts/plugin-class-classifier/.gitignore new file mode 100644 index 00000000..f7db2fa7 --- /dev/null +++ b/scripts/plugin-class-classifier/.gitignore @@ -0,0 +1,2 @@ +vendor/ +report.json diff --git a/scripts/plugin-class-classifier/classify b/scripts/plugin-class-classifier/classify new file mode 100755 index 00000000..03406ac2 --- /dev/null +++ b/scripts/plugin-class-classifier/classify @@ -0,0 +1,40 @@ +#!/usr/bin/env php +<?php + +declare(strict_types=1); + +require __DIR__ . '/vendor/autoload.php'; + +use Shirabe\PluginClassifier\Classifier; +use Shirabe\PluginClassifier\Lists; +use Shirabe\PluginClassifier\Report; + +$repoRoot = dirname(__DIR__, 2); +$composerSrc = $repoRoot . '/composer/src/Composer'; +$out = __DIR__ . '/report.json'; + +foreach (array_slice($argv, 1) as $arg) { + if (str_starts_with($arg, '--composer-src=')) { + $composerSrc = substr($arg, strlen('--composer-src=')); + } elseif (str_starts_with($arg, '--out=')) { + $out = substr($arg, strlen('--out=')); + } else { + fwrite(STDERR, "usage: classify [--composer-src=DIR] [--out=FILE]\n"); + exit(2); + } +} + +if (!is_dir($composerSrc)) { + fwrite(STDERR, "not a directory: $composerSrc\n"); + exit(2); +} + +$classifier = new Classifier($composerSrc, Lists::load(__DIR__ . '/lists')); +$classifier->run(); + +$report = new Report($classifier); +$report->writeJson($out); +$report->printSummary(); + +fwrite(STDERR, "report written to $out\n"); +exit($classifier->violations === [] ? 0 : 1); diff --git a/scripts/plugin-class-classifier/composer.json b/scripts/plugin-class-classifier/composer.json new file mode 100644 index 00000000..853e755e --- /dev/null +++ b/scripts/plugin-class-classifier/composer.json @@ -0,0 +1,15 @@ +{ + "name": "shirabe/plugin-class-classifier", + "description": "Deterministic classifier for Composer classes at the Shirabe plugin boundary", + "license": "MIT", + "require": { + "php": ">=8.1", + "nikic/php-parser": "^5.0", + "jetbrains/phpstorm-stubs": "^2026.1" + }, + "autoload": { + "psr-4": { + "Shirabe\\PluginClassifier\\": "src/" + } + } +} diff --git a/scripts/plugin-class-classifier/composer.lock b/scripts/plugin-class-classifier/composer.lock new file mode 100644 index 00000000..b836b820 --- /dev/null +++ b/scripts/plugin-class-classifier/composer.lock @@ -0,0 +1,122 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "3dc1707f38fff8158ce4675b45d8915c", + "packages": [ + { + "name": "jetbrains/phpstorm-stubs", + "version": "v2026.1", + "source": { + "type": "git", + "url": "https://github.com/JetBrains/phpstorm-stubs", + "reference": "2cdd054c4109dfb76667c9198bf9427606354243" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/2cdd054c4109dfb76667c9198bf9427606354243", + "reference": "2cdd054c4109dfb76667c9198bf9427606354243", + "shasum": "" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.86", + "nikic/php-parser": "^v5.6", + "phpdocumentor/reflection-docblock": "^5.6", + "phpunit/phpunit": "^12.3" + }, + "type": "library", + "autoload": { + "files": [ + "PhpStormStubsMap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "PHP runtime & extensions header files for PhpStorm", + "homepage": "https://www.jetbrains.com/phpstorm", + "keywords": [ + "autocomplete", + "code", + "inference", + "inspection", + "jetbrains", + "phpstorm", + "stubs", + "type" + ], + "time": "2026-02-19T20:12:01+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.1" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/scripts/plugin-class-classifier/lists/overrides.list b/scripts/plugin-class-classifier/lists/overrides.list new file mode 100644 index 00000000..099578e0 --- /dev/null +++ b/scripts/plugin-class-classifier/lists/overrides.list @@ -0,0 +1,7 @@ +# Per-class category corrections: "FQCN category reason...". Every entry +# must carry a reason. Keep this list short; prefer fixing the rules. +Composer\Util\Platform rust-proxy env table is shared process state; the child's Platform stub RPCs putEnv/getEnv/clearEnv so both worlds see the same environment +Composer\Autoload\ClassLoader php-native the child bootstraps via the real vendor/composer/ClassLoader.php before any stub could load; a same-FQCN stub cannot shadow it. Registered-loader state is per-world. +Composer\InstalledVersions php-native same bootstrap constraint as ClassLoader (real file in vendor/composer is always loaded); its static $installed is genuinely shared state — Rust must push a reload after each install dump (see static-state.list needs-sync) +Composer\Platform\HhvmDetector php-native stateless environment probe; only reachable as a consumed ctor param of PlatformRepository, no Rust-owned instance is ever provided to plugins. Plugin-custom detectors passed into a proxied PlatformRepository become an explicit error. +Composer\Package\Version\VersionGuesser php-native stateless computation over VCS output; only reachable as a consumed ctor param of RootPackageLoader, no Rust-owned instance is ever provided to plugins. Plugin-custom guessers passed into proxied ctors become an explicit error. diff --git a/scripts/plugin-class-classifier/lists/static-state.list b/scripts/plugin-class-classifier/lists/static-state.list new file mode 100644 index 00000000..a33faeec --- /dev/null +++ b/scripts/plugin-class-classifier/lists/static-state.list @@ -0,0 +1,21 @@ +# Disposition of classes that write static properties. +# memo-cache pure memoization; each world computes its own copy +# seed-once copied from the Rust side once at child startup +# needs-sync genuinely shared process state; must not be php-native +# A class writing statics without an entry here fails the run. +Composer\Autoload\ClassLoader memo-cache +Composer\Cache memo-cache +Composer\Downloader\FileDownloader needs-sync +Composer\Downloader\ZipDownloader memo-cache +Composer\InstalledVersions needs-sync +Composer\Package\Version\VersionParser memo-cache +Composer\Platform\HhvmDetector memo-cache +Composer\Repository\PlatformRepository memo-cache +Composer\Util\ErrorHandler memo-cache +Composer\Util\Git memo-cache +Composer\Util\Hg memo-cache +Composer\Util\Http\ProxyManager memo-cache +Composer\Util\Platform needs-sync +Composer\Util\ProcessExecutor seed-once +Composer\Util\Silencer memo-cache +Composer\Util\Svn memo-cache diff --git a/scripts/plugin-class-classifier/lists/two-world.list b/scripts/plugin-class-classifier/lists/two-world.list new file mode 100644 index 00000000..11425cf6 --- /dev/null +++ b/scripts/plugin-class-classifier/lists/two-world.list @@ -0,0 +1,6 @@ +# Types with independent sibling implementations in each world +# (docs/dev/plugin-class-classification.md, category two-world). +# One namespace prefix or exact FQCN per line. +Composer\Console +Composer\Command +Symfony\Component\Console diff --git a/scripts/plugin-class-classifier/src/BodyAnalyzer.php b/scripts/plugin-class-classifier/src/BodyAnalyzer.php new file mode 100644 index 00000000..ad9d3795 --- /dev/null +++ b/scripts/plugin-class-classifier/src/BodyAnalyzer.php @@ -0,0 +1,335 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\PluginClassifier; + +use PhpParser\Node; +use PhpParser\Node\Expr; +use PhpParser\Node\Name; +use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitorAbstract; + +/** + * Walks one method body and records everything the classifier needs from + * it: direct $this mutations, by-ref builtin usage, self-calls (for purity + * propagation), statically resolvable external calls passing $this-rooted + * arguments, referenced types, and static property writes. + */ +final class BodyAnalyzer extends NodeVisitorAbstract +{ + /** @var list<string> */ + public array $newRefs = []; + + /** @var list<string> */ + public array $staticRefs = []; + + /** @var list<string> */ + public array $benignRefs = []; + + public bool $writesOwnStaticProps = false; + + public function __construct( + private readonly MethodInfo $method, + private readonly string $currentClass, + /** @var array<string, string> property name => single class type FQCN */ + private readonly array $propertyTypes, + /** @var array<string, string> param name => single class type FQCN */ + private readonly array $paramTypes, + private readonly BuiltinSignatures $builtins, + ) { + } + + public static function analyze(ClassMethod $node, MethodInfo $method, string $currentClass, array $propertyTypes, array $paramTypes, BuiltinSignatures $builtins): self + { + $analyzer = new self($method, $currentClass, $propertyTypes, $paramTypes, $builtins); + if ($node->stmts !== null) { + $traverser = new NodeTraverser(); + $traverser->addVisitor($analyzer); + $traverser->traverse($node->stmts); + } + + return $analyzer; + } + + public function enterNode(Node $node): null + { + if ($node instanceof Expr\Assign || $node instanceof Expr\AssignOp || $node instanceof Expr\AssignRef) { + $this->handleAssignTarget($node->var); + } elseif ($node instanceof Node\Stmt\Unset_) { + foreach ($node->vars as $var) { + if ($this->isThisRooted($var)) { + $this->method->mutatesThisDirectly = true; + } + } + } elseif ($node instanceof Expr\PreInc || $node instanceof Expr\PostInc + || $node instanceof Expr\PreDec || $node instanceof Expr\PostDec) { + if ($this->isThisRooted($node->var)) { + $this->method->mutatesThisDirectly = true; + } + } elseif ($node instanceof Expr\FuncCall) { + $this->handleFuncCall($node); + } elseif ($node instanceof Expr\MethodCall || $node instanceof Expr\NullsafeMethodCall) { + $this->handleMethodCall($node); + } elseif ($node instanceof Expr\StaticCall) { + $this->handleStaticCall($node); + } elseif ($node instanceof Expr\New_) { + $this->handleNew($node); + } elseif ($node instanceof Expr\Instanceof_) { + if ($node->class instanceof Name) { + $this->benignRefs[] = $node->class->toString(); + } + } elseif ($node instanceof Node\Stmt\Catch_) { + foreach ($node->types as $type) { + $this->benignRefs[] = $type->toString(); + } + } elseif ($node instanceof Expr\ClassConstFetch) { + if ($node->class instanceof Name && !$this->isSelfLike($node->class)) { + $this->benignRefs[] = $node->class->toString(); + } + } elseif ($node instanceof Expr\StaticPropertyFetch) { + // Reads are benign; writes are caught via handleAssignTarget. + if ($node->class instanceof Name && !$this->isSelfLike($node->class)) { + $this->benignRefs[] = $node->class->toString(); + } + } + + return null; + } + + private function handleAssignTarget(Expr $target): void + { + // Destructuring assigns to several targets at once. + if ($target instanceof Expr\List_ || $target instanceof Expr\Array_) { + foreach ($target->items as $item) { + if ($item !== null) { + $this->handleAssignTarget($item->value); + } + } + + return; + } + + if ($this->isThisRooted($target)) { + $this->method->mutatesThisDirectly = true; + + return; + } + + $root = $this->rootOf($target); + if ($root instanceof Expr\StaticPropertyFetch && $root->class instanceof Name) { + if ($this->isSelfLike($root->class) || $root->class->toString() === $this->currentClass) { + $this->writesOwnStaticProps = true; + } else { + $this->staticRefs[] = $root->class->toString(); + } + } + } + + private function handleFuncCall(Expr\FuncCall $call): void + { + $thisArgs = $this->thisArgPositions($call->args); + + if (!$call->name instanceof Name) { + if ($thisArgs !== []) { + $this->method->thisEscapesUnresolved = true; + } + + return; + } + + if ($thisArgs === []) { + return; + } + + $name = strtolower($call->name->toString()); + + // Callback-forwarding builtins re-dispatch their arguments to an + // unknown callee, so the stub signature (no by-ref) is not the + // whole truth: [$this, 'method'] can reach a mutator. + if (in_array($name, ['call_user_func', 'call_user_func_array', 'array_walk', 'array_walk_recursive', 'usort', 'uasort', 'uksort'], true)) { + $this->method->thisEscapesUnresolved = true; + + return; + } + + $info = $this->builtins->byRefInfo($name); + if ($info === null) { + // Not a known builtin (user-land global function, unknown + // extension): conservative. + $this->method->thisEscapesUnresolved = true; + + return; + } + + foreach ($thisArgs as $pos) { + if (in_array($pos, $info['fixed'], true) + || ($info['variadicFrom'] !== null && $pos >= $info['variadicFrom'])) { + $this->method->mutatesThisDirectly = true; + + return; + } + } + } + + private function handleMethodCall(Expr\MethodCall|Expr\NullsafeMethodCall $call): void + { + $thisArgs = $this->thisArgPositions($call->args); + $literalName = $call->name instanceof Node\Identifier ? strtolower($call->name->toString()) : null; + + $receiverIsThis = $call->var instanceof Expr\Variable && $call->var->name === 'this'; + + if ($receiverIsThis) { + if ($literalName === null) { + // $this->$method(): both the callee and any argument escape + // are unresolvable. + $this->method->thisEscapesUnresolved = true; + + return; + } + $this->method->selfCalls[] = ['name' => $literalName, 'thisArgs' => $thisArgs]; + + return; + } + + if ($thisArgs === []) { + return; + } + + $receiverClass = $this->resolveReceiverClass($call->var); + if ($receiverClass === null || $literalName === null) { + $this->method->thisEscapesUnresolved = true; + + return; + } + + $this->method->externalCalls[] = ['class' => $receiverClass, 'method' => $literalName, 'thisArgs' => $thisArgs]; + } + + private function handleStaticCall(Expr\StaticCall $call): void + { + $thisArgs = $this->thisArgPositions($call->args); + $literalName = $call->name instanceof Node\Identifier ? strtolower($call->name->toString()) : null; + + if ($call->class instanceof Name && $this->isSelfLike($call->class)) { + if ($literalName === null) { + $this->method->thisEscapesUnresolved = true; + + return; + } + $this->method->selfCalls[] = ['name' => $literalName, 'thisArgs' => $thisArgs]; + + return; + } + + if ($call->class instanceof Name) { + $className = $call->class->toString(); + $this->staticRefs[] = $className; + if ($thisArgs !== []) { + if ($literalName === null) { + $this->method->thisEscapesUnresolved = true; + } else { + $this->method->externalCalls[] = ['class' => $className, 'method' => $literalName, 'thisArgs' => $thisArgs]; + } + } + + return; + } + + if ($thisArgs !== []) { + $this->method->thisEscapesUnresolved = true; + } + } + + private function handleNew(Expr\New_ $new): void + { + $thisArgs = $this->thisArgPositions($new->args); + + if (!$new->class instanceof Name) { + if ($thisArgs !== []) { + $this->method->thisEscapesUnresolved = true; + } + + return; + } + + if ($this->isSelfLike($new->class)) { + return; + } + + $className = $new->class->toString(); + $this->newRefs[] = $className; + if ($thisArgs !== []) { + $this->method->externalCalls[] = ['class' => $className, 'method' => '__construct', 'thisArgs' => $thisArgs]; + } + } + + /** @param array<Node\Arg|Node\VariadicPlaceholder> $args */ + private function thisArgPositions(array $args): array + { + $positions = []; + foreach ($args as $i => $arg) { + if (!$arg instanceof Node\Arg) { + continue; + } + if ($this->isThisRooted($arg->value)) { + $positions[] = $i; + continue; + } + // [$this, 'method'] callback literals: the receiver escapes + // into the argument even though the array itself is fresh. + if ($arg->value instanceof Expr\Array_) { + foreach ($arg->value->items as $item) { + if ($item !== null && $this->isThisRooted($item->value)) { + $positions[] = $i; + break; + } + } + } + } + + return $positions; + } + + private function isThisRooted(Expr $expr): bool + { + $root = $this->rootOf($expr); + + return $root instanceof Expr\Variable && $root->name === 'this'; + } + + private function rootOf(Expr $expr): Expr + { + while (true) { + if ($expr instanceof Expr\PropertyFetch || $expr instanceof Expr\NullsafePropertyFetch) { + $expr = $expr->var; + } elseif ($expr instanceof Expr\ArrayDimFetch) { + $expr = $expr->var; + } else { + return $expr; + } + } + } + + private function isSelfLike(Name $name): bool + { + return in_array(strtolower($name->toString()), ['self', 'static', 'parent'], true); + } + + /** Resolve the receiver's class when it is a typed property or param. */ + private function resolveReceiverClass(Expr $receiver): ?string + { + if (($receiver instanceof Expr\PropertyFetch || $receiver instanceof Expr\NullsafePropertyFetch) + && $receiver->var instanceof Expr\Variable && $receiver->var->name === 'this' + && $receiver->name instanceof Node\Identifier) { + return $this->propertyTypes[$receiver->name->toString()] ?? null; + } + + if ($receiver instanceof Expr\Variable && is_string($receiver->name)) { + return $this->paramTypes[$receiver->name] ?? null; + } + + return null; + } +} diff --git a/scripts/plugin-class-classifier/src/BuiltinSignatures.php b/scripts/plugin-class-classifier/src/BuiltinSignatures.php new file mode 100644 index 00000000..af3d77f3 --- /dev/null +++ b/scripts/plugin-class-classifier/src/BuiltinSignatures.php @@ -0,0 +1,126 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\PluginClassifier; + +use JetBrains\PHPStormStub\PhpStormStubsMap; +use PhpParser\Node; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitorAbstract; +use PhpParser\Parser; +use PhpParser\ParserFactory; + +/** + * By-ref parameter positions of PHP builtin functions, resolved from + * jetbrains/phpstorm-stubs instead of a hand-maintained table. Stub files + * are parsed lazily, one file per first lookup of any function it defines; + * a function absent from the stubs map is unknown and the caller must be + * conservative. + */ +final class BuiltinSignatures +{ + private Parser $parser; + + /** @var array<string, string> lowercase function name => stub file relative path */ + private array $functionFiles = []; + + /** @var array<string, array{fixed: list<int>, variadicFrom: int|null}> lowercase function name => by-ref info */ + private array $byRef = []; + + /** @var array<string, true> */ + private array $parsedFiles = []; + + public function __construct() + { + $this->parser = (new ParserFactory())->createForNewestSupportedVersion(); + foreach (PhpStormStubsMap::FUNCTIONS as $name => $file) { + $this->functionFiles[strtolower((string) $name)] = $file; + } + } + + /** + * @return array{fixed: list<int>, variadicFrom: int|null}|null + * null when the function is not a known builtin + */ + public function byRefInfo(string $lowerName): ?array + { + if (isset($this->byRef[$lowerName])) { + return $this->byRef[$lowerName]; + } + + $file = $this->functionFiles[$lowerName] ?? null; + if ($file === null) { + return null; + } + + $this->parseStubFile($file); + + // Defined in the map but somehow absent from the parsed file: + // treat as unknown rather than silently non-by-ref. + return $this->byRef[$lowerName] ?? null; + } + + private function parseStubFile(string $relativePath): void + { + if (isset($this->parsedFiles[$relativePath])) { + return; + } + $this->parsedFiles[$relativePath] = true; + + $path = PhpStormStubsMap::DIR . '/' . $relativePath; + $code = file_get_contents($path); + if ($code === false) { + throw new \RuntimeException("cannot read stub file $path"); + } + $stmts = $this->parser->parse($code); + if ($stmts === null) { + throw new \RuntimeException("cannot parse stub file $path"); + } + + $byRef = &$this->byRef; + $collector = new class($byRef) extends NodeVisitorAbstract { + /** @param array<string, array{fixed: list<int>, variadicFrom: int|null}> $byRef */ + public function __construct(private array &$byRef) + { + } + + public function enterNode(Node $node): null + { + if (!$node instanceof Node\Stmt\Function_) { + return null; + } + + $name = strtolower($node->name->toString()); + $fixed = []; + $variadicFrom = null; + foreach ($node->params as $i => $param) { + if (!$param->byRef) { + continue; + } + if ($param->variadic) { + $variadicFrom = $variadicFrom === null ? $i : min($variadicFrom, $i); + } else { + $fixed[] = $i; + } + } + + // Stub files occasionally declare a function more than once + // (per-version signatures); merge conservatively. + if (isset($this->byRef[$name])) { + $fixed = array_values(array_unique(array_merge($this->byRef[$name]['fixed'], $fixed))); + sort($fixed); + $prev = $this->byRef[$name]['variadicFrom']; + $variadicFrom = $prev === null ? $variadicFrom : ($variadicFrom === null ? $prev : min($prev, $variadicFrom)); + } + $this->byRef[$name] = ['fixed' => $fixed, 'variadicFrom' => $variadicFrom]; + + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($collector); + $traverser->traverse($stmts); + } +} diff --git a/scripts/plugin-class-classifier/src/ClassInfo.php b/scripts/plugin-class-classifier/src/ClassInfo.php new file mode 100644 index 00000000..84af72d4 --- /dev/null +++ b/scripts/plugin-class-classifier/src/ClassInfo.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\PluginClassifier; + +final class ClassInfo +{ + /** @var array<string, MethodInfo> keyed by lowercase method name */ + public array $methods = []; + + /** @var list<PropertyInfo> */ + public array $properties = []; + + /** @var list<string> FQCNs of used traits */ + public array $traitUses = []; + + /** + * Types instantiated in method bodies (`new X`). Instantiating a + * proxied service from real PHP raises the dual-instantiation question, + * so these are tracked apart from static references. + * + * @var list<string> + */ + public array $newRefs = []; + + /** + * Types referenced statically in method bodies (`X::method()`, writes + * to `X::$prop`). Satisfied by any executable presence in the child + * world, including a generated proxy stub. + * + * @var list<string> + */ + public array $staticRefs = []; + + /** + * Types referenced from method bodies in ways satisfied by a mere + * declaration: `instanceof`, `catch`, `X::class`, constant reads. + * + * @var list<string> + */ + public array $benignBodyRefs = []; + + public bool $writesOwnStaticProps = false; + + public function __construct( + public readonly string $fqcn, + /** 'class' | 'interface' | 'trait' | 'enum' */ + public readonly string $kind, + public readonly bool $abstract, + public readonly bool $final, + public readonly ?string $parent, + /** @var list<string> */ + public readonly array $interfaces, + public readonly string $file, + ) { + } + + public function isContractLike(): bool + { + return $this->kind === 'interface' || ($this->kind === 'class' && $this->abstract); + } +} diff --git a/scripts/plugin-class-classifier/src/Classifier.php b/scripts/plugin-class-classifier/src/Classifier.php new file mode 100644 index 00000000..d24a5f96 --- /dev/null +++ b/scripts/plugin-class-classifier/src/Classifier.php @@ -0,0 +1,508 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\PluginClassifier; + +final class Classifier +{ + public const CATEGORIES = [ + 'rust-proxy', 'rust-snapshot', 'contract', 'two-world', 'php-native', 'unsupported', + ]; + + private const THROWABLE_ROOTS = ['Throwable', 'Exception', 'Error']; + + public SourceParser $sources; + + public ReachabilityClosure $closure; + + public PurityAnalyzer $purity; + + public NativeFixedPoint $native; + + /** @var array<string, array<string, mixed>> FQCN => row */ + public array $rows = []; + + /** @var list<string> */ + public array $violations = []; + + /** @var array<string, bool> */ + private array $throwableCache = []; + + /** @var array<string, bool> */ + private array $valueObjectCandidates = []; + + /** @var array<string, bool> */ + private array $statelessPureCandidates = []; + + public function __construct(private readonly string $composerSrc, private readonly Lists $lists) + { + } + + public function run(): void + { + $this->sources = new SourceParser(); + $this->sources->parseTree($this->composerSrc); + $this->sources->finalize(); + + $this->closure = new ReachabilityClosure($this->sources, $this->lists); + $this->closure->run(); + + $this->purity = new PurityAnalyzer($this->sources); + $this->purity->run(); + + $this->computeLocalCandidates(); + + // The unreachable fixed point needs to know which reachable classes + // are proxied (constructing one demotes), and candidate demotion + // needs to know which unreachable classes ended up unsupported. + // Iterate the two until stable; candidate sets only shrink, so this + // terminates. + do { + $reachableCategories = []; + foreach ($this->closure->reachable as $fqcn => $bits) { + $info = $this->sources->classes[$fqcn] ?? null; + if ($info !== null) { + $reachableCategories[$fqcn] = $this->categoryForReachable($fqcn, $info); + } + } + + $this->native = new NativeFixedPoint($this->sources, $this->closure, $this->lists, $reachableCategories); + $this->native->run(); + } while ($this->demoteCandidates()); + + foreach ($this->sources->classes as $fqcn => $info) { + $this->rows[$fqcn] = $this->classify($fqcn, $info); + } + + $this->applyOverrides(); + $this->collectViolations(); + ksort($this->rows); + } + + /** @return array<string, mixed> */ + private function classify(string $fqcn, ClassInfo $info): array + { + $reachableBits = $this->closure->reachable[$fqcn] ?? 0; + $row = [ + 'fqcn' => $fqcn, + 'kind' => $info->kind, + 'reachable' => $reachableBits !== 0, + 'direction' => $this->directionLabel($reachableBits), + 'category' => null, + 'reasons' => [], + 'attributes' => [], + ]; + + if ($info->writesOwnStaticProps) { + $row['attributes']['mutable-static'] = + $this->lists->staticDispositions[$fqcn] ?? 'needs-review'; + } + if ($this->isThrowable($fqcn)) { + $row['attributes']['throwable'] = true; + } + + if ($this->lists->isTwoWorld($fqcn)) { + $row['category'] = 'two-world'; + $row['reasons'][] = 'two-world.list'; + + return $row; + } + + if ($reachableBits === 0) { + $row['category'] = $this->native->categories[$fqcn] ?? 'unsupported'; + $row['reasons'] = array_merge( + $row['reasons'], + $this->native->reasons[$fqcn] ?? ['not plugin-reachable; every reference resolves to executable code in the child'], + ); + + return $row; + } + + $category = $this->categoryForReachable($fqcn, $info); + $row['category'] = $category; + $row['reasons'][] = match ($category) { + 'php-native' => $this->isThrowable($fqcn) + ? 'exception class: real definition in the child process, crossed by value' + : (($this->statelessPureCandidates[$fqcn] ?? false) + ? 'stateless pure class: no instance state, all methods pure, real code answers identically in both worlds' + : 'constants-only class: pure definitions, no state to share'), + 'contract' => 'interface/abstract type: declaration stub, artifacts depend on direction', + 'rust-snapshot' => 'immutable value object: no state referencing living services, all methods pure', + 'rust-proxy' => 'reachable concrete class holding or reaching shared state', + }; + if ($category === 'rust-proxy') { + $row['attributes']['plugin-constructible'] = $this->hasPublicConstructor($info); + } + if (in_array($category, ['rust-proxy', 'rust-snapshot'], true)) { + // Stub static properties cannot be intercepted in PHP (there is + // no __getStatic); public ones need an explicit decision. + $statics = []; + foreach ($info->properties as $prop) { + if ($prop->static && $prop->visibility === 'public') { + $statics[] = $prop->name; + } + } + if ($statics !== []) { + sort($statics); + $row['attributes']['public-static-properties'] = $statics; + } + } + if (in_array($category, ['rust-proxy', 'rust-snapshot', 'contract'], true)) { + $row['methods'] = $this->methodRows($fqcn, $info); + } + + return $row; + } + + private function categoryForReachable(string $fqcn, ClassInfo $info): string + { + if ($this->isThrowable($fqcn)) { + return 'php-native'; + } + // Contract wins over constants-only: a marker interface such as + // Capability is still implemented by plugins and needs direction + // attributes, not just its constant-free declaration. + if ($info->isContractLike() || $info->kind === 'trait') { + return 'contract'; + } + if ($this->isConstantsOnly($info)) { + return 'php-native'; + } + if ($this->statelessPureCandidates[$fqcn] ?? false) { + return 'php-native'; + } + if ($this->valueObjectCandidates[$fqcn] ?? false) { + return 'rust-snapshot'; + } + + return 'rust-proxy'; + } + + /** @return list<array<string, mixed>> */ + private function methodRows(string $fqcn, ClassInfo $info): array + { + $rows = []; + foreach ($info->methods as $lname => $method) { + if ($method->visibility === 'private') { + continue; + } + $callableParams = []; + foreach ($method->params as $i => $param) { + if ($param->callable) { + $callableParams[] = $i; + } + } + $rows[] = [ + 'name' => $method->name, + 'visibility' => $method->visibility, + 'static' => $method->static, + 'purity' => $this->purity->verdicts["$fqcn::$lname"] ?? 'n/a', + 'byRefParams' => $method->byRefParamPositions(), + 'callableParams' => $callableParams, + ]; + } + usort($rows, static fn (array $a, array $b) => strcmp($a['name'], $b['name'])); + + return $rows; + } + + private function directionLabel(int $bits): ?string + { + return match ($bits) { + 0 => null, + ReachabilityClosure::PROVIDED => 'provided', + ReachabilityClosure::CONSUMED => 'consumed', + default => 'both', + }; + } + + private function isThrowable(string $fqcn): bool + { + if (isset($this->throwableCache[$fqcn])) { + return $this->throwableCache[$fqcn]; + } + // Pre-set to break inheritance cycles (malformed input). + $this->throwableCache[$fqcn] = false; + + $info = $this->sources->classes[$fqcn] ?? null; + if ($info === null) { + $isGlobal = !str_contains($fqcn, '\\'); + $result = $isGlobal && ( + in_array($fqcn, self::THROWABLE_ROOTS, true) + || str_ends_with($fqcn, 'Exception') + || str_ends_with($fqcn, 'Error') + ); + + return $this->throwableCache[$fqcn] = $result; + } + + foreach (array_merge($info->parent !== null ? [$info->parent] : [], $info->interfaces) as $ancestor) { + if ($this->isThrowable($ancestor)) { + return $this->throwableCache[$fqcn] = true; + } + } + + return false; + } + + private function hasPublicConstructor(ClassInfo $info): bool + { + if ($info->abstract) { + return false; + } + $current = $info; + while (true) { + $ctor = $current->methods['__construct'] ?? null; + if ($ctor !== null) { + return $ctor->visibility === 'public'; + } + if ($current->parent === null || !isset($this->sources->classes[$current->parent])) { + // No declared constructor anywhere visible: implicit public. + return true; + } + $current = $this->sources->classes[$current->parent]; + } + } + + private function computeLocalCandidates(): void + { + // Grow-only fixed point over two candidate kinds. Both require + // every instance method in the hierarchy to be pure and the body + // references to be locally satisfiable; they differ on state: + // + // - value objects carry copyable state (>= 1 instance property) + // none of which references a living service -> rust-snapshot + // - stateless pure classes carry no instance state at all; + // a snapshot has nothing to copy, and the real code answers + // identically in both worlds -> php-native + do { + $changed = false; + foreach ($this->closure->reachable as $fqcn => $bits) { + if (($this->valueObjectCandidates[$fqcn] ?? false) || ($this->statelessPureCandidates[$fqcn] ?? false)) { + continue; + } + $info = $this->sources->classes[$fqcn] ?? null; + if ($info === null || $info->isContractLike() || $info->kind !== 'class') { + continue; + } + if ($this->lists->isTwoWorld($fqcn) || $this->isThrowable($fqcn) || $this->isConstantsOnly($info)) { + continue; + } + if (!$this->allInstanceMethodsPure($fqcn) || !$this->bodyRefsAreLocal($info)) { + continue; + } + if ($this->hasInstanceProperties($info)) { + if ($this->stateIsValueOnly($info)) { + $this->valueObjectCandidates[$fqcn] = true; + $changed = true; + } + } else { + $this->statelessPureCandidates[$fqcn] = true; + $changed = true; + } + } + } while ($changed); + } + + private function hasInstanceProperties(ClassInfo $info): bool + { + foreach ($this->hierarchyOf($info) as $level) { + foreach ($level->properties as $prop) { + if (!$prop->static) { + return true; + } + } + } + + return false; + } + + /** + * Snapshot classes ship their real method bodies; stateless pure + * classes run as real code. Either way, what the bodies construct must + * be locally constructible: another candidate, an exception, real + * vendor/two-world/builtin code — not a proxied service. + */ + private function bodyRefsAreLocal(ClassInfo $info): bool + { + foreach (array_unique($info->newRefs) as $ref) { + if ($ref === $info->fqcn || $this->isCandidate($ref) || $this->isThrowable($ref)) { + continue; + } + if (isset($this->closure->reachable[$ref])) { + $target = $this->sources->classes[$ref] ?? null; + if ($target !== null && ($this->isConstantsOnly($target) || $this->lists->isTwoWorld($ref))) { + continue; + } + + // Constructs what will be a proxied service. + return false; + } + // Unreachable, vendor, or builtin targets are checked again + // once the unreachable fixed point has run (demoteCandidates). + } + + return true; + } + + private function isCandidate(string $fqcn): bool + { + return ($this->valueObjectCandidates[$fqcn] ?? false) || ($this->statelessPureCandidates[$fqcn] ?? false); + } + + /** + * Re-check candidates against the unreachable fixed point's outcome: + * a candidate whose bodies construct or statically reference an + * unsupported class cannot run locally after all. Returns true when + * anything was demoted (the caller then reruns the fixed point). + */ + private function demoteCandidates(): bool + { + $demoted = false; + do { + $changed = false; + foreach (array_merge(array_keys($this->valueObjectCandidates), array_keys($this->statelessPureCandidates)) as $fqcn) { + if (!$this->isCandidate($fqcn)) { + continue; + } + $info = $this->sources->classes[$fqcn]; + if ($this->bodyRefsAreLocal($info) && !$this->refsUnsupported($info)) { + continue; + } + unset($this->valueObjectCandidates[$fqcn], $this->statelessPureCandidates[$fqcn]); + $changed = true; + $demoted = true; + } + } while ($changed); + + return $demoted; + } + + private function refsUnsupported(ClassInfo $info): bool + { + foreach (array_unique(array_merge($info->newRefs, $info->staticRefs)) as $ref) { + if (($this->native->categories[$ref] ?? null) === 'unsupported') { + return true; + } + } + + return false; + } + + private function stateIsValueOnly(ClassInfo $info): bool + { + $types = []; + // State includes inherited properties: a VcsDownloader subclass + // carries its parent's ProcessExecutor even with no own fields. + foreach ($this->hierarchyOf($info) as $level) { + foreach ($level->properties as $prop) { + $types = array_merge($types, $prop->classTypes); + if ($prop->expandable || $prop->classTypes === []) { + $types = array_merge($types, $prop->docblockTypes); + } + } + } + $ctor = $info->methods['__construct'] ?? null; + if ($ctor !== null) { + foreach ($ctor->params as $param) { + $types = array_merge($types, $param->classTypes); + if ($param->expandable || $param->classTypes === []) { + $types = array_merge($types, $param->docblockTypes); + } + } + } + + foreach (array_unique($types) as $type) { + if ($type === $info->fqcn) { + continue; + } + if (!isset($this->closure->reachable[$type]) && !isset($this->sources->classes[$type])) { + // Vendor or builtin: value-safe only if php-native. + $vendor = VendorPackages::lookup($type); + if ($vendor !== null && $vendor['category'] !== 'php-native') { + return false; + } + continue; + } + if ($this->isThrowable($type)) { + continue; + } + if (!$this->isCandidate($type)) { + return false; + } + } + + return true; + } + + /** @return list<ClassInfo> the class and its parents visible in the sources */ + private function hierarchyOf(ClassInfo $info): array + { + $levels = []; + $seen = []; + $current = $info; + while (true) { + if (isset($seen[$current->fqcn])) { + break; + } + $seen[$current->fqcn] = true; + $levels[] = $current; + if ($current->parent === null || !isset($this->sources->classes[$current->parent])) { + break; + } + $current = $this->sources->classes[$current->parent]; + } + + return $levels; + } + + private function allInstanceMethodsPure(string $fqcn): bool + { + $info = $this->sources->classes[$fqcn]; + foreach ($this->hierarchyOf($info) as $level) { + foreach ($level->methods as $lname => $method) { + if ($method->static || $method->visibility === 'private' || $lname === '__construct') { + continue; + } + $verdict = $this->purity->verdicts["{$level->fqcn}::$lname"] ?? 'mutator'; + if ($verdict === 'mutator') { + return false; + } + } + } + + return true; + } + + private function isConstantsOnly(ClassInfo $info): bool + { + return $info->methods === [] && $info->properties === [] + && $info->parent === null && $info->interfaces === []; + } + + private function applyOverrides(): void + { + foreach ($this->lists->overrides as $fqcn => $override) { + if (!isset($this->rows[$fqcn])) { + $this->violations[] = "overrides.list: unknown class $fqcn"; + continue; + } + $this->rows[$fqcn]['computedCategory'] = $this->rows[$fqcn]['category']; + $this->rows[$fqcn]['category'] = $override['category']; + $this->rows[$fqcn]['reasons'][] = 'override: ' . $override['reason']; + } + } + + private function collectViolations(): void + { + foreach ($this->rows as $fqcn => $row) { + if (($row['attributes']['mutable-static'] ?? null) === 'needs-review') { + $this->violations[] = "$fqcn writes static properties but has no disposition in static-state.list"; + } + } + foreach ($this->closure->unknownReachable as $fqcn => $bits) { + $this->violations[] = "reachable type $fqcn is neither a composer class, a known vendor package, nor a PHP builtin"; + } + } +} diff --git a/scripts/plugin-class-classifier/src/DocblockTypeExtractor.php b/scripts/plugin-class-classifier/src/DocblockTypeExtractor.php new file mode 100644 index 00000000..60fac191 --- /dev/null +++ b/scripts/plugin-class-classifier/src/DocblockTypeExtractor.php @@ -0,0 +1,197 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\PluginClassifier; + +/** + * Extracts class-like type names from phpdoc @param / @return / @var / + * @throws tags and resolves them against the file's namespace and use map. + * + * This is deliberately not a full phpdoc type grammar. Composer's docblocks + * are PHPStan-checked, so tokenizing the type expression and keeping the + * class-like tokens is sufficient. Tokens are considered class-like only + * when they contain a namespace separator or start with an uppercase letter; + * this filters array-shape keys and scalar keywords. Resolved names that do + * not exist in the symbol table or a known vendor namespace are reported by + * the caller rather than silently dropped. + */ +final class DocblockTypeExtractor +{ + private const KEYWORDS = [ + 'int', 'integer', 'float', 'double', 'string', 'bool', 'boolean', + 'true', 'false', 'null', 'void', 'never', 'mixed', 'scalar', 'array', + 'iterable', 'object', 'callable', 'resource', 'self', 'static', + 'parent', 'this', 'list', 'non-empty-list', 'non-empty-array', + 'non-empty-string', 'class-string', 'callable-string', + 'numeric-string', 'lowercase-string', 'literal-string', 'key-of', + 'value-of', 'array-key', 'positive-int', 'negative-int', + 'non-negative-int', 'non-positive-int', 'int-mask-of', 'Closure', + 'Generator', 'Traversable', 'Iterator', 'IteratorAggregate', + 'ArrayAccess', 'Countable', 'Stringable', 'JsonSerializable', + 'Throwable', 'Exception', 'SplFileInfo', 'ArrayObject', + ]; + + /** + * Names declared by @template / @phpstan-type / @phpstan-import-type + * anywhere in the tree; they look like class names inside type + * expressions but are not classes. Filled by a pre-scan. + * + * @var array<string, true> + */ + public array $aliasNames = []; + + public function collectAliases(string $code): void + { + if (preg_match_all( + '/@(?:phpstan-|psalm-)?(?:template(?:-covariant|-contravariant)?|type|import-type)\s+([A-Za-z_][A-Za-z0-9_]*)/', + $code, + $m, + ) > 0) { + foreach ($m[1] as $name) { + $this->aliasNames[$name] = true; + } + } + } + + /** + * @param array<string, string> $useMap lowercase alias => FQCN + * @return array{params: array<string, list<string>>, return: list<string>, var: list<string>, throws: list<string>} + */ + public function extract(?string $docblock, string $namespace, array $useMap): array + { + $result = ['params' => [], 'return' => [], 'var' => [], 'throws' => []]; + if ($docblock === null) { + return $result; + } + + $pattern = '/@(param|return|var|throws|phpstan-param|phpstan-return|phpstan-var)[ \t]+(.+)$/m'; + if (preg_match_all($pattern, $docblock, $matches, PREG_SET_ORDER) === false) { + return $result; + } + + foreach ($matches as $m) { + $tag = str_replace('phpstan-', '', $m[1]); + $rest = rtrim($m[2]); + // The type expression ends at the first whitespace at bracket + // depth zero; spaces inside array{...} / array<...> shapes are + // part of the type. Whatever follows is the variable name + // (for @param) and/or a free-text description. + $typeExpr = $this->cutAtToplevelSpace($rest); + $after = substr($rest, strlen($typeExpr)); + $paramName = null; + if (preg_match('/^\s*\$(\w+)/', $after, $nm) === 1) { + $paramName = $nm[1]; + } + + $types = $this->classLikeTokens($typeExpr, $namespace, $useMap); + if ($types === []) { + continue; + } + + switch ($tag) { + case 'param': + if ($paramName !== null) { + $result['params'][$paramName] = array_values(array_unique(array_merge( + $result['params'][$paramName] ?? [], + $types, + ))); + } + break; + case 'return': + $result['return'] = array_values(array_unique(array_merge($result['return'], $types))); + break; + case 'var': + $result['var'] = array_values(array_unique(array_merge($result['var'], $types))); + break; + case 'throws': + $result['throws'] = array_values(array_unique(array_merge($result['throws'], $types))); + break; + } + } + + return $result; + } + + private function cutAtToplevelSpace(string $expr): string + { + $depth = 0; + $len = strlen($expr); + for ($i = 0; $i < $len; $i++) { + $c = $expr[$i]; + if ($c === '<' || $c === '{' || $c === '(' || $c === '[') { + $depth++; + } elseif ($c === '>' || $c === '}' || $c === ')' || $c === ']') { + $depth--; + } elseif (($c === ' ' || $c === "\t") && $depth === 0) { + return substr($expr, 0, $i); + } + } + + return $expr; + } + + /** + * @param array<string, string> $useMap + * @return list<string> + */ + private function classLikeTokens(string $typeExpr, string $namespace, array $useMap): array + { + // Constant references (self::STABILITY_*, BasePackage::STABILITIES, + // PATHINFO_EXTENSION|...) are not class names: drop everything after + // `::`, then drop all-caps tokens. + $typeExpr = preg_replace('/::[A-Za-z0-9_*]*/', '', $typeExpr) ?? $typeExpr; + + if (preg_match_all('/\\\\?[A-Za-z_][A-Za-z0-9_]*(?:\\\\[A-Za-z_][A-Za-z0-9_]*)*/', $typeExpr, $m) === false) { + return []; + } + + $out = []; + foreach ($m[0] as $token) { + $isQualified = str_contains($token, '\\'); + if (!$isQualified) { + if (in_array($token, self::KEYWORDS, true) || in_array(strtolower($token), self::KEYWORDS, true)) { + continue; + } + // Array-shape keys and phpdoc keywords are lowercase; + // Composer class names are StudlyCaps. + if (!ctype_upper($token[0])) { + continue; + } + // All-caps tokens are constants, not classes. + if (preg_match('/[a-z]/', $token) !== 1) { + continue; + } + // Generic parameters and phpstan type aliases. + if (isset($this->aliasNames[$token])) { + continue; + } + } + $out[] = $this->resolve($token, $namespace, $useMap); + } + + return array_values(array_unique($out)); + } + + /** @param array<string, string> $useMap */ + private function resolve(string $name, string $namespace, array $useMap): string + { + if (str_starts_with($name, '\\')) { + return ltrim($name, '\\'); + } + + $parts = explode('\\', $name); + $firstLower = strtolower($parts[0]); + if (isset($useMap[$firstLower])) { + $parts[0] = $useMap[$firstLower]; + + return implode('\\', $parts); + } + + if ($namespace === '') { + return $name; + } + + return $namespace . '\\' . $name; + } +} diff --git a/scripts/plugin-class-classifier/src/Lists.php b/scripts/plugin-class-classifier/src/Lists.php new file mode 100644 index 00000000..7e544a88 --- /dev/null +++ b/scripts/plugin-class-classifier/src/Lists.php @@ -0,0 +1,84 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\PluginClassifier; + +/** + * The three versioned exception lists. Formats are line-based; `#` starts a + * comment, blank lines are skipped. + * + * two-world.list one namespace prefix (or exact FQCN) per line + * static-state.list "FQCN memo-cache|seed-once|needs-sync" per line + * overrides.list "FQCN category reason..." per line + */ +final class Lists +{ + /** @var list<string> */ + public array $twoWorldPrefixes = []; + + /** @var array<string, string> FQCN => 'memo-cache' | 'seed-once' | 'needs-sync' */ + public array $staticDispositions = []; + + /** @var array<string, array{category: string, reason: string}> */ + public array $overrides = []; + + public static function load(string $dir): self + { + $lists = new self(); + + foreach (self::lines("$dir/two-world.list") as $line) { + $lists->twoWorldPrefixes[] = $line; + } + + foreach (self::lines("$dir/static-state.list") as $line) { + $parts = preg_split('/\s+/', $line, 2); + if (count($parts) !== 2 || !in_array($parts[1], ['memo-cache', 'seed-once', 'needs-sync'], true)) { + throw new \RuntimeException("static-state.list: malformed line: $line"); + } + $lists->staticDispositions[$parts[0]] = $parts[1]; + } + + foreach (self::lines("$dir/overrides.list") as $line) { + $parts = preg_split('/\s+/', $line, 3); + if (count($parts) !== 3) { + throw new \RuntimeException("overrides.list: malformed line (need FQCN, category, reason): $line"); + } + if (!in_array($parts[1], Classifier::CATEGORIES, true)) { + throw new \RuntimeException("overrides.list: unknown category {$parts[1]}: $line"); + } + $lists->overrides[$parts[0]] = ['category' => $parts[1], 'reason' => $parts[2]]; + } + + return $lists; + } + + public function isTwoWorld(string $fqcn): bool + { + foreach ($this->twoWorldPrefixes as $prefix) { + if ($fqcn === $prefix || str_starts_with($fqcn, rtrim($prefix, '\\') . '\\')) { + return true; + } + } + + return false; + } + + /** @return list<string> */ + private static function lines(string $path): array + { + if (!is_file($path)) { + throw new \RuntimeException("missing exception list: $path"); + } + $out = []; + foreach (file($path, FILE_IGNORE_NEW_LINES) as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + $out[] = $line; + } + + return $out; + } +} diff --git a/scripts/plugin-class-classifier/src/MethodInfo.php b/scripts/plugin-class-classifier/src/MethodInfo.php new file mode 100644 index 00000000..a03c2126 --- /dev/null +++ b/scripts/plugin-class-classifier/src/MethodInfo.php @@ -0,0 +1,71 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\PluginClassifier; + +final class MethodInfo +{ + /** Filled by BodyAnalyzer. */ + public bool $mutatesThisDirectly = false; + + /** + * Calls to methods of the same object ($this->m(), self::m(), + * static::m(), parent::m()) with a literal name. Purity propagates + * through these; thisArgs lists 0-based argument positions holding + * $this-rooted expressions (checked against by-ref parameters). + * + * @var list<array{name: string, thisArgs: list<int>}> + */ + public array $selfCalls = []; + + /** + * Calls on receivers whose class could be resolved statically (typed + * property or typed parameter) that pass $this-rooted expressions. + * Checked against the callee's by-ref parameter positions once the whole + * symbol table is available. + * + * @var list<array{class: string, method: string, thisArgs: list<int>}> + */ + public array $externalCalls = []; + + /** + * True when the body contains a call whose signature cannot be resolved + * statically (dynamic method name, variable function, call_user_func, + * closure) with a $this-rooted expression (or $this itself) as argument, + * or passes a $this-rooted expression into a by-ref parameter position. + */ + public bool $thisEscapesUnresolved = false; + + public function __construct( + public readonly string $name, + /** 'public' | 'protected' | 'private' */ + public readonly string $visibility, + public readonly bool $static, + public readonly bool $abstract, + /** @var list<ParamInfo> */ + public readonly array $params, + /** Class-like FQCNs in the native return type. @var list<string> */ + public readonly array $returnClassTypes, + /** Native return type is array/iterable/mixed/object or absent. */ + public readonly bool $returnExpandable, + /** Class-like FQCNs from the `@return` docblock. @var list<string> */ + public readonly array $docblockReturnTypes, + /** Class-like FQCNs from `@throws` docblocks. @var list<string> */ + public readonly array $docblockThrowsTypes, + ) { + } + + /** @return list<int> positions of by-ref parameters */ + public function byRefParamPositions(): array + { + $positions = []; + foreach ($this->params as $i => $param) { + if ($param->byRef) { + $positions[] = $i; + } + } + + return $positions; + } +} diff --git a/scripts/plugin-class-classifier/src/NativeFixedPoint.php b/scripts/plugin-class-classifier/src/NativeFixedPoint.php new file mode 100644 index 00000000..7b0ecc65 --- /dev/null +++ b/scripts/plugin-class-classifier/src/NativeFixedPoint.php @@ -0,0 +1,153 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\PluginClassifier; + +/** + * Decides php-native vs unsupported for classes the closure never reached, + * per "Mechanical rules" step 6: leaf-first fixed point over body + * references. + * + * What a reference may legally target from real PHP running in the child + * process: + * + * - `new X` works when X exists there as executable-or-local code: a + * php-native class, a two-world class (real console code), a vendor + * class (real package code), a rust-snapshot value object (values are + * built locally), or a PHP builtin. Constructing a rust-proxy service + * is the unresolved dual-instantiation case and demotes. + * - `X::method()` / `X::$prop` additionally works when X is any stubbed + * reachable class (proxy stubs carry static methods as RPC), so only + * unsupported peers and unknown types demote. + */ +final class NativeFixedPoint +{ + /** @var array<string, string> unreachable FQCN => 'php-native' | 'unsupported' */ + public array $categories = []; + + /** @var array<string, list<string>> FQCN => reasons for demotion */ + public array $reasons = []; + + /** @var list<string> classes writing static props with no filed disposition */ + public array $unfiledStaticState = []; + + public function __construct( + private readonly SourceParser $sources, + private readonly ReachabilityClosure $closure, + private readonly Lists $lists, + /** @var array<string, string> reachable FQCN => preliminary category */ + private readonly array $reachableCategories, + ) { + } + + public function run(): void + { + $universe = []; + foreach ($this->sources->classes as $fqcn => $info) { + if (isset($this->closure->reachable[$fqcn]) || $this->lists->isTwoWorld($fqcn)) { + continue; + } + if (isset($this->lists->overrides[$fqcn])) { + // The override fixes this class's category; it does not + // participate in the fixed point. + continue; + } + $universe[$fqcn] = $info; + } + + foreach ($universe as $fqcn => $info) { + $this->categories[$fqcn] = 'php-native'; + if ($info->writesOwnStaticProps) { + $disposition = $this->lists->staticDispositions[$fqcn] ?? null; + if ($disposition === null) { + $this->unfiledStaticState[] = $fqcn; + $this->demote($fqcn, 'mutable static state with no filed disposition'); + } elseif ($disposition === 'needs-sync') { + $this->demote($fqcn, 'mutable static state shared with the Rust side (needs-sync)'); + } + } + } + + do { + $changed = false; + foreach ($universe as $fqcn => $info) { + if ($this->categories[$fqcn] === 'unsupported') { + continue; + } + $bad = $this->firstBadRef($info); + if ($bad !== null) { + $this->demote($fqcn, $bad); + $changed = true; + } + } + } while ($changed); + } + + private function firstBadRef(ClassInfo $info): ?string + { + foreach (array_unique($info->newRefs) as $ref) { + $problem = $this->checkTarget($info, $ref, true); + if ($problem !== null) { + return $problem; + } + } + foreach (array_unique($info->staticRefs) as $ref) { + $problem = $this->checkTarget($info, $ref, false); + if ($problem !== null) { + return $problem; + } + } + + return null; + } + + private function checkTarget(ClassInfo $info, string $ref, bool $isNew): ?string + { + if ($ref === $info->fqcn) { + return null; + } + + $category = $this->lists->overrides[$ref]['category'] + ?? $this->reachableCategories[$ref] + ?? null; + if ($category !== null) { + if ($isNew && $category === 'rust-proxy') { + return "constructs proxied service $ref (dual instantiation unresolved)"; + } + + return null; + } + + if ($this->lists->isTwoWorld($ref)) { + return null; + } + + if (isset($this->sources->classes[$ref])) { + if (($this->categories[$ref] ?? 'unsupported') === 'unsupported') { + return ($isNew ? 'constructs' : 'statically references') . " unsupported type $ref"; + } + + return null; + } + + if (VendorPackages::lookup($ref) !== null) { + // Vendor packages ship as real PHP in the child process + // regardless of their bridging category. + return null; + } + + if (!str_contains($ref, '\\')) { + // Global namespace: PHP builtin. + return null; + } + + return ($isNew ? 'constructs' : 'statically references') . " unknown type $ref"; + } + + private function demote(string $fqcn, string $reason): void + { + $this->categories[$fqcn] = 'unsupported'; + $this->reasons[$fqcn][] = $reason; + } +} diff --git a/scripts/plugin-class-classifier/src/ParamInfo.php b/scripts/plugin-class-classifier/src/ParamInfo.php new file mode 100644 index 00000000..3caaa07f --- /dev/null +++ b/scripts/plugin-class-classifier/src/ParamInfo.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\PluginClassifier; + +final class ParamInfo +{ + public function __construct( + public readonly string $name, + public readonly bool $byRef, + public readonly bool $variadic, + /** Class-like FQCNs in the native type. @var list<string> */ + public readonly array $classTypes, + /** Native type is array/iterable/mixed/object or absent. */ + public readonly bool $expandable, + /** Class-like FQCNs from the `@param` docblock. @var list<string> */ + public readonly array $docblockTypes, + /** Native type mentions callable or \Closure: the value is a + * callback that needs a handle across the boundary. */ + public readonly bool $callable = false, + ) { + } +} diff --git a/scripts/plugin-class-classifier/src/PropertyInfo.php b/scripts/plugin-class-classifier/src/PropertyInfo.php new file mode 100644 index 00000000..a15ddcf9 --- /dev/null +++ b/scripts/plugin-class-classifier/src/PropertyInfo.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\PluginClassifier; + +final class PropertyInfo +{ + public function __construct( + public readonly string $name, + /** 'public' | 'protected' | 'private' */ + public readonly string $visibility, + public readonly bool $static, + /** Class-like FQCNs in the native type. @var list<string> */ + public readonly array $classTypes, + /** Native type is array/iterable/mixed/object or absent. */ + public readonly bool $expandable, + /** Class-like FQCNs from the `@var` docblock. @var list<string> */ + public readonly array $docblockTypes, + ) { + } +} diff --git a/scripts/plugin-class-classifier/src/PurityAnalyzer.php b/scripts/plugin-class-classifier/src/PurityAnalyzer.php new file mode 100644 index 00000000..84e355c5 --- /dev/null +++ b/scripts/plugin-class-classifier/src/PurityAnalyzer.php @@ -0,0 +1,133 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\PluginClassifier; + +/** + * Per-method pure/mutator decision (see "pure/mutator" in + * docs/dev/plugin-class-classification.md): a method is a mutator iff it + * writes $this directly, passes a $this-rooted expression into a by-ref + * parameter, contains a statically unresolvable call that a $this-rooted + * expression escapes into, or (transitively) calls a mutator method on the + * same object. + */ +final class PurityAnalyzer +{ + /** @var array<string, string> "Fqcn::lowername" => 'pure' | 'mutator' | 'n/a' */ + public array $verdicts = []; + + public function __construct(private readonly SourceParser $sources) + { + } + + public function run(): void + { + // Seed with local evidence. + foreach ($this->sources->classes as $fqcn => $info) { + foreach ($info->methods as $lname => $method) { + $key = "$fqcn::$lname"; + if ($method->static || $method->abstract) { + $this->verdicts[$key] = 'n/a'; + continue; + } + $this->verdicts[$key] = $this->locallyMutates($fqcn, $method) ? 'mutator' : 'pure'; + } + } + + // Propagate through same-object calls until stable. + do { + $changed = false; + foreach ($this->sources->classes as $fqcn => $info) { + foreach ($info->methods as $lname => $method) { + $key = "$fqcn::$lname"; + if ($this->verdicts[$key] !== 'pure') { + continue; + } + foreach ($method->selfCalls as $call) { + $callee = $this->resolveMethod($fqcn, $call['name']); + if ($callee === null) { + // Magic __call or a method we cannot see. + $this->verdicts[$key] = 'mutator'; + $changed = true; + break; + } + [$calleeClass, $calleeMethod] = $callee; + $calleeVerdict = $this->verdicts["$calleeClass::" . strtolower($calleeMethod->name)] ?? 'mutator'; + if ($calleeVerdict === 'mutator') { + $this->verdicts[$key] = 'mutator'; + $changed = true; + break; + } + if (array_intersect($call['thisArgs'], $calleeMethod->byRefParamPositions()) !== []) { + $this->verdicts[$key] = 'mutator'; + $changed = true; + break; + } + } + } + } + } while ($changed); + } + + private function locallyMutates(string $fqcn, MethodInfo $method): bool + { + if ($method->mutatesThisDirectly || $method->thisEscapesUnresolved) { + return true; + } + + foreach ($method->externalCalls as $call) { + $positions = $this->byRefPositionsOf($call['class'], $call['method']); + if ($positions === null) { + // Callee signature unknown: conservative. + return true; + } + if (array_intersect($call['thisArgs'], $positions) !== []) { + return true; + } + } + + return false; + } + + /** @return list<int>|null null when the signature cannot be resolved */ + private function byRefPositionsOf(string $class, string $lowerMethod): ?array + { + $resolved = $this->resolveMethod($class, $lowerMethod); + if ($resolved !== null) { + return $resolved[1]->byRefParamPositions(); + } + + $vendor = VendorPackages::BY_REF[$class] ?? null; + if ($vendor !== null) { + return $vendor[$lowerMethod] ?? []; + } + if (VendorPackages::lookup($class) !== null) { + // Vendor packages other than the ones tabled expose no by-ref + // parameters in APIs composer calls. + return []; + } + + return null; + } + + /** @return array{string, MethodInfo}|null resolved (declaring class, method) */ + private function resolveMethod(string $class, string $lowerMethod): ?array + { + $seen = []; + $current = $class; + while ($current !== null && !isset($seen[$current])) { + $seen[$current] = true; + $info = $this->sources->classes[$current] ?? null; + if ($info === null) { + return null; + } + if (isset($info->methods[$lowerMethod])) { + return [$current, $info->methods[$lowerMethod]]; + } + $current = $info->parent; + } + + return null; + } +} diff --git a/scripts/plugin-class-classifier/src/ReachabilityClosure.php b/scripts/plugin-class-classifier/src/ReachabilityClosure.php new file mode 100644 index 00000000..39214a4e --- /dev/null +++ b/scripts/plugin-class-classifier/src/ReachabilityClosure.php @@ -0,0 +1,221 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\PluginClassifier; + +/** + * Computes the plugin-reachable type set with direction marks, per the + * "Mechanical rules" steps 2-4 in docs/dev/plugin-class-classification.md. + */ +final class ReachabilityClosure +{ + public const PROVIDED = 1; + public const CONSUMED = 2; + + /** @var array<string, int> composer-src FQCN => direction bits */ + public array $reachable = []; + + /** @var array<string, int> vendor FQCN => direction bits */ + public array $vendorReachable = []; + + /** @var array<string, int> global-namespace (builtin) name => direction bits */ + public array $builtinReachable = []; + + /** @var array<string, int> unknown FQCN => direction bits (reported) */ + public array $unknownReachable = []; + + /** @var array<string, list<string>> FQCN => transitive subtypes */ + private array $descendants = []; + + /** @var list<array{string, int}> */ + private array $worklist = []; + + public function __construct( + private readonly SourceParser $sources, + private readonly Lists $lists, + ) { + $this->buildDescendants(); + } + + public function run(): void + { + foreach ($this->sources->classes as $fqcn => $info) { + if (str_starts_with($fqcn, 'Composer\\Plugin\\')) { + $this->add($fqcn, $info->kind === 'interface' ? self::CONSUMED : self::PROVIDED); + } + } + $this->add('Composer\\EventDispatcher\\EventSubscriberInterface', self::CONSUMED); + $this->add('Composer\\EventDispatcher\\Event', self::PROVIDED); + foreach ($this->descendants['Composer\\EventDispatcher\\Event'] ?? [] as $sub) { + $this->add($sub, self::PROVIDED); + } + + while ($this->worklist !== []) { + [$fqcn, $bits] = array_pop($this->worklist); + $this->expand($fqcn, $bits); + } + } + + private function add(string $fqcn, int $bits): void + { + if ($fqcn === '') { + return; + } + + if (isset($this->sources->classes[$fqcn])) { + $current = $this->reachable[$fqcn] ?? 0; + $new = $current | $bits; + if ($new !== $current) { + $this->reachable[$fqcn] = $new; + $this->worklist[] = [$fqcn, $new & ~$current]; + } + + return; + } + + if (VendorPackages::lookup($fqcn) !== null) { + $this->vendorReachable[$fqcn] = ($this->vendorReachable[$fqcn] ?? 0) | $bits; + + return; + } + + if (!str_contains($fqcn, '\\')) { + $this->builtinReachable[$fqcn] = ($this->builtinReachable[$fqcn] ?? 0) | $bits; + + return; + } + + $this->unknownReachable[$fqcn] = ($this->unknownReachable[$fqcn] ?? 0) | $bits; + } + + /** Expand newly gained direction bits of a composer-src type. */ + private function expand(string $fqcn, int $newBits): void + { + // Two-world classes do not extend the shared boundary: the child + // process carries their real implementation wholesale, so their + // members are world-2-local, not graph seams. + if ($this->lists->isTwoWorld($fqcn)) { + return; + } + + $info = $this->sources->classes[$fqcn]; + + // Hierarchy carries the same direction both ways: ancestors so that + // instanceof works on the stub side, descendants because any + // concrete subtype can be the runtime instance behind the type. + if ($info->parent !== null) { + $this->add($info->parent, $newBits); + } + foreach ($info->interfaces as $iface) { + $this->add($iface, $newBits); + } + foreach ($this->descendants[$fqcn] ?? [] as $sub) { + $this->add($sub, $newBits); + } + + // The inverted expansion (params->provided, returns->consumed) + // models Composer calling a plugin's implementation, so it only + // applies to plugin-implementable types. A concrete class that + // gained a consumed mark (a plugin can construct and pass one in) + // still executes Composer's own method bodies. + $expandBits = $info->isContractLike() || $info->kind === 'trait' + ? $newBits + : ($newBits !== 0 ? self::PROVIDED : 0); + + foreach ($info->methods as $method) { + if ($method->visibility === 'private') { + continue; + } + $this->expandMethod($method, $expandBits); + } + + foreach ($info->properties as $prop) { + if ($prop->visibility === 'private') { + continue; + } + foreach ($this->propTypes($prop) as $type) { + // Property values are read by (sub)classing plugin code: + // they flow toward PHP regardless of the owner's direction. + $this->add($type, self::PROVIDED); + } + } + } + + private function expandMethod(MethodInfo $method, int $ownerBits): void + { + // On a provided type, plugins call the methods: arguments flow + // PHP->Rust (consumed), results flow Rust->PHP (provided). On a + // consumed type, Composer calls the plugin's implementation: the + // directions invert. + foreach ([self::PROVIDED => [self::CONSUMED, self::PROVIDED], self::CONSUMED => [self::PROVIDED, self::CONSUMED]] as $ownerDir => [$paramDir, $returnDir]) { + if (($ownerBits & $ownerDir) === 0) { + continue; + } + + foreach ($method->params as $param) { + foreach ($param->classTypes as $type) { + $this->add($type, $paramDir); + } + if ($param->expandable || $param->classTypes === []) { + foreach ($param->docblockTypes as $type) { + $this->add($type, $paramDir); + } + } + } + + foreach ($method->returnClassTypes as $type) { + $this->add($type, $returnDir); + } + if ($method->returnExpandable || $method->returnClassTypes === []) { + foreach ($method->docblockReturnTypes as $type) { + $this->add($type, $returnDir); + } + } + foreach ($method->docblockThrowsTypes as $type) { + $this->add($type, $returnDir); + } + } + } + + /** @return list<string> */ + private function propTypes(PropertyInfo $prop): array + { + $types = $prop->classTypes; + if ($prop->expandable || $types === []) { + $types = array_merge($types, $prop->docblockTypes); + } + + return $types; + } + + private function buildDescendants(): void + { + $direct = []; + foreach ($this->sources->classes as $fqcn => $info) { + if ($info->parent !== null) { + $direct[$info->parent][] = $fqcn; + } + foreach ($info->interfaces as $iface) { + $direct[$iface][] = $fqcn; + } + } + + foreach (array_keys($direct) as $root) { + $seen = []; + $stack = $direct[$root]; + while ($stack !== []) { + $cur = array_pop($stack); + if (isset($seen[$cur])) { + continue; + } + $seen[$cur] = true; + foreach ($direct[$cur] ?? [] as $child) { + $stack[] = $child; + } + } + $this->descendants[$root] = array_keys($seen); + sort($this->descendants[$root]); + } + } +} diff --git a/scripts/plugin-class-classifier/src/Report.php b/scripts/plugin-class-classifier/src/Report.php new file mode 100644 index 00000000..e24363fe --- /dev/null +++ b/scripts/plugin-class-classifier/src/Report.php @@ -0,0 +1,103 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\PluginClassifier; + +final class Report +{ + public function __construct(private readonly Classifier $classifier) + { + } + + public function writeJson(string $path): void + { + $vendor = []; + foreach ($this->classifier->closure->vendorReachable as $fqcn => $bits) { + $info = VendorPackages::lookup($fqcn); + $vendor[] = [ + 'fqcn' => $fqcn, + 'package' => $info['package'], + 'category' => $info['category'], + 'direction' => $bits === 3 ? 'both' : ($bits === 1 ? 'provided' : 'consumed'), + ]; + } + usort($vendor, static fn (array $a, array $b) => strcmp($a['fqcn'], $b['fqcn'])); + + $builtins = array_keys($this->classifier->closure->builtinReachable); + sort($builtins); + + $vendorPackages = []; + foreach (VendorPackages::PREFIXES as $prefix => $info) { + $vendorPackages[$info['package']] = $info['category']; + } + ksort($vendorPackages); + + $data = [ + 'categories' => $this->countByCategory(), + 'classes' => array_values($this->classifier->rows), + 'vendorPackages' => $vendorPackages, + 'vendorTypes' => $vendor, + 'reachableBuiltins' => $builtins, + 'violations' => $this->classifier->violations, + ]; + + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if (file_put_contents($path, $json . "\n") === false) { + throw new \RuntimeException("cannot write $path"); + } + } + + public function printSummary(): void + { + $counts = $this->countByCategory(); + echo "# Plugin boundary classification\n\n"; + echo "| category | classes |\n|---|---|\n"; + foreach ($counts as $category => $count) { + echo "| $category | $count |\n"; + } + + echo "\n## Reachable surface\n\n"; + foreach (['rust-proxy', 'rust-snapshot', 'contract'] as $category) { + $names = []; + foreach ($this->classifier->rows as $row) { + if ($row['category'] === $category && $row['reachable']) { + $names[] = $row['fqcn'] . ($row['direction'] === 'both' ? ' (both)' : ''); + } + } + echo '### ' . $category . ' (' . count($names) . ")\n\n"; + foreach ($names as $name) { + echo "- $name\n"; + } + echo "\n"; + } + + $overridden = array_filter($this->classifier->rows, static fn (array $r) => isset($r['computedCategory'])); + if ($overridden !== []) { + echo "## Overridden\n\n"; + foreach ($overridden as $row) { + echo "- {$row['fqcn']}: {$row['computedCategory']} -> {$row['category']}\n"; + } + echo "\n"; + } + + if ($this->classifier->violations !== []) { + echo "## Violations\n\n"; + foreach ($this->classifier->violations as $violation) { + echo "- $violation\n"; + } + echo "\n"; + } + } + + /** @return array<string, int> */ + private function countByCategory(): array + { + $counts = array_fill_keys(Classifier::CATEGORIES, 0); + foreach ($this->classifier->rows as $row) { + $counts[$row['category']]++; + } + + return $counts; + } +} diff --git a/scripts/plugin-class-classifier/src/SourceParser.php b/scripts/plugin-class-classifier/src/SourceParser.php new file mode 100644 index 00000000..591c54b1 --- /dev/null +++ b/scripts/plugin-class-classifier/src/SourceParser.php @@ -0,0 +1,416 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\PluginClassifier; + +use PhpParser\Node; +use PhpParser\Node\Name; +use PhpParser\Node\Stmt; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor\NameResolver; +use PhpParser\NodeVisitorAbstract; +use PhpParser\Parser; +use PhpParser\ParserFactory; + +/** + * Parses a source tree into ClassInfo records. Names inside declarations + * and bodies are resolved by PhpParser's NameResolver; docblock types are + * resolved separately against the recorded per-file use map. + */ +final class SourceParser +{ + private Parser $parser; + + private DocblockTypeExtractor $docblocks; + + /** @var array<string, ClassInfo> FQCN (case-preserved) => info */ + public array $classes = []; + + /** @var array<string, string> lowercase FQCN => case-preserved FQCN */ + public array $lowercaseIndex = []; + + /** @var list<string> docblock tokens that resolved to nothing known; reported, not fatal */ + public array $unresolvedDocblockTypes = []; + + private BuiltinSignatures $builtins; + + public function __construct() + { + $this->parser = (new ParserFactory())->createForNewestSupportedVersion(); + $this->docblocks = new DocblockTypeExtractor(); + $this->builtins = new BuiltinSignatures(); + } + + public function parseTree(string $root): void + { + $files = []; + $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)); + foreach ($it as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + // PHPStan extensions are dev-only tooling, never shipped at + // runtime; they reference phpstan types and only add noise. + if (str_contains($file->getPathname(), '/PHPStan/')) { + continue; + } + $files[] = $file->getPathname(); + } + sort($files); + + // Pre-scan for @template / @phpstan-type alias names so the + // docblock type extractor can ignore them tree-wide. + foreach ($files as $path) { + $code = file_get_contents($path); + if ($code !== false) { + $this->docblocks->collectAliases($code); + } + } + + foreach ($files as $path) { + $this->parseFile($path); + } + } + + public function finalize(): void + { + $this->mergeTraits(); + } + + private function parseFile(string $path): void + { + $code = file_get_contents($path); + if ($code === false) { + throw new \RuntimeException("cannot read $path"); + } + + $stmts = $this->parser->parse($code); + if ($stmts === null) { + throw new \RuntimeException("cannot parse $path"); + } + + $traverser = new NodeTraverser(); + $traverser->addVisitor(new NameResolver()); + $useCollector = new class extends NodeVisitorAbstract { + public string $namespace = ''; + + /** @var array<string, string> lowercase alias => FQCN */ + public array $useMap = []; + + public function enterNode(Node $node): null + { + if ($node instanceof Stmt\Namespace_) { + $this->namespace = $node->name?->toString() ?? ''; + } elseif ($node instanceof Stmt\Use_ && $node->type === Stmt\Use_::TYPE_NORMAL) { + foreach ($node->uses as $use) { + $alias = $use->alias?->toString() ?? $use->name->getLast(); + $this->useMap[strtolower($alias)] = $use->name->toString(); + } + } elseif ($node instanceof Stmt\GroupUse) { + foreach ($node->uses as $use) { + if ($use->type !== Stmt\Use_::TYPE_NORMAL && $node->type !== Stmt\Use_::TYPE_NORMAL) { + continue; + } + $alias = $use->alias?->toString() ?? $use->name->getLast(); + $this->useMap[strtolower($alias)] = $node->prefix->toString() . '\\' . $use->name->toString(); + } + } + + return null; + } + }; + $traverser->addVisitor($useCollector); + + $classCollector = new class extends NodeVisitorAbstract { + /** @var list<Stmt\ClassLike> */ + public array $classLikes = []; + + public function enterNode(Node $node): null + { + if ($node instanceof Stmt\ClassLike && $node->name !== null) { + $this->classLikes[] = $node; + } + + return null; + } + }; + $traverser->addVisitor($classCollector); + + $traverser->traverse($stmts); + + foreach ($classCollector->classLikes as $node) { + $this->collectClass($node, $path, $useCollector->namespace, $useCollector->useMap); + } + } + + /** @param array<string, string> $useMap */ + private function collectClass(Stmt\ClassLike $node, string $file, string $namespace, array $useMap): void + { + $fqcn = $node->namespacedName?->toString() ?? $node->name->toString(); + + $kind = match (true) { + $node instanceof Stmt\Interface_ => 'interface', + $node instanceof Stmt\Trait_ => 'trait', + $node instanceof Stmt\Enum_ => 'enum', + default => 'class', + }; + + $parent = null; + $interfaces = []; + if ($node instanceof Stmt\Class_) { + $parent = $node->extends?->toString(); + foreach ($node->implements as $iface) { + $interfaces[] = $iface->toString(); + } + } elseif ($node instanceof Stmt\Interface_) { + foreach ($node->extends as $iface) { + $interfaces[] = $iface->toString(); + } + } elseif ($node instanceof Stmt\Enum_) { + foreach ($node->implements as $iface) { + $interfaces[] = $iface->toString(); + } + } + + $info = new ClassInfo( + $fqcn, + $kind, + $node instanceof Stmt\Class_ && $node->isAbstract(), + $node instanceof Stmt\Class_ && $node->isFinal(), + $parent, + $interfaces, + $file, + ); + + foreach ($node->getTraitUses() as $traitUse) { + foreach ($traitUse->traits as $trait) { + $info->traitUses[] = $trait->toString(); + } + } + + foreach ($node->getProperties() as $propNode) { + $doc = $this->docblocks->extract($propNode->getDocComment()?->getText(), $namespace, $useMap); + [$classTypes, $expandable] = $this->typeClassNames($propNode->type, $fqcn, $parent); + foreach ($propNode->props as $prop) { + $info->properties[] = new PropertyInfo( + $prop->name->toString(), + $propNode->isPrivate() ? 'private' : ($propNode->isProtected() ? 'protected' : 'public'), + $propNode->isStatic(), + $classTypes, + $expandable, + $doc['var'], + ); + } + } + + foreach ($node->getMethods() as $methodNode) { + $this->collectMethod($info, $methodNode, $namespace, $useMap); + } + + $this->classes[$fqcn] = $info; + $this->lowercaseIndex[strtolower($fqcn)] = $fqcn; + } + + /** @param array<string, string> $useMap */ + private function collectMethod(ClassInfo $info, Stmt\ClassMethod $node, string $namespace, array $useMap): void + { + $doc = $this->docblocks->extract($node->getDocComment()?->getText(), $namespace, $useMap); + + $params = []; + foreach ($node->params as $paramNode) { + $name = $paramNode->var instanceof Node\Expr\Variable && is_string($paramNode->var->name) + ? $paramNode->var->name + : ''; + [$classTypes, $expandable] = $this->typeClassNames($paramNode->type, $info->fqcn, $info->parent); + $params[] = new ParamInfo( + $name, + $paramNode->byRef, + $paramNode->variadic, + $classTypes, + $expandable, + $doc['params'][$name] ?? [], + $this->typeIsCallable($paramNode->type), + ); + + // Constructor property promotion. + if ($paramNode->flags !== 0) { + $info->properties[] = new PropertyInfo( + $name, + ($paramNode->flags & \PhpParser\Modifiers::PRIVATE) !== 0 ? 'private' + : (($paramNode->flags & \PhpParser\Modifiers::PROTECTED) !== 0 ? 'protected' : 'public'), + false, + $classTypes, + $expandable, + $doc['params'][$name] ?? [], + ); + } + } + + [$returnClassTypes, $returnExpandable] = $this->typeClassNames($node->returnType, $info->fqcn, $info->parent); + + $method = new MethodInfo( + $node->name->toString(), + $node->isPrivate() ? 'private' : ($node->isProtected() ? 'protected' : 'public'), + $node->isStatic(), + $node->isAbstract() || $node->stmts === null, + $params, + $returnClassTypes, + $returnExpandable, + $doc['return'], + $doc['throws'], + ); + + $propertyTypes = []; + foreach ($info->properties as $prop) { + $single = $this->singleClassType($prop->classTypes, $prop->docblockTypes); + if ($single !== null) { + $propertyTypes[$prop->name] = $single; + } + } + $paramTypes = []; + foreach ($params as $param) { + $single = $this->singleClassType($param->classTypes, $param->docblockTypes); + if ($single !== null) { + $paramTypes[$param->name] = $single; + } + } + + $analyzer = BodyAnalyzer::analyze($node, $method, $info->fqcn, $propertyTypes, $paramTypes, $this->builtins); + foreach ($analyzer->newRefs as $ref) { + $info->newRefs[] = ltrim($ref, '\\'); + } + foreach ($analyzer->staticRefs as $ref) { + $info->staticRefs[] = ltrim($ref, '\\'); + } + foreach ($analyzer->benignRefs as $ref) { + $info->benignBodyRefs[] = ltrim($ref, '\\'); + } + if ($analyzer->writesOwnStaticProps) { + $info->writesOwnStaticProps = true; + } + + $info->methods[strtolower($method->name)] = $method; + } + + /** + * @param list<string> $native + * @param list<string> $docblock + */ + private function singleClassType(array $native, array $docblock): ?string + { + if (count($native) === 1) { + return $native[0]; + } + if ($native === [] && count($docblock) === 1) { + return $docblock[0]; + } + + return null; + } + + /** + * Extracts class-like FQCNs from a native type node and reports whether + * the type invites docblock refinement (array/iterable/mixed/object or + * no type at all). + * + * @return array{0: list<string>, 1: bool} + */ + private function typeClassNames(?Node $type, string $currentClass, ?string $parentClass): array + { + if ($type === null) { + return [[], true]; + } + + $classes = []; + $expandable = false; + + $walk = function (Node $t) use (&$walk, &$classes, &$expandable, $currentClass, $parentClass): void { + if ($t instanceof Node\NullableType) { + $walk($t->type); + } elseif ($t instanceof Node\UnionType || $t instanceof Node\IntersectionType) { + foreach ($t->types as $sub) { + $walk($sub); + } + } elseif ($t instanceof Node\Identifier) { + if (in_array($t->toLowerString(), ['array', 'iterable', 'mixed', 'object'], true)) { + $expandable = true; + } + } elseif ($t instanceof Name) { + $name = $t->toString(); + $lower = strtolower($name); + if ($lower === 'self' || $lower === 'static') { + $classes[] = $currentClass; + } elseif ($lower === 'parent') { + if ($parentClass !== null) { + $classes[] = $parentClass; + } + } else { + $classes[] = ltrim($name, '\\'); + } + } + }; + $walk($type); + + return [array_values(array_unique($classes)), $expandable]; + } + + private function typeIsCallable(?Node $type): bool + { + if ($type === null) { + return false; + } + if ($type instanceof Node\NullableType) { + return $this->typeIsCallable($type->type); + } + if ($type instanceof Node\UnionType || $type instanceof Node\IntersectionType) { + foreach ($type->types as $sub) { + if ($this->typeIsCallable($sub)) { + return true; + } + } + + return false; + } + if ($type instanceof Node\Identifier) { + return $type->toLowerString() === 'callable'; + } + if ($type instanceof Name) { + return strtolower($type->toString()) === 'closure'; + } + + return false; + } + + private function mergeTraits(): void + { + foreach ($this->classes as $info) { + foreach ($info->traitUses as $traitName) { + $trait = $this->classes[$traitName] ?? null; + if ($trait === null) { + continue; + } + foreach ($trait->methods as $lname => $method) { + if (!isset($info->methods[$lname])) { + $info->methods[$lname] = $method; + } + } + foreach ($trait->properties as $prop) { + $info->properties[] = $prop; + } + foreach ($trait->newRefs as $ref) { + $info->newRefs[] = $ref; + } + foreach ($trait->staticRefs as $ref) { + $info->staticRefs[] = $ref; + } + foreach ($trait->benignBodyRefs as $ref) { + $info->benignBodyRefs[] = $ref; + } + if ($trait->writesOwnStaticProps) { + $info->writesOwnStaticProps = true; + } + } + } + } +} diff --git a/scripts/plugin-class-classifier/src/VendorPackages.php b/scripts/plugin-class-classifier/src/VendorPackages.php new file mode 100644 index 00000000..768a9c17 --- /dev/null +++ b/scripts/plugin-class-classifier/src/VendorPackages.php @@ -0,0 +1,69 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\PluginClassifier; + +/** + * Package-level classification of composer/composer's runtime vendor + * dependencies, applied by namespace prefix. See the "Mechanical rules" + * step 6 in docs/dev/plugin-class-classification.md for the criterion. + */ +final class VendorPackages +{ + /** @var array<string, array{package: string, category: string}> prefix => info */ + public const PREFIXES = [ + 'Composer\\Semver\\' => ['package' => 'composer/semver', 'category' => 'php-native'], + 'Composer\\Pcre\\' => ['package' => 'composer/pcre', 'category' => 'php-native'], + 'Composer\\CaBundle\\' => ['package' => 'composer/ca-bundle', 'category' => 'php-native'], + 'Composer\\ClassMapGenerator\\' => ['package' => 'composer/class-map-generator', 'category' => 'php-native'], + 'Composer\\MetadataMinifier\\' => ['package' => 'composer/metadata-minifier', 'category' => 'php-native'], + 'Composer\\Spdx\\' => ['package' => 'composer/spdx-licenses', 'category' => 'php-native'], + 'Composer\\XdebugHandler\\' => ['package' => 'composer/xdebug-handler', 'category' => 'php-native'], + 'JsonSchema\\' => ['package' => 'justinrainbow/json-schema', 'category' => 'php-native'], + 'Seld\\JsonLint\\' => ['package' => 'seld/jsonlint', 'category' => 'php-native'], + 'Seld\\PharUtils\\' => ['package' => 'seld/phar-utils', 'category' => 'php-native'], + 'Seld\\Signal\\' => ['package' => 'seld/signal-handler', 'category' => 'php-native'], + 'Psr\\Log\\' => ['package' => 'psr/log', 'category' => 'php-native'], + 'React\\Promise\\' => ['package' => 'react/promise', 'category' => 'contract'], + 'Symfony\\Component\\Console\\' => ['package' => 'symfony/console', 'category' => 'two-world'], + 'Symfony\\Component\\Process\\' => ['package' => 'symfony/process', 'category' => 'php-native'], + 'Symfony\\Component\\Filesystem\\' => ['package' => 'symfony/filesystem', 'category' => 'php-native'], + 'Symfony\\Component\\Finder\\' => ['package' => 'symfony/finder', 'category' => 'php-native'], + 'Symfony\\Polyfill\\' => ['package' => 'symfony/polyfill', 'category' => 'php-native'], + ]; + + /** + * By-ref parameter positions of vendor methods, needed by the purity + * analysis when composer code passes $this-rooted expressions to them. + * Vendor packages other than composer/pcre expose no by-ref parameters + * in APIs composer calls; composer/pcre's output parameter is pervasive. + * + * @var array<string, array<string, list<int>>> FQCN => lowercase method => positions + */ + public const BY_REF = [ + 'Composer\\Pcre\\Preg' => [ + 'match' => [2], + 'matchstrictgroups' => [2], + 'ismatch' => [2], + 'ismatchstrictgroups' => [2], + 'matchall' => [2], + 'matchallstrictgroups' => [2], + 'ismatchall' => [2], + 'ismatchallstrictgroups' => [2], + ], + 'Composer\\Pcre\\Regex' => [], + ]; + + /** @return array{package: string, category: string}|null */ + public static function lookup(string $fqcn): ?array + { + foreach (self::PREFIXES as $prefix => $info) { + if (str_starts_with($fqcn, $prefix)) { + return $info; + } + } + + return null; + } +} |
