Aller au contenu

omnibridge/composer

A Composer plugin that knows no package: the patches and the stub providers the installed packages declare in extra.omnibridge, applied after install and update, restored when they no longer apply, recorded with checksums and checked in CI.

Étage
Pont
Version
1.x
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnibridge/composer:1.x-dev

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 install and update (and require, remove, reinstall), on the file as published; written back as published when the declaring package leaves, the target leaves, or versions / php stop matching; applying twice changes nothing. Strict: a patch that no longer finds its target fails the command with its id and its why, 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 in originals/. No date, no random id: the same lock gives the same record.
  • Stubs - providers (StubInterface; AbstractStub mirrors a namespace's classes under another) run after dump-autoload into vendor/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:patches and omnibridge: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.

Proposer une modification 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: Greeter is still final, only Salutation gets a stub), once more after the patches changed a file (Greeter 2: Greeter is no longer final).
  • omnibridge/composer and acme/child-plugin are symlinked path packages (never patched themselves); acme/greeter is mirrored ("symlink": false), so it can be patched.
  • A second composer install leaves vendor/acme and the record identical, byte for byte.
  • Removing the child writes Greeter.php back as published, and the engine, removed with it, removes vendor/composer/omnibridge.

Proposer une modification 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, after composer 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).

Proposer une modification 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.

Proposer une modification 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 published for each recorded file, the stubs and the record removed.

In CI, after composer install:

composer omnibridge:patches --check
composer omnibridge:stubs --check

Proposer une modification 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 --check rely 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

  1. Composer installs or updates the packages, then dumps the autoloader: post-autoload-dump runs the stub providers.
  2. 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.
  3. When a file was patched or written back, the stub providers run again (a class no longer final may now get a stub).
  4. 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.

Proposer une modification 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, erase and their count);
  • 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> (a class patch).

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.

Proposer une modification 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.

Proposer une modification 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 of Base\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) states omnibridge:patches and omnibridge:stubs --check green.

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 class patch or a stub provider that needs a dependency of its child: the engine loads the child's own autoload entries only (A child).

Proposer une modification docs/verified.md