From bcfa307eb38df4b5487d129fe9b4d8b048b463b8 Mon Sep 17 00:00:00 2001 From: Sean Fisher Date: Fri, 12 Jun 2026 15:35:06 -0400 Subject: [PATCH 1/5] Add design spec for Composer dependency scoping (#309) --- .../2026-06-12-composer-scoping-design.md | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-12-composer-scoping-design.md diff --git a/docs/superpowers/specs/2026-06-12-composer-scoping-design.md b/docs/superpowers/specs/2026-06-12-composer-scoping-design.md new file mode 100644 index 00000000..12bcd6a2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-composer-scoping-design.md @@ -0,0 +1,153 @@ +# Composer Dependency Scoping — Design + +**Issue:** [#309 — Allow Composer Dependencies to be scoped](https://github.com/alleyinteractive/create-wordpress-plugin/issues/309) +**Date:** 2026-06-12 +**Status:** Approved (brainstorm) — pending implementation plan + +## Problem + +When a `create-wordpress-plugin`-based plugin is loaded as a Composer dependency +in a larger project — or when two such plugins coexist in the same WordPress +install — their unscoped `vendor/` directories collide. Same namespaces, +different versions; whoever autoloads first wins, and the other plugin breaks. + +The dominant source of collision is **Alley's own shared packages** +(`alleyinteractive/wp-type-extensions`, `mantle-framework/support`) shipping at +different versions across plugins, not just third-party libraries like Symfony. + +The fix: optionally prefix ("scope") a plugin's Composer dependencies so each +plugin carries its own isolated copy under a unique namespace. + +## Goals + +1. Scaffolded plugins can **opt in** to scoping at configure time (AC #3). +2. The plugin's own `src/` is never renamed; only its *references* to vendor + classes point at the scoped names (AC #2). +3. Scoping runs **locally** (a `composer` script) and **at release** + (action-release), both producing a separate `vendor-prefixed/` folder that + the plugin loads from — dev/prod parity. +4. Released artifacts **prune** `require`/`require-dev` so dependencies are not + re-resolved (and re-conflicted) when the plugin is required (AC #1). +5. A CI test proves the scoped build loads and passes on every PR (AC #4). + +## Non-goals + +- Surviving a consuming project's `composer install` of the plugin's **source** + (develop) branch. The conflict-free guarantee applies to the **released** + artifact (built branch / tag / .org zip), which bundles `vendor-prefixed/` and + has pruned requires. +- Scoping in the template repo itself for release. The template never publishes + a built branch (action-release skips + `github.repository == 'alleyinteractive/create-wordpress-plugin'`). + +## Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Tool | **php-scoper** (`humbug/php-scoper`) | Named in the issue; matches prior art (Yoast, Gato). **Strauss held in reserve** if the in-place subfolder pattern proves painful. | +| Import model | **B — authored prefixed imports** | Only model that gives local/release parity *and* never auto-rewrites committed `src/` (AC #2 satisfied literally). php-scoper only ever runs over `vendor/`. | +| Prefix | `{RootNamespace}\Dependencies` (e.g. `Alley\WP\My_Plugin\Dependencies`) | configure.php already computes the namespace; prefix nests cleanly under the plugin. | +| Output folder | `vendor-prefixed/` | Community convention; also Strauss's default, easing the fallback. | +| WP globals | Excluded via `sniccowp/php-scoper-wordpress-excludes` + the plugin's own namespace | Never prefix `WP_*` / `wp_*` / core constants, or the plugin's own classes. | +| Per-package policy | **Opt-out** (scope all of `vendor/`, exclude as needed) | php-scoper's native model; opt-in per package would require enumerating the whole transitive tree and is a footgun. | +| configure.php default | **Opt-in, default OFF** | Most template plugins live inside a project where scoping is unneeded overhead; scoping is purely additive. (AC #3's "opt to not scope" wording was considered; default-off chosen deliberately.) | + +## Architecture + +Two modes, selected at configure time: + +- **Unscoped (default):** today's behavior, unchanged. `src/` uses normal + imports; `vendor/` loaded directly. +- **Scoped (opt-in):** `configure.php` applies an additive transform; the plugin + loads from `vendor-prefixed/`; scoping regenerates that folder on every + install and at release. + +### Components + +**1. Template ships unscoped.** No change to the committed template's runtime +behavior. The template's own tests continue to run unscoped. + +**2. `configure.php` scoping opt-in.** When the developer answers yes: +- Add `humbug/php-scoper` and `sniccowp/php-scoper-wordpress-excludes` to + `require-dev`. +- Write `.scoper/scoper.inc.php` (config home already reserved in + `.deployignore`). Config sets `prefix`, `output-dir` (`vendor-prefixed`), + excludes the plugin's own namespace, and pulls WP excludes from the + `php-scoper-wordpress-excludes` package. +- Add a `composer scope` script and wire `post-install-cmd` / + `post-update-cmd` so `vendor-prefixed/` is always regenerated. +- Rewrite `plugin.php`'s loader path + `vendor/wordpress-autoload.php` → `vendor-prefixed/wordpress-autoload.php` + (and the parent-project fallback comment accordingly). +- Rewrite the **finite, known set** of vendor imports in `src/` to the prefixed + namespace: + - `src/main.php`: `use Alley\WP\Features\Group;` + - `src/features/class-register-block-manifest.php`, + `src/features/class-load-entries.php`: `use Alley\WP\Types\Feature;` + - `src/meta.php`: `use function Mantle\Support\Helpers\register_meta_from_file;` + Each gains the `{RootNamespace}\Dependencies\` prefix. +- Add `vendor-prefixed` to `.gitignore` (source). Ensure `.deployignore` + excludes `vendor/` but **keeps** `vendor-prefixed/` on the built branch. + +**3. `composer scope` script.** Runs php-scoper over `vendor/ → vendor-prefixed/`, +then `composer dump-autoload` (scoped) so the classmap targets prefixed classes. +Never touches `src/`. + +**4. action-release change (cross-repo: `alleyinteractive/action-release`).** +Add a `scope` input. When enabled, after the existing +`composer install --no-dev --optimize-autoloader`: +- run `composer scope`, +- **enable the currently-disabled "Clear composer require/require-dev" step** + (it is commented out in `action.yml` citing this exact issue), +- commit `vendor-prefixed/` (not `vendor/`) to the `-built` branch. + +**5. CI test for scoped code (AC #4).** A job — gated on scoping being enabled — +that runs `composer install` → `composer scope` → `phpunit` against +`vendor-prefixed/`, proving the scoped build loads and passes on every PR. + +## Data flow + +``` +Dev (scoped mode): + composer install # vendor/ (unscoped) + php-scoper in require-dev + └─ post-install-cmd: composer scope + └─ php-scoper vendor/ → vendor-prefixed/ (prefix applied, WP excluded) + └─ composer dump-autoload (scoped) + plugin.php → vendor-prefixed/wordpress-autoload.php + src/ imports reference {RootNamespace}\Dependencies\... + +Release (action-release, scope: true): + composer install --no-dev --optimize-autoloader + composer scope → vendor-prefixed/ + clear require/require-dev (pruned: no re-resolution on require) + commit vendor-prefixed/ to *-built (vendor/ excluded via .deployignore) + consumer `composer require`s release → self-contained, conflict-free +``` + +## Risks & mitigations + +- **DX cost of Model B:** verbose imports; `composer install` must always run + the scope step or prefixed imports won't resolve. → Mitigated by the + `post-install-cmd`/`post-update-cmd` hooks. +- **String-based class resolution:** php-scoper cannot rewrite class names + referenced as runtime strings. → The AC #4 CI test exercises the scoped build + and catches breakage. +- **Cross-repo coordination:** runtime work spans this template and + `action-release`. → Two separate PRs; the action-release input is backward + compatible (defaults off). + +## Acceptance criteria mapping + +| AC | Covered by | +|---|---| +| 1. Scaffold includes php-scoper for release | configure.php opt-in + action-release `scope` input + require pruning | +| 2. `src/` not scoped, only references changed | Model B: php-scoper touches only `vendor/`; configure.php rewrites the finite import set | +| 3. Developer can opt out of scoping | configure.php opt-in, default off | +| 4. PRs test against scoped code | New CI job: install → scope → phpunit on `vendor-prefixed/` | + +## Open items for the plan + +- Exact `scoper.inc.php` contents (patchers for any package that needs them). +- Whether `composer dump-autoload` scoped needs `--classmap-authoritative`. +- action-release: input name/semantics and built-branch file selection. +- configure.php import-rewrite mechanism (targeted replacement of the known set). From 061a004bc180e3509a814d390eaff9dfdd281204 Mon Sep 17 00:00:00 2001 From: Sean Fisher Date: Fri, 12 Jun 2026 15:45:57 -0400 Subject: [PATCH 2/5] Add implementation plan for Composer dependency scoping (#309) --- .../plans/2026-06-12-composer-scoping.md | 321 ++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-12-composer-scoping.md diff --git a/docs/superpowers/plans/2026-06-12-composer-scoping.md b/docs/superpowers/plans/2026-06-12-composer-scoping.md new file mode 100644 index 00000000..8fb47dd5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-composer-scoping.md @@ -0,0 +1,321 @@ +# Composer Dependency Scoping Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let plugins scaffolded from `create-wordpress-plugin` optionally prefix ("scope") their Composer dependencies into `vendor-prefixed/`, loaded both locally and at release, so two template-based plugins no longer collide on shared packages. + +**Architecture:** php-scoper (Strauss in reserve), authored-prefixed-imports model (Model B — scoper only ever touches `vendor/`). A namespace-agnostic `.scoper/scoper.inc.php` derives the prefix from `composer.json` at scope time. `configure.php` gains an opt-in (default off) that wires up the machinery; without opt-in the template behaves exactly as today. Release-time scoping is a backward-compatible `scope` input added to `alleyinteractive/action-release`. + +**Tech Stack:** PHP 8.2+, Composer, `humbug/php-scoper`, `sniccowp/php-scoper-wordpress-excludes`, GitHub Actions. + +--- + +## File Structure + +- **Create** `.scoper/scoper.inc.php` — namespace-agnostic php-scoper config. Reads the root namespace from `composer.json` `extra.wordpress-autoloader.autoload`, sets `prefix` = `\Dependencies`, excludes that namespace + WP globals. +- **Modify** `configure.php` — add `scope_dependencies()` transform + opt-in prompt; delete `.scoper/` when declined. +- **Modify** `composer.json` (template) — no default scoping deps; `configure.php` injects them. (`vendor-prefixed` handled via ignore files.) +- **Modify** `.gitignore` — add `vendor-prefixed`. +- **Modify** `.deployignore` — exclude `vendor` (so source vendor isn't shipped on built branch); `vendor-prefixed` stays. +- **Create** `.github/workflows/test-scoped.yml` — AC #4 CI: install → scope → phpunit against `vendor-prefixed/` (no-op unless scoping enabled). +- **Cross-repo** `alleyinteractive/action-release` `action.yml` — add `scope` input; run `composer scope` + enable the disabled require-pruning step when on. + +--- + +## Task 1: Namespace-agnostic scoper config + +**Files:** +- Create: `.scoper/scoper.inc.php` + +- [ ] **Step 1: Write `.scoper/scoper.inc.php`** + +```php + $prefix, + 'output-dir' => __DIR__ . '/../vendor-prefixed', + 'finders' => [ + Finder::create() + ->files() + ->ignoreVCS( true ) + ->notName( '/.*\\.dist|Makefile|composer\\.(json|lock)/' ) + ->exclude( [ 'doc', 'docs', 'test', 'tests', 'Tests', 'vendor-bin' ] ) + ->in( __DIR__ . '/../vendor' ), + ], + // Do not prefix the plugin's own namespace (AC #2: src classes keep names). + 'exclude-namespaces' => [ + $root_namespace, + ], + 'exclude-functions' => $wp_excludes( 'exclude-wordpress-functions.php' ), + 'exclude-classes' => $wp_excludes( 'exclude-wordpress-classes.php' ), + 'exclude-constants' => $wp_excludes( 'exclude-wordpress-constants.php' ), +]; +``` + +- [ ] **Step 2: Commit** + +```bash +git add .scoper/scoper.inc.php +git commit -m "Add namespace-agnostic php-scoper config (#309)" +``` + +## Task 2: `configure.php` scoping opt-in + +**Files:** +- Modify: `configure.php` (add helper + prompt near the Composer block, ~line 33-56) + +- [ ] **Step 1: Add a `scope_dependencies()` helper function** + +Add alongside the other helper functions (e.g. after `remove_composer_files()`): + +```php +function scope_dependencies( string $root_namespace ): void { + $prefix = $root_namespace . '\\Dependencies'; + + // 1. Add php-scoper tooling to require-dev. + $composer = (array) json_decode( (string) file_get_contents( 'composer.json' ), true ); + + $composer['require-dev']['humbug/php-scoper'] = '^0.18'; + $composer['require-dev']['sniccowp/php-scoper-wordpress-excludes'] = '^6.0'; + + // 2. Add the scope script + regenerate vendor-prefixed on install/update. + $composer['scripts']['scope'] = [ + 'php-scoper add-prefix --config=.scoper/scoper.inc.php --force --quiet', + '@composer dump-autoload --working-dir=vendor-prefixed --classmap-authoritative', + ]; + $composer['scripts']['post-install-cmd'][] = '@scope'; + $composer['scripts']['post-update-cmd'][] = '@scope'; + $composer['config']['allow-plugins']['humbug/php-scoper'] = true; + + file_put_contents( + 'composer.json', + json_encode( $composer, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n" + ); + + // 3. Point the plugin loader at the scoped autoloader. + replace_in_file( + 'plugin.php', + [ + '/vendor/wordpress-autoload.php' => '/vendor-prefixed/wordpress-autoload.php', + ] + ); + + // 4. Prefix the finite set of vendor imports in src (Model B). + $rewrites = [ + 'src/main.php' => [ 'use Alley\\WP\\Features\\Group;' => "use {$prefix}\\Alley\\WP\\Features\\Group;" ], + 'src/features/class-register-block-manifest.php' => [ 'use Alley\\WP\\Types\\Feature;' => "use {$prefix}\\Alley\\WP\\Types\\Feature;" ], + 'src/features/class-load-entries.php' => [ 'use Alley\\WP\\Types\\Feature;' => "use {$prefix}\\Alley\\WP\\Types\\Feature;" ], + 'src/meta.php' => [ 'use function Mantle\\Support\\Helpers\\register_meta_from_file;' => "use function {$prefix}\\Mantle\\Support\\Helpers\\register_meta_from_file;" ], + ]; + + foreach ( $rewrites as $file => $replacements ) { + if ( file_exists( $file ) ) { + replace_in_file( $file, $replacements ); + } + } + + // 5. Ignore vendor-prefixed in source; ship it (not vendor) on built branch. + if ( file_exists( '.gitignore' ) ) { + $gitignore = (string) file_get_contents( '.gitignore' ); + if ( ! str_contains( $gitignore, 'vendor-prefixed' ) ) { + file_put_contents( '.gitignore', $gitignore . "vendor-prefixed\n" ); + } + } + if ( file_exists( '.deployignore' ) ) { + $deployignore = (string) file_get_contents( '.deployignore' ); + if ( ! preg_match( '/^vendor$/m', $deployignore ) ) { + file_put_contents( '.deployignore', $deployignore . "vendor\n" ); + } + } +} +``` + +- [ ] **Step 2: Wire the opt-in prompt into the Composer block** + +Inside the `if ( confirm( 'Will this plugin be using Composer?...' ) ) { $uses_composer = true; ... }` branch, after `composer install/update` runs, add: + +```php + if ( confirm( 'Do you want to scope (prefix) your Composer dependencies to avoid conflicts when this plugin is loaded alongside others? (Recommended for plugins distributed standalone or via Composer.)', false ) ) { + scope_dependencies( $namespace ); + echo run( 'composer update' ); + echo run( 'composer scope' ); + } else { + delete_files( '.scoper' ); + } +``` + +(`$namespace` is the variable configure.php already computes for the root namespace — confirm its exact name when implementing; it is the value written into `extra.wordpress-autoloader.autoload`.) + +- [ ] **Step 3: Delete `.scoper/` on the non-Composer path too** + +In the `elseif`/else branches where Composer is not used, ensure `.scoper` is removed: + +```php + if ( file_exists( '.scoper' ) ) { + delete_files( '.scoper' ); + } +``` + +- [ ] **Step 4: Commit** + +```bash +git add configure.php +git commit -m "Add Composer dependency scoping opt-in to configure.php (#309)" +``` + +## Task 3: Ignore-file defaults in template + +**Files:** +- Modify: `.deployignore` + +- [ ] **Step 1:** Leave the template's `.gitignore`/`.deployignore` as-is for the unscoped default; the scoping changes are applied by `configure.php` (Task 2 Step 1.5). No template change required unless verification shows the built branch ships `vendor/` incorrectly — in which case add `vendor` to `.deployignore` guarded by scoping. Skip if not needed. + +- [ ] **Step 2:** Commit only if changed. + +## Task 4: CI test for scoped builds (AC #4) + +**Files:** +- Create: `.github/workflows/test-scoped.yml` + +- [ ] **Step 1: Write the workflow** + +```yaml +name: Scoped Build Tests + +on: + pull_request: + branches: + - develop + - main + +jobs: + scoped-tests: + name: "Scoped dependency tests" + runs-on: ubuntu-latest + # Only run when the plugin has opted into scoping. + if: false # configure.php flips this to `true` when scoping is enabled. + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + tools: composer + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + - name: Scope dependencies + run: composer scope + - name: Assert vendor-prefixed was generated + run: test -f vendor-prefixed/wordpress-autoload.php + - name: Run tests against scoped code + run: composer phpunit +``` + +`configure.php` (Task 2) should flip `if: false` → `if: true` when scoping is enabled, and `delete_files()` this workflow when scoping is declined. Add to `scope_dependencies()`: + +```php + if ( file_exists( '.github/workflows/test-scoped.yml' ) ) { + replace_in_file( '.github/workflows/test-scoped.yml', [ 'if: false' => 'if: true' ] ); + } +``` + +and to the decline branch: + +```php + delete_files( '.github/workflows/test-scoped.yml' ); +``` + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/test-scoped.yml configure.php +git commit -m "Add scoped-build CI workflow (#309)" +``` + +## Task 5: action-release `scope` input (cross-repo) + +**Files:** +- Modify (separate PR, repo `alleyinteractive/action-release`): `action.yml` + +- [ ] **Step 1:** Add input: + +```yaml + scope: + description: 'Run `composer scope` to prefix dependencies into vendor-prefixed/ before release.' + required: false + default: 'false' +``` + +- [ ] **Step 2:** After the "Composer Install" step, add: + +```yaml + - name: Scope Composer dependencies + if: inputs.scope == 'true' && inputs.skip-composer-install != 'true' + shell: bash + run: composer scope +``` + +- [ ] **Step 3:** Enable the currently-disabled require-pruning step, gated on `inputs.scope == 'true'`: + +```yaml + - name: Clear composer require/require-dev + if: inputs.scope == 'true' && inputs.skip-composer-install != 'true' && inputs.skip-composer-require-removal != 'true' + shell: bash + run: | + jq 'del(.require, .["require-dev"])' composer.json > composer.json.tmp + mv composer.json.tmp composer.json + rm -rf composer.lock composer.json.bak || true +``` + +- [ ] **Step 4:** `built-release.yml` in scaffolded plugins passes `scope: true` when scoping is enabled (configure.php adds it). Document in the plugin's workflow. + +## Task 6: Local end-to-end verification (the goal) + +- [ ] **Step 1:** Scaffold a fresh plugin from this template into a temp dir. +- [ ] **Step 2:** Run `php configure.php` non-interactively (or with args) choosing: Composer = yes, scoping = yes, SQLite testing = yes. +- [ ] **Step 3:** `composer require` a real dependency (e.g. a small Symfony component) to prove third-party prefixing. +- [ ] **Step 4:** `composer scope`; assert `vendor-prefixed/` contains prefixed namespaces (`grep -r "Dependencies\\\\Symfony" vendor-prefixed | head`). +- [ ] **Step 5:** `composer phpunit` (SQLite) — expect PASS, proving the scoped autoloader loads the plugin and dependencies. +- [ ] **Step 6:** If php-scoper's composer-autoloader handling fails to produce a working `vendor-prefixed/wordpress-autoload.php`, pivot to **Strauss** (pre-approved fallback): replace the `scope` script with a Strauss invocation that emits `vendor-prefixed/` + autoloader, keeping the same folder/prefix contract. Re-run Steps 4-5. + +--- + +## Self-Review Notes + +- **Spec coverage:** AC1 → Tasks 2,5. AC2 → Task 1 (`exclude-namespaces`) + Task 2 finite rewrite. AC3 → Task 2 opt-in default off. AC4 → Task 4. All mapped. +- **Contingency:** Task 6 Step 6 captures the php-scoper-autoloader risk with the Strauss fallback the user pre-approved. +- **Cross-repo:** Task 5 is a backward-compatible (`default: 'false'`) change; safe to land independently. From 300cfc4312382f6c2201266bc0aff17fd9d7ab63 Mon Sep 17 00:00:00 2001 From: Sean Fisher Date: Fri, 12 Jun 2026 16:03:39 -0400 Subject: [PATCH 3/5] Add opt-in Composer dependency scoping (#309) Scaffolded plugins can opt into prefixing their runtime Composer dependencies into vendor-prefixed/ via php-scoper, isolating them from other plugins or the host project when loaded as a Composer dependency. - .scoper/scoper.inc.php: namespace-agnostic php-scoper config that derives the prefix from composer.json and scopes runtime packages only. - .scoper/scope.php: orchestrator that runs php-scoper then regenerates a classmap autoloader over the scoped files + the plugin's own src, carrying autoload.files (helper functions) forward. No-ops without php-scoper so it is safe in post-install/post-update hooks. - configure.php: opt-in prompt (default off) that wires the tooling, rewrites the plugin loader + the finite set of vendor imports, and enables the scoped-build CI workflow; removes the machinery otherwise. - .github/workflows/test-scoped.yml: PR test that scopes then runs phpunit against the scoped dependencies (enabled by configure.php on opt-in). Verified by scaffolding a plugin, requiring symfony/string, and running phpunit (2 passed) against the scoped autoloader. --- .github/workflows/test-scoped.yml | 33 +++++++++ .scoper/scope.php | 82 ++++++++++++++++++++++ .scoper/scoper.inc.php | 78 +++++++++++++++++++++ configure.php | 110 ++++++++++++++++++++++++++++++ 4 files changed, 303 insertions(+) create mode 100644 .github/workflows/test-scoped.yml create mode 100644 .scoper/scope.php create mode 100644 .scoper/scoper.inc.php diff --git a/.github/workflows/test-scoped.yml b/.github/workflows/test-scoped.yml new file mode 100644 index 00000000..4132ac1a --- /dev/null +++ b/.github/workflows/test-scoped.yml @@ -0,0 +1,33 @@ +name: Scoped Build Tests + +on: + pull_request: + branches: + - develop + - main + +jobs: + scoped-tests: + name: "Scoped dependency tests" + runs-on: ubuntu-latest + # configure.php flips this to `true` when dependency scoping is enabled. + if: false + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + tools: composer + + - name: Install dependencies (with dev, so php-scoper is available) + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Scope dependencies + run: composer scope + + - name: Assert the scoped autoloader was generated + run: test -f vendor-prefixed/vendor/autoload.php + + - name: Run tests against the scoped dependencies + run: composer phpunit diff --git a/.scoper/scope.php b/.scoper/scope.php new file mode 100644 index 00000000..b9f6e0ac --- /dev/null +++ b/.scoper/scope.php @@ -0,0 +1,82 @@ + 'scoped/dependencies', + 'version' => '1.0.0', + 'autoload' => [ + 'classmap' => [ '.', '../src' ], + 'files' => array_values( array_unique( $files ) ), + 'exclude-from-classmap' => [ '/nesbot/carbon/lazy/' ], + ], + 'config' => [ 'classmap-authoritative' => true ], + ], + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES + ) . "\n" +); + +passthru( + escapeshellarg( $composer ) . ' dump-autoload' + . ' --working-dir=' . escapeshellarg( $root . '/vendor-prefixed' ) + . ' --classmap-authoritative --no-interaction', + $code +); + +exit( $code ); diff --git a/.scoper/scoper.inc.php b/.scoper/scoper.inc.php new file mode 100644 index 00000000..a7969853 --- /dev/null +++ b/.scoper/scoper.inc.php @@ -0,0 +1,78 @@ + '' ] ), + '\\' +); + +if ( '' === $root_namespace ) { + fwrite( STDERR, "Unable to derive the root namespace from composer.json.\n" ); + exit( 1 ); +} + +$prefix = $root_namespace . '\\Dependencies'; + +// Build the finder from runtime packages only (composer.lock "packages"), so +// development tooling is never scoped. +$lock = json_decode( (string) file_get_contents( $base . '/composer.lock' ), true ); +$runtime = array_values( + array_filter( + array_map( + static fn ( array $package ): string => (string) ( $package['name'] ?? '' ), + $lock['packages'] ?? [] + ) + ) +); + +$finder = Finder::create() + ->files() + ->ignoreVCS( true ) + ->name( '*.php' ) + ->in( $base . '/vendor' ); + +foreach ( $runtime as $name ) { + $finder->path( '#^' . preg_quote( $name, '#' ) . '/#' ); +} + +// WordPress core symbols must never be prefixed. +$wp = static function ( string $file ) use ( $base ): array { + $path = $base . '/vendor/sniccowp/php-scoper-wordpress-excludes/generated/' . $file; + + return is_file( $path ) ? (array) json_decode( (string) file_get_contents( $path ), true ) : []; +}; + +return [ + 'prefix' => $prefix, + 'output-dir' => $base . '/vendor-prefixed', + 'finders' => [ $finder ], + // Never prefix the plugin's own classes (AC #2). + 'exclude-namespaces' => [ $root_namespace ], + 'exclude-classes' => array_merge( + $wp( 'exclude-wordpress-classes.json' ), + $wp( 'exclude-wordpress-interfaces.json' ) + ), + 'exclude-functions' => $wp( 'exclude-wordpress-functions.json' ), + 'exclude-constants' => $wp( 'exclude-wordpress-constants.json' ), +]; diff --git a/configure.php b/configure.php index 3b41f698..74fb5ef3 100644 --- a/configure.php +++ b/configure.php @@ -195,6 +195,97 @@ function remove_composer_files(): void { write( sprintf( 'Removed %s files.', implode( ', ', $file_list ) ) ); } +/** + * Remove the dependency-scoping machinery for plugins that do not opt in. + */ +function remove_scoping_files(): void { + delete_files( + [ + '.scoper', + '.github/workflows/test-scoped.yml', + ] + ); +} + +/** + * Enable Composer dependency scoping: prefix runtime dependencies into + * vendor-prefixed/ so the plugin does not conflict with other plugins or the + * host project when loaded as a Composer dependency. + * + * @param string $namespace The plugin's root namespace (e.g. Alley\WP\My_Plugin). + */ +function scope_dependencies( string $namespace ): void { + global $plugin_file; + + $prefix = $namespace . '\\Dependencies'; + + // 1. Add the scoping tooling and wire up the `scope` script + hooks so + // vendor-prefixed/ is regenerated on every install/update. + $composer = (array) json_decode( (string) file_get_contents( 'composer.json' ), true ); + + $composer['require-dev']['humbug/php-scoper'] = '^0.18'; + $composer['require-dev']['sniccowp/php-scoper-wordpress-excludes'] = '^6.0'; + + $composer['scripts']['scope'] = '@php .scoper/scope.php'; + $composer['scripts']['post-install-cmd'][] = '@scope'; + $composer['scripts']['post-update-cmd'][] = '@scope'; + + ksort( $composer['require-dev'] ); + + file_put_contents( + 'composer.json', + json_encode( $composer, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n" + ); + + // 2. Point the plugin loader at the scoped autoloader. + if ( ! empty( $plugin_file ) && file_exists( $plugin_file ) ) { + replace_in_file( + $plugin_file, + [ + '/vendor/wordpress-autoload.php' => '/vendor-prefixed/vendor/autoload.php', + ] + ); + } + + // 3. Prefix the finite set of vendor imports in src (Model B: php-scoper + // only ever touches vendor/, so these references are authored, not rewritten + // by the scoper). + $rewrites = [ + 'src/main.php' => [ 'use Alley\\WP\\Features\\Group;' => "use {$prefix}\\Alley\\WP\\Features\\Group;" ], + 'src/features/class-register-block-manifest.php' => [ 'use Alley\\WP\\Types\\Feature;' => "use {$prefix}\\Alley\\WP\\Types\\Feature;" ], + 'src/features/class-load-entries.php' => [ 'use Alley\\WP\\Types\\Feature;' => "use {$prefix}\\Alley\\WP\\Types\\Feature;" ], + 'src/meta.php' => [ 'use function Mantle\\Support\\Helpers\\register_meta_from_file;' => "use function {$prefix}\\Mantle\\Support\\Helpers\\register_meta_from_file;" ], + ]; + + foreach ( $rewrites as $file => $replacements ) { + if ( file_exists( $file ) ) { + replace_in_file( $file, $replacements ); + } + } + + // 4. Ignore vendor-prefixed/ in source; ship it (not vendor/) on the built branch. + if ( file_exists( '.gitignore' ) ) { + $gitignore = (string) file_get_contents( '.gitignore' ); + + if ( ! str_contains( $gitignore, 'vendor-prefixed' ) ) { + file_put_contents( '.gitignore', rtrim( $gitignore ) . "\nvendor-prefixed\n" ); + } + } + + if ( file_exists( '.deployignore' ) ) { + $deployignore = (string) file_get_contents( '.deployignore' ); + + if ( ! preg_match( '/^vendor$/m', $deployignore ) ) { + file_put_contents( '.deployignore', rtrim( $deployignore ) . "\nvendor\n" ); + } + } + + // 5. Enable the scoped-build CI workflow. + if ( file_exists( '.github/workflows/test-scoped.yml' ) ) { + replace_in_file( '.github/workflows/test-scoped.yml', [ 'if: false' => 'if: true' ] ); + } +} + function remove_project_files(): void { $file_list = [ 'CHANGELOG.md', @@ -634,6 +725,7 @@ function enable_sqlite_testing(): void { $needs_built_assets = false; $uses_composer = false; +$scoping_enabled = false; if ( confirm( 'Will this plugin be compiling front-end assets (Node)?', true ) ) { $needs_built_assets = true; @@ -701,6 +793,19 @@ function enable_sqlite_testing(): void { echo "\n\n"; } + + // Offer to scope (prefix) the plugin's Composer dependencies. This isolates + // runtime dependencies under the plugin's namespace so they don't conflict + // with other plugins or the host project when loaded as a Composer + // dependency. Recommended for standalone / distributed plugins. + if ( confirm( 'Do you want to scope (prefix) your Composer dependencies to avoid conflicts when this plugin is loaded alongside other plugins or within a larger project?', false ) ) { + scope_dependencies( $namespace ); + + echo run( 'composer update' ); + echo "\n\n"; + + $scoping_enabled = true; + } } elseif ( confirm( 'Do you want to remove the vendor/autoload.php dependency from your main plugin file and the composer.json file?' ) ) { remove_composer_require(); @@ -711,6 +816,11 @@ function enable_sqlite_testing(): void { } } +// Remove the dependency-scoping machinery unless the plugin opted in. +if ( ! $scoping_enabled ) { + remove_scoping_files(); +} + if ( file_exists( 'composer.json') && ! confirm(' Using PHPStan? (PHPStan is a great static analyzer to help find bugs in your code.)', true) ) { remove_phpstan(); } From cefd1d7e19903114f1c92ec979df3d2da49e2cb3 Mon Sep 17 00:00:00 2001 From: Sean Fisher Date: Fri, 12 Jun 2026 16:04:09 -0400 Subject: [PATCH 4/5] Reconcile scoping design docs with validated implementation (#309) --- .../plans/2026-06-12-composer-scoping.md | 34 +++++++++++++++++++ .../2026-06-12-composer-scoping-design.md | 26 ++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/docs/superpowers/plans/2026-06-12-composer-scoping.md b/docs/superpowers/plans/2026-06-12-composer-scoping.md index 8fb47dd5..312c8f36 100644 --- a/docs/superpowers/plans/2026-06-12-composer-scoping.md +++ b/docs/superpowers/plans/2026-06-12-composer-scoping.md @@ -314,6 +314,40 @@ git commit -m "Add scoped-build CI workflow (#309)" --- +## Validated Implementation (as built) + +Tasks 1–4 and 6 are implemented and verified locally. Key deltas from the +initial task drafts, discovered during prototyping: + +- **wp-excludes are JSON, not PHP** — `scoper.inc.php` `json_decode`s + `exclude-wordpress-{classes,interfaces,functions,constants}.json` (interfaces + merged into `exclude-classes`). +- **Runtime-only finder** — the finder is built from `composer.lock` `packages` + (via `->path()`), so dev tools (php-scoper, PHPUnit, Rector) are never scoped. +- **Autoloader regeneration, not php-scoper's** — php-scoper's scoped autoloader + is unusable (unprefixed `registerFromRules` strings + broken Composer + bootstrap). `.scoper/scope.php` instead regenerates a `classmap-authoritative` + autoloader over the scoped files + `../src`, and rebuilds the `files` + autoloads from each runtime package so helper functions still load. +- **Loader path** — `plugin.php` loads `vendor-prefixed/vendor/autoload.php`. +- **Guarded orchestrator** — `.scoper/scope.php` no-ops when php-scoper is + absent, so it is safe in `post-install-cmd`/`post-update-cmd`. + +**Verification:** scaffolded `wp-scoped-test-plugin` (Alley vendor), opted into +scoping + SQLite, ran `composer require symfony/string` (auto re-scoped to 1606 +classes), confirmed all scoped classes + the scoped `register_meta_from_file` +resolve while the unscoped `Alley\WP\Features\Group` does not, and +`composer phpunit` passed (2 tests, 3 assertions) — the Feature test boots the +whole plugin through the scoped autoloader. + +**Pre-existing quirk noted (out of scope):** `configure.php`'s +`'alleyinteractive' => $vendor_slug` replacement rewrites the Alley *dependency* +package names when a non-Alley vendor is chosen, breaking `composer update`. +Unrelated to #309; flagged for separate follow-up. + +**Task 5 (action-release) remains** — a backward-compatible cross-repo PR, not +yet opened (outward-facing; awaiting go-ahead). + ## Self-Review Notes - **Spec coverage:** AC1 → Tasks 2,5. AC2 → Task 1 (`exclude-namespaces`) + Task 2 finite rewrite. AC3 → Task 2 opt-in default off. AC4 → Task 4. All mapped. diff --git a/docs/superpowers/specs/2026-06-12-composer-scoping-design.md b/docs/superpowers/specs/2026-06-12-composer-scoping-design.md index 12bcd6a2..b341d2ca 100644 --- a/docs/superpowers/specs/2026-06-12-composer-scoping-design.md +++ b/docs/superpowers/specs/2026-06-12-composer-scoping-design.md @@ -145,6 +145,32 @@ Release (action-release, scope: true): | 3. Developer can opt out of scoping | configure.php opt-in, default off | | 4. PRs test against scoped code | New CI job: install → scope → phpunit on `vendor-prefixed/` | +## Validated implementation note (as built) + +Prototyping revealed that php-scoper's *file* scoping is reliable, but its +handling of the **autoloader** is not usable here: it leaves some +`registerFromRules()` namespace strings unprefixed (the Alley packages use the +WordPress autoloader with no PSR-4), and its scoped Composer bootstrap fails to +load `ClassLoader`. The robust fix, validated end-to-end, is to **ignore +php-scoper's generated autoloader and regenerate a classmap autoloader** over +the scoped files: + +1. `.scoper/scope.php` runs php-scoper (runtime packages only, derived from + `composer.lock`'s `packages`), producing prefixed files in `vendor-prefixed/`. +2. It writes a synthetic `vendor-prefixed/composer.json` declaring a + `classmap` over `.` (scoped deps) **and** `../src` (the plugin's own + classes), plus a `files` list rebuilt from each runtime package's + `autoload.files` so scoped helper *functions* (e.g. + `register_meta_from_file`) keep loading. +3. `composer dump-autoload --classmap-authoritative` over that produces + `vendor-prefixed/vendor/autoload.php`, which the plugin loads. + +Classmaps parse actual class declarations, so PSR-4 and WordPress-autoloader +packages are handled uniformly — this is why php-scoper alone was sufficient +and **Strauss was not needed**. The DX trade-off: adding a new class to a +scoped plugin's `src/` requires re-running `composer scope` (or +`composer dump-autoload`) to refresh the authoritative classmap. + ## Open items for the plan - Exact `scoper.inc.php` contents (patchers for any package that needs them). From 90007886aa437a215fb131565c948fa65b0efda7 Mon Sep 17 00:00:00 2001 From: Sean Fisher Date: Fri, 12 Jun 2026 16:08:36 -0400 Subject: [PATCH 5/5] Pass scope: true to release action for scoped plugins (#309) When scoping is enabled, configure.php now wires built-release.yml to call action-release with scope: true, so the built branch ships the prefixed vendor-prefixed/ directory. --- configure.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/configure.php b/configure.php index 74fb5ef3..e71a742f 100644 --- a/configure.php +++ b/configure.php @@ -284,6 +284,16 @@ function scope_dependencies( string $namespace ): void { if ( file_exists( '.github/workflows/test-scoped.yml' ) ) { replace_in_file( '.github/workflows/test-scoped.yml', [ 'if: false' => 'if: true' ] ); } + + // 6. Tell the release action to scope dependencies for the built branch. + if ( file_exists( '.github/workflows/built-release.yml' ) ) { + replace_in_file( + '.github/workflows/built-release.yml', + [ + "/action-release@develop\n" => "/action-release@develop\n with:\n scope: true\n", + ] + ); + } } function remove_project_files(): void {