A Composer plugin that knows no package. What it does comes from the packages installed (and the
root), in extra.omnibridge: patches applied to other packages' files after install and update,
recorded with checksums and written back as published when they no longer apply, and stub
providers that write IDE stubs after the autoloader is dumped.
git clone https://github.com/glitchr-omnisense/omnibridge-composer.git
cd omnibridge-composer/examples/child-plugin
docker compose run --rm example
That runs the example:
a project installs acme/greeter (a final Greeter that says "Hello"), then requires
acme/child-plugin, the smallest child of this engine - one patch, one stub provider. Composer
applies the patch (Greeter no longer final, "Bonjour" by default), writes the stubs, the program
greets in French through a subclass, --check is green, a second install changes nothing, and
removing the child gives acme/greeter back as published (Installation has the
whole run).
What it is
"extra": {
"omnibridge": {
"patches": [
{
"id": "greeter-in-french",
"package": "acme/greeter",
"versions": "^1.0",
"file": "src/Greeter.php",
"why": "The demo greets in French and extends Greeter, which acme/greeter ships final.",
"do": [
{ "unfinal": "Acme\\Greeter\\Greeter" },
{ "default": "'Bonjour'", "of": "Acme\\Greeter\\Greeter::__construct($greeting)" }
]
}
],
"stubs": ["Acme\\Child\\Stub\\GreeterStub"]
}
}
- Patches - applied after
installandupdate(andrequire,remove,reinstall), on the file as published; written back as published when the declaring package leaves, the target leaves, orversions/phpstop matching; applying twice changes nothing. Strict: a patch that no longer finds its target fails the command with its id and itswhy, unless it says"optional": true. A package installed as a symbolic link (a path repository) is never patched. The vocabulary -replace,prepend,append,erase,unfinal,visibility,default,returns- and"class"for what it cannot say: Patches. - The record -
vendor/composer/omnibridge/patches.json: each patched file, its package and version, the patches applied in order, the SHA-256 of the file as published and as patched; the published copy beside it inoriginals/. No date, no random id: the same lock gives the same record. - Stubs - providers (
StubInterface;AbstractStubmirrors a namespace's classes under another) run afterdump-autoloadintovendor/composer/omnibridge/stubs/, which no autoloader reads: for an IDE and static analysis, never a definition at run time. Deterministic, stale files removed: Stubs.
Commands
composer omnibridge:patches # declared: applied, skipped (why), failed
composer omnibridge:patches --diff # what the applied ones changed (unified diff)
composer omnibridge:patches --check # CI: fails if a patch lost its target or vendor/ differs from the record
composer omnibridge:patches --apply # apply now (what install and update do)
composer omnibridge:stubs [--check] # regenerate the stubs, or check them (CI)
Each command, its output and its exit codes: Commands.
A child
A child is a plain package ("type": "library") that requires omnibridge/composer and declares
extra.omnibridge. Only the engine is a Composer plugin, so a project allows one plugin, whatever the
number of children:
"config": { "allow-plugins": { "omnibridge/composer": true } }
omnisense/omnibase (4.0) is the first real one: its stub providers (Base\Bridge\Composer\Stub\*, the
App\ stubs of Base\Entity, Enum, Form, Notifier and Repository) and its patch of
symfony/process are declared in its composer.json. Writing one: A child.
How this manual is organised
- Installation: requirements, allowing the plugin, the example's whole run.
- Patches: the declaration, when patches run, strictness, the vocabulary, the record.
- Stubs: providers,
AbstractStub. - Commands:
omnibridge:patchesandomnibridge:stubs, every option, the messages. - A child: a package that declares patches and stubs, its classes, the hooks' order.
- Errors, Testing, Verified, and not.
Licence
MIT.
Propose a change docs/index.md
Installation
Requirements
| Package | Version | Why |
|---|---|---|
| PHP | 8.2 or later | |
composer-plugin-api |
^2.3 | Composer 2.3 or later |
nikic/php-parser |
^5.0 | the AST operations (unfinal, visibility, default, returns) |
The package is of type composer-plugin, its class Omnibridge\Composer\Plugin (extra.class).
Composer asks before running a plugin: allow it once, in the project's root.
{
"config": {
"allow-plugins": { "omnibridge/composer": true }
}
}
A project rarely requires it itself: a child requires it (omnisense/omnibase does), and Composer
installs it with the child. A child is never a plugin, so one entry in allow-plugins covers them all.
composer require omnibridge/composer
Without the plugin allowed (or with composer install --no-plugins), nothing is patched and no stub
is written; composer omnibridge:patches --apply and composer omnibridge:stubs do it afterwards.
The example, run
examples/child-plugin/run.sh drives a project (demo/) through the whole life of a patch:
acme/greeter 1.2.0 installed alone, acme/child-plugin required (it requires the engine and
declares the patch greeter-in-french and the stub provider GreeterStub), the listing, the diff,
the stubs, the program, both checks, a second install, the child removed.
cd examples/child-plugin
docker compose run --rm example
Run on 2026-10-27 in the image omnibridge-composer:php8.4, with no network: the script as it is
needs Packagist for nikic/php-parser, so the demo's composer.json was given two more repositories
first - the engine's own vendor/nikic/php-parser (5.9.0) as a path repository, and
"packagist.org": false. Everything else is the script's. Its output, the lines about Packagist's
unreachable advisories left out:
== 1. composer install: acme/greeter 1.2.0 alone (Greeter final, 'Hello')
No composer.lock file present. Updating dependencies to latest instead of installing from lock file. See https://getcomposer.org/install for more information.
Loading composer repositories with package information
Updating dependencies
Lock file operations: 1 install, 0 updates, 0 removals
- Locking acme/greeter (1.2.0)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 1 install, 0 updates, 0 removals
- Installing acme/greeter (1.2.0): Mirroring from /engine/examples/child-plugin/packages/greeter
Generating autoload files
(new Greeter())->greet("world"): Hello, world.
Greeter is final: App\LoudGreeter cannot extend it
== 2. composer require acme/child-plugin (requires omnibridge/composer; declares a patch and a stub provider)
./composer.json has been updated
Running composer update acme/child-plugin
Loading composer repositories with package information
Updating dependencies
Lock file operations: 3 installs, 0 updates, 0 removals
- Locking acme/child-plugin (1.0.0)
- Locking nikic/php-parser (5.9.0)
- Locking omnibridge/composer (1.x-dev 58d0450)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 3 installs, 0 updates, 0 removals
- Installing nikic/php-parser (5.9.0): Mirroring from /engine/vendor/nikic/php-parser
- Installing omnibridge/composer (1.x-dev 58d0450): Symlinking from /engine
- Installing acme/child-plugin (1.0.0): Symlinking from /engine/examples/child-plugin/packages/child
Generating autoload files
omnibridge/composer: stubs Greeter 1 (vendor/composer/omnibridge/stubs)
omnibridge/composer: patches
applied acme/child-plugin:greeter-in-french - unfinal Acme\Greeter\Greeter; default Acme\Greeter\Greeter::__construct($greeting) = 'Bonjour'
omnibridge/composer: stubs Greeter 2 (vendor/composer/omnibridge/stubs)
No security vulnerability advisories found.
== composer omnibridge:patches
1 declared
applied (1)
acme/child-plugin:greeter-in-french
acme/greeter src/Greeter.php (versions ^1.0)
why: The demo greets in French and extends Greeter, which acme/greeter ships final.
upstream: https://example.org/acme/greeter/issues/1
unfinal Acme\Greeter\Greeter; default Acme\Greeter\Greeter::__construct($greeting) = 'Bonjour'
== composer omnibridge:patches --diff
--- a/acme/greeter/src/Greeter.php
+++ b/acme/greeter/src/Greeter.php
@@ -5,11 +5,10 @@
/**
* Says hello.
*
- * @final
*/
-final class Greeter
+class Greeter
{
- public function __construct(private string $greeting = 'Hello')
+ public function __construct(private string $greeting = 'Bonjour')
{
}
== the stubs (vendor/composer/omnibridge/stubs, never autoloaded)
vendor/composer/omnibridge/stubs/Greeter/Greeter.php
vendor/composer/omnibridge/stubs/Greeter/Salutation.php
<?php
namespace App\Greeter;
/**
* Generated by omnibridge/composer (Acme\Child\Stub\GreeterStub) for an IDE and static
* analysis: at run time App\Greeter\Greeter is Acme\Greeter\Greeter unless the application
* defines it. Never autoloaded; rewritten at every composer dump-autoload.
*/
if (!class_exists('\App\Greeter\Greeter')) {
class Greeter extends \Acme\Greeter\Greeter {}
}
== the program, with the patch
(new Greeter())->greet("world"): Bonjour, world.
(new App\LoudGreeter())->greet("world"): BONJOUR, WORLD.
== composer omnibridge:patches --check, composer omnibridge:stubs --check
omnibridge:patches --check: 1 patch(es) applied as recorded, vendor/ as declared
omnibridge:stubs --check: Greeter 2
== 3. composer install again: nothing changes
Installing dependencies from lock file (including require-dev)
Verifying lock file contents can be installed on current platform.
Nothing to install, update or remove
Generating autoload files
omnibridge/composer: stubs Greeter 2 (vendor/composer/omnibridge/stubs)
omnibridge/composer: patches
applied acme/child-plugin:greeter-in-french - unfinal Acme\Greeter\Greeter; default Acme\Greeter\Greeter::__construct($greeting) = 'Bonjour'
vendor/acme and the record: identical, byte for byte
== 4. composer remove acme/child-plugin: the patch is taken back
./composer.json has been updated
Running composer update acme/child-plugin
Loading composer repositories with package information
Updating dependencies
Lock file operations: 0 installs, 0 updates, 3 removals
- Removing acme/child-plugin (1.0.0)
- Removing nikic/php-parser (5.9.0)
- Removing omnibridge/composer (1.x-dev 58d0450)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 0 installs, 0 updates, 3 removals
omnibridge/composer: acme/greeter/src/Greeter.php written back as published
- Removing omnibridge/composer (1.x-dev 58d0450)
- Removing nikic/php-parser (5.9.0)
- Removing acme/child-plugin (1.0.0)
Generating autoload files
No security vulnerability advisories found.
(new Greeter())->greet("world"): Hello, world.
Greeter is final: App\LoudGreeter cannot extend it
vendor/acme/greeter: as published
vendor/composer/omnibridge: gone
== done
What it shows:
- After
require, the stubs are written twice: once after the autoloader is dumped (Greeter 1:Greeteris still final, onlySalutationgets a stub), once more after the patches changed a file (Greeter 2:Greeteris no longer final). omnibridge/composerandacme/child-pluginare symlinked path packages (never patched themselves);acme/greeteris mirrored ("symlink": false), so it can be patched.- A second
composer installleavesvendor/acmeand the record identical, byte for byte. - Removing the child writes
Greeter.phpback as published, and the engine, removed with it, removesvendor/composer/omnibridge.
Propose a change docs/installation.md
Patches
A patch is declared by the package that needs it (or by the root), never by the
engine: extra.omnibridge.patches, a list.
{
"id": "process-timeout-from-env",
"package": "symfony/process",
"versions": "^6.0 || ^7.0 || ^8.0",
"php": ">=8.2",
"file": "Process.php",
"why": "One sentence: what the patch changes and what for.",
"upstream": "https://github.com/symfony/symfony/issues/...",
"optional": false,
"do": [
{ "default": "null", "of": "Symfony\\Component\\Process\\Process::__construct($timeout)" }
]
}
| Key | ||
|---|---|---|
id |
required | letters, digits, ., -, _; unique in the declaring package. The patch is named <declaring package>:<id> (__root__:<id> for the root) |
package |
required | the target package |
file |
required | the file, relative to the target package (one file per patch) |
why |
required | printed with every failure |
do |
do or class |
one operation or a list, applied in order |
class |
do or class |
a Omnibridge\Composer\Patch\PatchInterface, autoloaded from the declaring package |
versions |
optional | a Composer constraint on the target's installed version |
php |
optional | a constraint on the PHP the project installs for (config.platform.php, else the running PHP) |
upstream |
optional | where the change is (or should be) proposed |
optional |
optional | true: a target not found is a skip, not a failure |
When
After composer install and composer update - so after require, remove and
reinstall too, every package being on disk then. The engine computes each
touched file from its published content (the copy the record kept, or the file
itself when Composer has just written it) with every applicable patch, in a fixed
order: declaring packages by name, the root last, each package's patches in its
order. A file that no patch applies to any more is written back as published:
- the declaring package was removed;
- the target's version or PHP stopped matching
versions/php; - the engine itself was removed (it writes everything back on uninstall).
A target installed as a symbolic link (a path repository with "symlink": true)
is never patched: that would change its source. It is reported as skipped. The root
package declares patches (as __root__) but is never a target: a patch whose
package is the root's name is skipped as "not installed".
Strict
Every operation fails when it does not find what it changes - and also when the
change is already there (a parameter that already defaults to the value, a class
that is no longer final): a patch that has become useless says so, which is how
its declaring package learns to drop it or narrow versions. A failure stops the
command (install, update...) with the patch's name, what was not found and the
patch's why; the failed patch is left out of the file, which keeps the other patches that apply to
it, or its published content when none does. "optional": true turns it into a
skip.
The vocabulary
Two kinds of edit. The AST ones locate their target with nikic/php-parser by name
- a class, a member, a parameter - whatever the spacing, the attributes or the
line it is on, and splice at the node's offsets (the rest of the file keeps its
bytes). The text ones match exact text; in a PHP file the lines they add are
marked with a comment naming the patch,
// [omnibridge <package>:<id>].
replace (text)
{ "replace": "new HtmlErrorRenderer(", "with": "new \\App\\HtmlErrorRenderer(", "count": 1 }
Every occurrence of the exact text (whitespace included). count, when given, is
how many there must be. Unmarked: the text may be inside an expression. with may
be empty.
prepend, append (text, marked)
{ "prepend": "$timeout ??= $_ENV['SYMFONY_PROCESS_TIMEOUT'] ?? 60;", "before": "$this->setTimeout($timeout);" }
{ "append": "...", "after": "..." }
The lines inserted before (after) every line that contains the anchor (count
to say how many), at the anchor's indentation unless the text brings its own,
after a marker line in a PHP file. "mark": false leaves the marker out. A list
of strings is joined with new lines.
erase (text, marked)
{ "erase": "function mb_ucfirst(" }
Every line that contains the anchor. In a PHP file the line stays as a comment
after the marker (// [omnibridge acme/child:id] function mb_ucfirst(...), so what
was there remains readable; "mark": false or another file type removes it.
unfinal (AST)
{ "unfinal": "Vendor\\Package\\Class" }
{ "unfinal": ["Vendor\\Package\\A", "Vendor\\Package\\B"] }
The class's final keyword and the @final line of its docblock (the one
Symfony's DebugClassLoader reads) removed. A class that is neither is not found.
visibility (AST)
{ "visibility": "protected", "of": "Vendor\\Package\\Class::$property" }
{ "visibility": "protected", "of": ["Class::method()", "Class::CONSTANT", "Class::*"] }
The member's visibility keyword changed; a promoted constructor parameter counts
as a property; a method or constant without a keyword gets one. Class::*
changes every private member of the class. A member that already has the
visibility is not found.
default (AST)
{ "default": "null", "of": "Vendor\\Package\\Class::method($parameter)" }
{ "default": "'fr'", "of": "Vendor\\Package\\function($parameter)" }
The parameter's default value replaced by the expression, written as PHP. A parameter without a default, or that already defaults to it, is not found.
returns (AST)
{ "returns": "void", "of": "Vendor\\Package\\Class::method()" }
{ "returns": "?string", "of": ["Class::a()", "Vendor\\Package\\function()"] }
The declared return type replaced, written as PHP - for a subclass whose
override PHP refuses (a parent's never where the child returns void). A
method without a declared return type, or that already returns it, is not found.
class
{ "id": "...", "package": "...", "file": "...", "why": "...", "class": "Vendor\\Child\\Patch\\MyPatch" }
final class MyPatch implements \Omnibridge\Composer\Patch\PatchInterface
{
public function apply(string $code, \Omnibridge\Composer\Patch\Target $target): string
{
// $code is the file as published; return it changed, or throw
// Omnibridge\Composer\Patch\TargetNotFound. Unchanged counts as not found.
}
}
For what the vocabulary cannot say. It is recorded, restored and checked like the others; it must be a function of the published content (no date, nothing random).
The record
vendor/composer/omnibridge/patches.json:
{
"files": {
"symfony/process/Process.php": {
"package": "symfony/process",
"version": "v8.1.6",
"file": "Process.php",
"path": "vendor/symfony/process/Process.php",
"before": "sha256:...",
"after": "sha256:...",
"patches": ["omnisense/omnibase:process-timeout-from-env"]
}
}
}
and vendor/composer/omnibridge/originals/<package>/<file>, the published file. No
date and no random id: two installs of one lock write the same record. A file that
no longer has the recorded after was replaced by Composer (a reinstall, an update)
and is patched again on its new content.
Commands
composer omnibridge:patches- every declared patch: applied (what it does), skipped (package not installed, version, PHP, linked package, optional and not found) or failed, with the target,why,upstream.composer omnibridge:patches --diff- a unified diff of each patched file, as published against as installed.composer omnibridge:patches --check- for CI, aftercomposer install: fails when a patch no longer finds its target, when a file differs from the record, when a published copy is missing or altered, or when vendor/ is not in the state the declarations give (a patch never applied, one no longer declared).composer omnibridge:patches --apply- applies now, as install and update do (after a run with--no-plugins, say).
Propose a change docs/patches.md
Stubs
A package declares stub providers in extra.omnibridge.stubs: class names,
autoloaded from that package (its psr-4, psr-0 and classmap; never its files).
They run after every composer dump-autoload (so after install and update), and
once more after the patches when those changed a file.
interface StubInterface
{
public function getName(): string; // the subdirectory: Entity, Repository...
public function generate(StubContext $context): iterable; // relative path => content
}
StubContext gives the project directory, the vendor directory, the declaring
package's directory and name.
The engine writes every provider's files under
vendor/composer/omnibridge/stubs/<name>/, rewriting a file only when its content
changes and removing what no provider returns any more. No autoloader reads that
directory: a stub is for an IDE and static analysis, never a definition at run
time. A provider must be deterministic - the same tree gives the same bytes, no
date, no absolute path - which composer omnibridge:stubs --check verifies in CI.
AbstractStub
The generic generator: it mirrors the classes of a source namespace under a target
namespace, for a package that, at run time, makes each Target\X an alias of
Source\X unless the application defines Target\X (omnibase's Base\ and
App\):
final class EntityStub extends AbstractStub
{
public function getName(): string { return 'Entity'; }
protected function getSourceNamespace(): string { return 'Base\Entity'; }
protected function getSourceDirectory(StubContext $c): string { return $c->packageDir.'/src/Entity'; }
protected function getTargetNamespace(): string { return 'App\Entity'; }
protected function getApplicationDirectory(StubContext $c): ?string { return $c->projectDir.'/src/Entity'; }
}
One stub per plain class (not abstract, final or readonly; not an interface, a trait or an enum, read with PHP's tokenizer) whose relative path has no file in the application directory:
namespace App\Entity;
/** Generated by omnibridge/composer (...EntityStub) ... */
if (!class_exists('\App\Entity\User')) {
class User extends \Base\Entity\User {}
}
render() can be overridden for another form.
Propose a change docs/stubs.md
Commands
The plugin adds two commands to Composer (Composer\Plugin\Capability\CommandProvider).
omnibridge:patches
composer omnibridge:patches [--diff] [--check] [--apply]
| Mode | What it does | Exit code |
|---|---|---|
| (none) | computes every declared patch without writing, and lists them: N declared, then applied, skipped, failed groups, each patch with its target package and file (and versions), its why, its upstream, and what it does (applied) or why it was skipped or failed; the problems as warnings. No package declares a patch (extra.omnibridge.patches). when there is none |
1 when a patch fails, else 0 |
--diff |
a unified diff of each recorded file, as published (a/<package>/<file>) against as installed (b/...) |
0 |
--check |
computes without writing and compares with vendor/ and the record: fails on a patch that no longer finds its target, a file that differs from the record, a published copy missing or altered, a file not in the declared state, a patch never applied, a recorded file no patch applies to any more. omnibridge:patches --check: N patch(es) applied as recorded, vendor/ as declared when clean |
1 on any failure or problem (omnibridge:patches --check: N problem(s)), else 0 |
--apply |
applies now and writes, as install and update do (after a run with --no-plugins), printing what the hooks print |
1 when a patch fails, else 0 |
--apply wins over --diff, which wins over --check, when several are given.
The statuses of a patch:
| Status | When |
|---|---|
applied |
its operations found their targets |
skipped: package not installed |
the target package is absent (or is the root) |
skipped: version |
the target's installed version does not match versions |
skipped: php |
the project's PHP (config.platform.php, else the running PHP) does not match php |
skipped: linked package |
the target is installed as a symbolic link |
skipped: optional, target not found |
an optional patch that found nothing |
FAILED |
a target not found (or already patched), an invalid declaration, a patch declared twice by one package |
omnibridge:stubs
composer omnibridge:stubs [--check]
| Mode | What it does | Exit code |
|---|---|---|
| (none) | runs every declared provider into vendor/composer/omnibridge/stubs/: omnibridge:stubs: <name> <count>, ... and N written, M removed when something changed |
1 when a provider is missing, not a StubInterface, has an invalid name or path, or two providers share a name; else 0 |
--check |
the same, writing nothing: also fails on a stub missing, a stub that is not what its provider generates, a file no provider returns any more | 1 on any problem, else 0 |
no provider declared when no package declares one.
What the hooks print
During install, update, require, remove:
omnibridge/composer: stubs <name> <count> (vendor/composer/omnibridge/stubs)after each autoloader dump (post-autoload-dump), and again after the patches when they changed or restored a file;omnibridge/composer: patches, then one line per patch (applied,skipped,FAILED), the files Composer replaced (... was replaced by Composer: patched again), the files written back as published, the problems (post-install-cmd,post-update-cmd);- a failure ends the command with
omnibridge/composer: a declared patch could not be applied (it is strict: say "optional": true or narrow its "versions" if that is expected):and the failed patches' lines; - on the engine's own removal:
omnibridge/composer: <file> written back as publishedfor each recorded file, the stubs and the record removed.
In CI, after composer install:
composer omnibridge:patches --check
composer omnibridge:stubs --check
Propose a change docs/commands.md
A child
A child is a plain package ("type": "library") that requires omnibridge/composer and declares, in
its composer.json, extra.omnibridge.patches (a list of patches) and extra.omnibridge.stubs (a list
of provider class names). The root package may declare both too (its patches are named
__root__:<id>).
{
"name": "acme/child-plugin",
"type": "library",
"require": { "omnibridge/composer": "1.x-dev", "acme/greeter": "^1.0" },
"autoload": { "psr-4": { "Acme\\Child\\": "src/" } },
"extra": {
"omnibridge": {
"patches": [{ "id": "greeter-in-french", "package": "acme/greeter", "versions": "^1.0", "file": "src/Greeter.php", "why": "...", "do": [{ "unfinal": "Acme\\Greeter\\Greeter" }] }],
"stubs": ["Acme\\Child\\Stub\\GreeterStub"]
}
}
}
Its classes inside Composer
A "class" patch and a stub provider are the child's own classes, run inside Composer's process. The
engine makes them loadable from the child's psr-4, psr-0 and classmap autoload entries, for the
time they run - never its files (that would run the child's code inside Composer). So a provider or
a class patch:
- must have a constructor without arguments;
- may use the child's own classes and PHP, not the child's dependencies (they are not loaded by Composer's process, unless Composer itself has them);
- must be deterministic: the same tree gives the same bytes, no date, no absolute path, nothing
random - the record and
--checkrely on it.
| Interface | Method | Contract |
|---|---|---|
Omnibridge\Composer\Patch\PatchInterface |
apply(string $code, Target $target): string |
$code is the file as published (or as the earlier patches of the same file left it); return it changed, or throw TargetNotFound; unchanged counts as not found. Any other exception becomes TargetNotFound |
Omnibridge\Composer\Stub\StubInterface |
getName(): string |
the subdirectory, letters, digits, ., _, -, unique among providers |
generate(StubContext $context): iterable |
relative path => content; a path with .. or empty is refused |
Target: package, version (pretty), file (relative to the package), path (absolute), patch
(its key); isPhp($code) (a .php file, or code starting with <?php or a PHP shebang).
StubContext: projectDir, vendorDir, packageDir (the child's), packageName.
The order of things
- Composer installs or updates the packages, then dumps the autoloader:
post-autoload-dumpruns the stub providers. post-install-cmd/post-update-cmd: every patch, packages by name and the root last, each package's in its order, on each file's published content.- When a file was patched or written back, the stub providers run again (a class no longer final may now get a stub).
- A failed patch fails the Composer command, once every file is written: the failed patch is left out, its file holds the other patches that apply to it, or its published content when none does.
Propose a change docs/children.md
Errors
Three classes of the engine; what reaches the user is a Composer command's failure or a listing's line.
| Class | Parent | Meaning |
|---|---|---|
Omnibridge\Composer\Patch\InvalidDeclaration |
\InvalidArgumentException |
a declaration in extra.omnibridge the engine cannot read |
Omnibridge\Composer\Patch\TargetNotFound |
\RuntimeException |
a patch did not find what it changes, or found it already changed |
\RuntimeException (Composer's command fails) |
Plugin::onPostInstall() after a run with failed patches, with Report::failureMessage() |
None of the first two escapes: the reconciler turns each into a FAILED outcome (or a skip for an
optional patch), and the install or update fails at the end with the list.
Invalid declarations
The patch is FAILED with invalid declaration: <message>:
| Message | Cause |
|---|---|
a patch is an object |
an entry of patches that is not an object |
"<key>" must be a non-empty string |
id, package, file, why missing or empty; class, versions, php, upstream given empty |
"id" is letters, digits, dots, dashes and underscores, not "..." |
|
"file" is relative to the target package, inside it, not "..." |
an absolute path or .. |
a patch says either "do" (operations) or "class" (a PatchInterface), one of them |
both or neither |
"do" is an operation or a non-empty list of them / an operation is an object, such as {...} |
|
an operation says exactly one of replace, prepend, append, erase, unfinal, visibility, default, returns; got {...} |
no verb or two |
"<key>" is missing in {...} |
an operation's required key (with for replace says "replace" needs "with" (a string, possibly empty); before / after for prepend / append) |
"count" must be a positive integer |
|
"visibility" is public, protected or private, not "..." |
|
"of" names ... |
an of that is not Class::$property, Class::method(), Class::CONSTANT, Class::* (visibility), Class::method($parameter) or function($parameter) (default), Class::method() or function() (returns) |
"<versions or php>" is not a version constraint: ... |
Composer's version parser refused it |
declared twice |
one package declares the same id twice |
Targets not found
The patch is FAILED with the message (or skipped when optional):
<package> has no file <file>;<text> not found in <file>,<text> found N times in <file>, the patch expects M(replace,prepend,append,eraseand theircount);class X not found in <file>,class X is neither final nor @final in <file>(unfinal);<member> is already <visibility> in <file>, no such member (visibility);<function> not found,$p of <function> has no default value,... already defaults to ...,... has no parameter $p(default);<function> declares no return type,... already returns ...(returns);<file> does not parse as PHP: ...(every AST operation);class X not found in <declaring package>,X does not implement ...PatchInterface,X: <message of its exception>,X changed nothing in <file>(aclasspatch).
Stub problems
Reported as warnings by the hooks, as errors by omnibridge:stubs (exit code 1): <package>: class "X" not found, <package>: X does not implement ...StubInterface, X: "<name>" is not a directory name, X: another provider already writes the stubs "<name>", X: "<path>" is not a path inside its directory; and with --check: <path> is missing, <path> is not what its provider generates,
<path> is no provider's any more.
Record problems
With --check (and listed as problems by the other runs): <key> differs from the record (<hash> expected, <hash> found), <key>: the published copy the record keeps is missing or altered; the file is taken as it is, <key> is in the record but no longer installed, <key> is not in the declared state (...), <key> is not in the record (patches never applied: run ...), <key>: the record says ..., the declarations give ..., <key> is in the record but no patch applies to it any more.
Propose a change docs/errors.md
Testing
In a project's CI
After composer install:
composer omnibridge:patches --check
composer omnibridge:stubs --check
The first fails when a dependency's update made a patch useless or broken (the patch says its why
and its upstream), or when vendor/ was changed by hand; the second when a provider's output changed
without a dump. A child tests its own patches by installing a project that requires it - the engine's
examples/child-plugin is the pattern.
The package's suite
docker compose run --rm test
The service test: the image omnibridge-composer:php8.4 (docker/Dockerfile: PHP 8.4-cli with git,
unzip and Composer 2), the package at /engine, a named volume composer-cache; it runs
composer install then vendor/bin/phpunit. With the checkout's vendor/ installed:
docker run --rm --network none -v "$PWD":/engine:ro omnibridge-composer:php8.4 vendor/bin/phpunit --do-not-cache-result
| Test class | What it checks |
|---|---|
OperationsTest |
replace every occurrence and with count; prepend marked and indented, append unmarked; erase keeps the line as a comment; unfinal removes the keyword and the tag, refuses a class that is not final; visibility of a promoted parameter, a property, a method, a constant, of every private member, where it is implicit, refused when already there; default of a method and a function, refused when already there; returns of a method and a function, refused when already there; everything else of the file keeps its bytes; each target not found says what; a declaration says one verb; code that does not parse |
ReconcilerTest (a fake vendor directory) |
apply records checksums and keeps the published file; applying again changes nothing; restored when the declaring package leaves, when the version stops matching; skipped by PHP; a strict failure says the id and why and touches nothing; an optional patch that finds nothing is skipped; a class patch; two patches on one file in a fixed order; a file Composer replaced is patched again; --check fails when a file differs from the record, when a patch was never applied; a linked package is never patched; an absent package is skipped and an invalid declaration fails; the root declares too; restore all |
StubGeneratorTest |
plain classes without an application class get a stub; deterministic and checked; a stub whose class is gone is removed; no provider removes them all |
DiffTest |
a unified diff with two hunks; the same content, no diff |
Scenario\ChildPluginTest |
the example from start to end (examples/child-plugin/run.sh) |
ChildPluginTest runs Composer for real and needs Packagist (for nikic/php-parser): without network
it fails with Composer's exit code 100.
Propose a change docs/testing.md
Verified, and not
What the tests prove
Run on 2026-10-27 in omnibridge-composer:php8.4, the checkout's own vendor/, no network: 48
tests, 121 assertions, 47 green; Scenario\ChildPluginTest failed with Composer's exit code 100, the
network being off (it installs nikic/php-parser from Packagist). The same scenario was then run by
hand with the engine's own vendor/nikic/php-parser as a path repository and Packagist turned off,
and went through every step, its output pasted in Installation.
Verified elsewhere
- omnisense/omnibase 4.0, the first real child: its stub providers (
App\stubs ofBase\Entity,Enum,Form,Notifier,Repository) and its patch of symfony/process (process-timeout-from-env), on its harness and its sites; a site's commit after the Omnisense rename (nakaya-gourmet 732436b, 2026-10-26) statesomnibridge:patchesandomnibridge:stubs --checkgreen.
What is not verified
- Composer versions other than the image's (composer-plugin-api ^2.3 is admitted).
- PHP 8.2 and 8.3: composer.json admits them; the image is PHP 8.4.
- Windows: not tried.
- A
classpatch or a stub provider that needs a dependency of its child: the engine loads the child's own autoload entries only (A child).