diff options
Diffstat (limited to 'docs/dev/plugin-class-classification.md')
| -rw-r--r-- | docs/dev/plugin-class-classification.md | 466 |
1 files changed, 466 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). |
