Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/test-scoped.yml
Original file line number Diff line number Diff line change
@@ -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
82 changes: 82 additions & 0 deletions .scoper/scope.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php
/**
* Orchestrates Composer dependency scoping.
*
* 1. Runs php-scoper to prefix runtime dependencies into vendor-prefixed/.
* 2. Regenerates a classmap autoloader over the scoped files + the plugin's
* own src/ (classmaps handle PSR-4 and WordPress-autoloader packages
* alike), and re-declares each runtime package's `autoload.files` (helper
* functions) so they continue to load eagerly under the new prefix.
*
* No-ops cleanly when php-scoper is not installed (e.g. a `--no-dev` install),
* so it is safe to wire into Composer's post-install / post-update hooks.
*
* @package create-wordpress-plugin
*
* phpcs:disable
*/

declare(strict_types=1);

$root = dirname( __DIR__ );
$scoper = $root . '/vendor/bin/php-scoper';
$composer = getenv( 'COMPOSER_BINARY' ) ?: 'composer';

if ( ! is_file( $scoper ) ) {
fwrite( STDOUT, "php-scoper is not installed; skipping dependency scoping.\n" );
exit( 0 );
}

passthru(
escapeshellarg( $scoper ) . ' add-prefix'
. ' --config=' . escapeshellarg( $root . '/.scoper/scoper.inc.php' )
. ' --force --quiet',
$code
);

if ( 0 !== $code ) {
exit( $code );
}

// Carry each runtime package's autoload.files forward so scoped helper
// functions (e.g. Mantle support helpers) keep loading eagerly.
$lock = json_decode( (string) file_get_contents( $root . '/composer.lock' ), true );
$files = [];

foreach ( $lock['packages'] ?? [] as $package ) {
$name = (string) ( $package['name'] ?? '' );

foreach ( (array) ( $package['autoload']['files'] ?? [] ) as $file ) {
$relative = $name . '/' . ltrim( (string) $file, '/' );

if ( is_file( $root . '/vendor-prefixed/' . $relative ) ) {
$files[] = './' . $relative;
}
}
}

file_put_contents(
$root . '/vendor-prefixed/composer.json',
json_encode(
[
'name' => '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 );
78 changes: 78 additions & 0 deletions .scoper/scoper.inc.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php
/**
* php-scoper configuration.
*
* Namespace-agnostic: the prefix is derived from the plugin's own root
* namespace (declared in composer.json under
* extra.wordpress-autoloader.autoload) with a `\Dependencies` suffix, so this
* file works unchanged for any plugin scaffolded from create-wordpress-plugin.
*
* Only runtime dependencies (composer.lock "packages") are scoped; development
* tools such as php-scoper, PHPUnit and Rector are never prefixed.
*
* @package create-wordpress-plugin
*
* phpcs:disable
*/

declare(strict_types=1);

use Isolated\Symfony\Component\Finder\Finder;

$base = __DIR__ . '/..';
$composer = json_decode( (string) file_get_contents( $base . '/composer.json' ), true );

$root_namespace = rtrim(
(string) array_key_first( $composer['extra']['wordpress-autoloader']['autoload'] ?? [ '' => '' ] ),
'\\'
);

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' ),
];
120 changes: 120 additions & 0 deletions configure.php
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,107 @@ 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' ] );
}

// 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 {
$file_list = [
'CHANGELOG.md',
Expand Down Expand Up @@ -634,6 +735,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;
Expand Down Expand Up @@ -701,6 +803,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();

Expand All @@ -711,6 +826,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();
}
Expand Down
Loading
Loading