diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c27096e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,21 @@ +* text=auto eol=lf + +/.github export-ignore +/tests export-ignore +/docs export-ignore +/AGENTS.md export-ignore +/CONTRIBUTING.md export-ignore +/qa-adoption.json export-ignore +/phpcs.xml export-ignore +/phpcs.xml.dist export-ignore +/phpstan.neon export-ignore +/phpstan.neon.dist export-ignore +/phpunit*.xml* export-ignore +/.php-cs-fixer* export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore +/.env* export-ignore +/auth.json export-ignore +/.npmrc export-ignore +/composer.review*.json export-ignore +/composer.review*.lock export-ignore diff --git a/.github/workflows/dependency-canary.yml b/.github/workflows/dependency-canary.yml new file mode 100644 index 0000000..c716908 --- /dev/null +++ b/.github/workflows/dependency-canary.yml @@ -0,0 +1,18 @@ +name: Dependency compatibility + +on: + schedule: + - cron: '26 4 * * 3' + workflow_dispatch: + +permissions: + contents: read + +jobs: + compatibility: + permissions: + contents: read + issues: write + uses: sympress/workflows/.github/workflows/dependency-canary.yml@177fa0d727b278d2103052ec77c102b4a4c492a0 + with: + php_version: '8.5' diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 98a7111..f43b763 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -12,10 +12,10 @@ permissions: jobs: workflows: - uses: sympress/workflows/.github/workflows/lint-workflows.yml@v1 + uses: sympress/workflows/.github/workflows/lint-workflows.yml@177fa0d727b278d2103052ec77c102b4a4c492a0 qa: - uses: sympress/workflows/.github/workflows/sympress-qa.yml@v1 + uses: sympress/workflows/.github/workflows/sympress-qa.yml@177fa0d727b278d2103052ec77c102b4a4c492a0 with: php_version: '8.5' secrets: diff --git a/README.md b/README.md index afff22b..1fc2c66 100644 --- a/README.md +++ b/README.md @@ -71,3 +71,19 @@ repository. ## License This package is licensed under `GPL-2.0-or-later`. + +## Generated literals, output paths, and Process + +PHP skeleton values use exported literals; JavaScript/TSX values use JSON-based +quoting with escaped HTML delimiters and Unicode separators. Identifiers are +validated separately, and generated frontend code compares data-block attribute +values without interpolating user text into a CSS selector. Root project packages +and nested packages both generate relative output paths. MakePackage --path must +be relative and remain inside the canonical project, including nonexistent children +of symlinks; traversal and absolute paths are rejected and checked again before +writing. Project/cache parents must remain trusted while generation runs. + +This bundle declares the real symfony/process ^8.1 dependency and integrates with +symfony/maker-bundle ^1.68. It no longer provides a fictitious Symfony Process +version through wp-cli/process. Generated PHP, webpack JavaScript and TSX/TS must +be syntax checked alongside existing fixture contracts before release. diff --git a/Resources/skeleton/asset_entry/AssetConfigLoader.tpl.php b/Resources/skeleton/asset_entry/AssetConfigLoader.tpl.php index 06fd508..6fcc4bf 100644 --- a/Resources/skeleton/asset_entry/AssetConfigLoader.tpl.php +++ b/Resources/skeleton/asset_entry/AssetConfigLoader.tpl.php @@ -9,7 +9,7 @@ final class implements ConfigLoaderInterface { - public const HANDLE = ''; + public const HANDLE = ; public function accepts(Asset $asset): bool { diff --git a/Resources/skeleton/block/Block.tpl.php b/Resources/skeleton/block/Block.tpl.php index 8b2cb64..949a9a6 100644 --- a/Resources/skeleton/block/Block.tpl.php +++ b/Resources/skeleton/block/Block.tpl.php @@ -14,12 +14,12 @@ final class implements - public const JS_CONFIG_VAR = ''; + public const JS_CONFIG_VAR = ; public function name(): string { - return ''; + return ; } /** @@ -29,17 +29,17 @@ public function args(): array { return [ 'api_version' => 2, - 'title' => __('', ''), - 'description' => __('', ''), - 'category' => '', - 'icon' => '', + 'title' => __(, ), + 'description' => __(, ), + 'category' => , + 'icon' => , 'supports' => [ 'html' => false, ], 'attributes' => [], - 'editor_script' => '', + 'editor_script' => , - 'script' => '', + 'script' => , 'render_callback' => [$this, 'render'], ]; @@ -61,7 +61,7 @@ public function localize(): array public function render(array $attributes): string { - $template = __DIR__ . '/'; + $template = __DIR__ . ; if (!is_file($template)) { return ''; @@ -73,7 +73,7 @@ public function render(array $attributes): string return (string) ob_get_clean(); - return '
'; + return ', true); ?>; } } diff --git a/Resources/skeleton/config_loader/FrontendConfigLoader.tpl.php b/Resources/skeleton/config_loader/FrontendConfigLoader.tpl.php index 36ea562..94f7cd4 100644 --- a/Resources/skeleton/config_loader/FrontendConfigLoader.tpl.php +++ b/Resources/skeleton/config_loader/FrontendConfigLoader.tpl.php @@ -9,7 +9,7 @@ final class implements ConfigLoaderInterface { - public const FRONTEND = ''; + public const FRONTEND = ; public function accepts(Asset $asset): bool { diff --git a/Resources/skeleton/config_loader/GutenbergConfigLoader.tpl.php b/Resources/skeleton/config_loader/GutenbergConfigLoader.tpl.php index a07dfb5..2ad2884 100644 --- a/Resources/skeleton/config_loader/GutenbergConfigLoader.tpl.php +++ b/Resources/skeleton/config_loader/GutenbergConfigLoader.tpl.php @@ -11,7 +11,7 @@ final class implements ConfigLoaderInterface { - public const EDITOR = ''; + public const EDITOR = ; /** @var list */ private array $blocks = []; @@ -45,7 +45,7 @@ public function process(Asset $asset): Asset if ($asset instanceof Script) { $asset - ->withLocalize('', $localization) + ->withLocalize(, $localization) ->withDependencies('wp-blocks'); } diff --git a/composer.json b/composer.json index 7fb4dd6..e11c215 100644 --- a/composer.json +++ b/composer.json @@ -13,17 +13,14 @@ "require": { "php": "^8.5", "composer-runtime-api": "^2.1", - "symfony/maker-bundle": "^1.0", - "sympress/kernel": "dev-main", - "wp-cli/process": "^5.9.99" - }, - "provide": { - "symfony/process": "8.1.0" + "symfony/maker-bundle": "^1.68", + "sympress/kernel": "^1.1.1", + "symfony/process": "^8.1" }, "require-dev": { "symfony/security-core": "^8.1", "symfony/validator": "^8.1", - "sympress/qa": "dev-main" + "sympress/qa": "^0.1.2" }, "autoload": { "psr-4": { @@ -75,15 +72,6 @@ "phpstan/extension-installer": true } }, - "repositories": [ - { - "type": "path", - "url": "./../*", - "options": { - "symlink": true - } - } - ], - "minimum-stability": "dev", + "minimum-stability": "stable", "prefer-stable": true } diff --git a/docs/releases/1.0.2.md b/docs/releases/1.0.2.md new file mode 100644 index 0000000..7636db3 --- /dev/null +++ b/docs/releases/1.0.2.md @@ -0,0 +1,9 @@ +# 1.0.2 release candidate + +This candidate contains the 2026-10-01 review repairs and requires the stable +SymPress dependency train. It has not been published yet. CI consumers use the +reviewed workflows commit 177fa0d727b278d2103052ec77c102b4a4c492a0. + +Local exact-commit Composer archives verify dependency resolution and distribution +contents before publication. They do not imply that prospective tags exist on +GitHub or Packagist. Historical tags remain unchanged. diff --git a/src/Maker/MakeBlock.php b/src/Maker/MakeBlock.php index c37aac9..d0451a2 100644 --- a/src/Maker/MakeBlock.php +++ b/src/Maker/MakeBlock.php @@ -8,6 +8,7 @@ use SymPress\MakerBundle\Util\PackageContext; use SymPress\MakerBundle\Util\PackageContextResolver; use SymPress\MakerBundle\Util\PackageServiceConfigurator; +use SymPress\MakerBundle\Util\SourceLiteral; use SymPress\MakerBundle\Util\WebpackEncoreEntryConfigurator; use Symfony\Bundle\MakerBundle\ConsoleStyle; use Symfony\Bundle\MakerBundle\DependencyBuilder; @@ -137,7 +138,7 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen if ($withView) { $generator->dumpFile( $context->packageRelativePath(sprintf('Resources/views/block/%s.php', $slug)), - "
\n", + '
' . "\n", ); } @@ -248,14 +249,14 @@ private function editorSource(string $blockName, string $title, string $textDoma import { registerBlockType } from '@wordpress/blocks'; import { __ } from '@wordpress/i18n'; -registerBlockType('%s', { - title: __('%s', '%s'), +registerBlockType(%s, { + title: __(%s, %s), edit: () => { const blockProps = useBlockProps(); return (
- {__('%s', '%s')} + {__(%s, %s)}
); }, @@ -263,16 +264,17 @@ private function editorSource(string $blockName, string $title, string $textDoma }); TS , - $blockName, - $title, - $textDomain, - $title, - $textDomain, + SourceLiteral::javascript($blockName), + SourceLiteral::javascript($title), + SourceLiteral::javascript($textDomain), + SourceLiteral::javascript($title), + SourceLiteral::javascript($textDomain), ) . "\n"; } private function frontendSource(string $blockName): string { - return sprintf("document.querySelectorAll('[data-block=\"%s\"]');\n", $blockName); + return "Array.from(document.querySelectorAll('[data-block]')).filter(element => element.getAttribute('data-block') === " + . SourceLiteral::javascript($blockName) . ");\n"; } } diff --git a/src/Maker/MakeHook.php b/src/Maker/MakeHook.php index aca3b8f..62549bc 100644 --- a/src/Maker/MakeHook.php +++ b/src/Maker/MakeHook.php @@ -6,6 +6,7 @@ use SymPress\MakerBundle\Util\PackageContextResolver; use SymPress\MakerBundle\Util\PackageServiceConfigurator; +use SymPress\MakerBundle\Util\SourceLiteral; use Symfony\Bundle\MakerBundle\ConsoleStyle; use Symfony\Bundle\MakerBundle\DependencyBuilder; use Symfony\Bundle\MakerBundle\Generator; @@ -55,6 +56,7 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen } $method = (string) $input->getOption('method'); + SourceLiteral::identifier($method); $classDetails = $generator->createClassNameDetails((string) $input->getArgument('class'), 'Hook\\'); $generator->generateClass( $classDetails->getFullName(), diff --git a/src/Maker/MakeSympressPackage.php b/src/Maker/MakeSympressPackage.php index 26a9f69..92b7657 100644 --- a/src/Maker/MakeSympressPackage.php +++ b/src/Maker/MakeSympressPackage.php @@ -5,6 +5,8 @@ namespace SymPress\MakerBundle\Maker; use SymPress\MakerBundle\Util\JsonFile; +use SymPress\MakerBundle\Util\ProjectPath; +use SymPress\MakerBundle\Util\SourceLiteral; use Symfony\Bundle\MakerBundle\ConsoleStyle; use Symfony\Bundle\MakerBundle\DependencyBuilder; use Symfony\Bundle\MakerBundle\Generator; @@ -73,6 +75,7 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen $packageType = $this->normalizePackageType((string) $input->getOption('type')); $packageDirectory = $this->packageDirectory($packageName); $packagePath = (string) ($input->getOption('path') ?: sprintf('packages/%s', $packageDirectory)); + $packagePath = ProjectPath::relative($this->projectDir, $packagePath); $absolutePackagePath = sprintf('%s/%s', rtrim($this->projectDir, '/'), trim($packagePath, '/')); if (is_dir($absolutePackagePath)) { @@ -80,6 +83,9 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen } $namespace = trim((string) ($input->getOption('namespace') ?: $this->namespace($packageName)), '\\'); + foreach (explode('\\', $namespace) as $segment) { + SourceLiteral::identifier($segment); + } $bundleClass = $this->isKernelPackage($packageType) ? sprintf('%s\\%s', $namespace, $this->bundleClassName($packageName)) : null; @@ -117,6 +123,7 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen $generator->dumpFile('composer.json', $this->rootComposerWithPackage($packageName, (bool) $input->getOption('dev'))); } + ProjectPath::relative($this->projectDir, $packagePath); $generator->writeChanges(); $this->writeSuccessMessage($io); $io->text(sprintf('Package "%s" (%s) was created at %s.', $packageName, $packageType, $packagePath)); @@ -373,7 +380,7 @@ private function pluginEntry(string $packageName, string $description, string $n PHP , Str::asHumanWords($packageName), - $description, + str_replace(['*/', "\r", "\n"], ['* /', ' ', ' '], $description), $namespace, Str::getShortClassName($bundleClass), ) . "\n"; @@ -421,7 +428,7 @@ private function themeStylesheet(string $packageName, string $description): stri CSS , Str::asHumanWords($name), - $description, + str_replace(['*/', "\r", "\n"], ['* /', ' ', ' '], $description), $name, ) . "\n"; } diff --git a/src/SymPressMakerBundle.php b/src/SymPressMakerBundle.php index f11ac78..224b322 100644 --- a/src/SymPressMakerBundle.php +++ b/src/SymPressMakerBundle.php @@ -8,6 +8,7 @@ use SymPress\MakerBundle\Composer\InstalledComposerPackageLocator; use SymPress\MakerBundle\Composer\PackageAutoloadResolver; use Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass\MakeCommandRegistrationPass; +use Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass\MakeDecoratorPass; use Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass\RemoveMissingParametersPass; use Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass\SetDoctrineAnnotatedPrefixesPass; use Symfony\Bundle\MakerBundle\MakerBundle; @@ -17,6 +18,7 @@ use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\DependencyInjection\Reference; final class SymPressMakerBundle extends AbstractBundle { @@ -51,6 +53,9 @@ public function loadExtension( $configurator->import(sprintf('%s/config/makers.php', $makerBundlePath)); $configurator->import('../Resources/config/services.php'); + // Keep the complete constructor positional when Symfony rebuilds debug/lint definitions. + $container->getDefinition('maker.maker.make_command')->setArguments([null, new Reference('maker.file_manager')]); + $rootNamespace = $this->rootNamespace($config, $container); $entityNamespace = $this->entityNamespace($config, $rootNamespace); @@ -79,6 +84,7 @@ public function build(ContainerBuilder $container): void ->addTag(MakeCommandRegistrationPass::MAKER_TAG); $container->addCompilerPass(new MakeCommandRegistrationPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 10); + $container->addCompilerPass(new MakeDecoratorPass()); $container->addCompilerPass(new RemoveMissingParametersPass()); $container->addCompilerPass(new SetDoctrineAnnotatedPrefixesPass()); } diff --git a/src/Util/PackageContext.php b/src/Util/PackageContext.php index b3effca..4f5fcc9 100644 --- a/src/Util/PackageContext.php +++ b/src/Util/PackageContext.php @@ -47,20 +47,23 @@ public function relativePath(string $path): string $path = str_replace('\\', '/', $path); $projectDir = rtrim(str_replace('\\', '/', $this->projectDir), '/'); + if ($path === $projectDir) { + return ''; + } if (str_starts_with($path, $projectDir . '/')) { return substr($path, strlen($projectDir) + 1); } - return ltrim($path, '/'); + return ProjectPath::relative($this->projectDir, $path); } public function packageRelativePath(string $path): string { - return sprintf( + return ltrim(sprintf( '%s/%s', $this->relativePath($this->packagePath), ltrim(str_replace('\\', '/', $path), '/'), - ); + ), '/'); } public function tagPrefix(): string diff --git a/src/Util/PackageServiceConfigurator.php b/src/Util/PackageServiceConfigurator.php index c3f9438..1dc81e7 100644 --- a/src/Util/PackageServiceConfigurator.php +++ b/src/Util/PackageServiceConfigurator.php @@ -4,6 +4,8 @@ namespace SymPress\MakerBundle\Util; +use Symfony\Component\Yaml\Yaml; + final class PackageServiceConfigurator { /** @param list> $tags */ @@ -245,7 +247,7 @@ private function appendPhpDefinition(string $contents, string $className, array private function yamlSimpleTags(array $tags): array { if (count($tags) === 1) { - return [sprintf(' tags: [\'%s\']', $this->yamlQuote($tags[0]))]; + return [sprintf(' tags: [%s]', $this->yamlQuote($tags[0]))]; } return [ @@ -275,7 +277,7 @@ private function yamlTags(array $tags): array */ private function phpSimpleTags(array $tags): array { - return array_map(static fn (string $tag): string => sprintf(' ->tag(\'%s\')', $tag), $tags); + return array_map(static fn (string $tag): string => sprintf(' ->tag(%s)', var_export($tag, true)), $tags); } /** @@ -285,7 +287,7 @@ private function phpSimpleTags(array $tags): array private function phpTags(array $tags): array { return array_map( - fn (array $tag): string => sprintf(' ->tag(\'%s\', %s)', $tag['name'], $this->phpArray($tag)), + fn (array $tag): string => sprintf(' ->tag(%s, %s)', var_export($tag['name'], true), $this->phpArray($tag)), $tags, ); } @@ -312,7 +314,7 @@ private function phpArray(array $values): string $parts[] = sprintf( '\'%s\' => %s', $key, - is_int($value) ? (string) $value : sprintf('\'%s\'', str_replace('\'', '\\\'', (string) $value)), + is_int($value) ? (string) $value : var_export((string) $value, true), ); } @@ -321,6 +323,6 @@ private function phpArray(array $values): string private function yamlQuote(string $value): string { - return '\'' . str_replace('\'', '\'\'', $value) . '\''; + return Yaml::dump($value); } } diff --git a/src/Util/ProjectPath.php b/src/Util/ProjectPath.php new file mode 100644 index 0000000..acd1c9e --- /dev/null +++ b/src/Util/ProjectPath.php @@ -0,0 +1,35 @@ +insertEntry($contents, $line); return new FileUpdate($context->relativePath($file), $updated); diff --git a/tests/Maker/MakerContainerTest.php b/tests/Maker/MakerContainerTest.php new file mode 100644 index 0000000..e090623 --- /dev/null +++ b/tests/Maker/MakerContainerTest.php @@ -0,0 +1,95 @@ +setParameter('kernel.project_dir', sys_get_temp_dir()); + $container->setParameter('kernel.package_prefixes', []); + $container->setParameter('twig.default_path', null); + $container->register('filesystem', Filesystem::class); + $container->register('event_dispatcher', EventDispatcher::class); + $bundle = new SymPressMakerBundle(); + $bundle->build($container); + $container->getExtension('sympress_maker')->load([['root_namespace' => 'App']], $container); + foreach ($container->getDefinitions() as $definition) { + if ($definition->isAbstract()) { + continue; + } + $definition->setPublic(true); + } + $container->addCompilerPass(new class implements CompilerPassInterface { + public function process(ContainerBuilder $container): void + { + foreach ($container->findTaggedServiceIds('console.command') as $id => $tags) { + $container->getDefinition($id)->setPublic(true); + } + } + }, PassConfig::TYPE_BEFORE_REMOVING, 1000); + $container->addCompilerPass(new AddConsoleCommandPass(), PassConfig::TYPE_BEFORE_REMOVING); + $container->addCompilerPass(new CheckTypeDeclarationsPass(true), PassConfig::TYPE_AFTER_REMOVING, -100); + $container->compile(); + $dump = new PhpDumper($container)->dump(['class' => 'MakerContainerRegression']); + self::assertIsString($dump); + self::assertArrayNotHasKey('fileManager', $container->getDefinition('maker.maker.make_command')->getArguments()); + + // Debug/lint reconstructs definitions in a fresh builder; normalized bare named keys fail there. + $lint = new ContainerBuilder(); + $lint->getParameterBag()->add($container->getParameterBag()->all()); + $lint->setDefinitions($container->getDefinitions()); + $lint->setAliases($container->getAliases()); + $lint->addCompilerPass(new CheckTypeDeclarationsPass(true), PassConfig::TYPE_AFTER_REMOVING, -100); + $lint->compile(); + self::assertInstanceOf(MakeCommand::class, $lint->get('maker.maker.make_command')); + $helper = $lint->get('maker.decorator_helper'); + self::assertInstanceOf(DecoratorHelper::class, $helper); + self::assertContains('event_dispatcher', $helper->suggestIds()); + self::assertSame('event_dispatcher', $helper->getRealId('EventDispatcher')); + self::assertSame(EventDispatcher::class, $helper->getClass('event_dispatcher')); + foreach ($lint->getDefinitions() as $id => $definition) { + if (!str_starts_with($id, 'maker.') || str_starts_with($id, 'maker.auto_command.')) { + continue; + } + $class = $definition->getClass(); + if (!is_string($class)) { + continue; + } + self::assertInstanceOf($class, $lint->get($id), $id); + } + $application = new Application(); + $application->setAutoExit(false); + foreach ($lint->getDefinitions() as $id => $definition) { + if (!str_starts_with($id, 'maker.auto_command.')) { + continue; + } + $command = $lint->get($id); + self::assertInstanceOf(Command::class, $command, $id); + $application->addCommand($command); + } + $tester = new ApplicationTester($application); + self::assertSame(Command::SUCCESS, $tester->run(['command' => 'list', 'namespace' => 'make', '--format' => 'json'])); + $commands = json_decode($tester->getDisplay(), true, flags: JSON_THROW_ON_ERROR); + self::assertContains('make:decorator', array_column($commands['commands'], 'name')); + } +} diff --git a/tests/Util/GeneratedSourceSafetyTest.php b/tests/Util/GeneratedSourceSafetyTest.php new file mode 100644 index 0000000..407c0f5 --- /dev/null +++ b/tests/Util/GeneratedSourceSafetyTest.php @@ -0,0 +1,76 @@ +"; + $variables = [ + 'namespace' => 'Example', + 'class_name' => 'DangerBlock', + 'localizable' => true, + 'block_name' => $value, + 'title' => $value, + 'description' => $value, + 'category' => $value, + 'icon' => $value, + 'text_domain' => $value, + 'editor_handle' => $value, + 'frontend_handle' => $value, + 'js_config_var' => $value, + 'with_frontend' => true, + 'with_view' => false, + 'view_path' => $value, + ]; + $file = tempnam(sys_get_temp_dir(), 'maker-literal-'); + self::assertIsString($file); + file_put_contents($file, (new \ReflectionClass(FileManager::class))->newInstanceWithoutConstructor()->parseTemplate(dirname(__DIR__, 2) . '/Resources/skeleton/block/Block.tpl.php', $variables)); + try { + $process = new Process([PHP_BINARY, '-l', $file]); + self::assertSame(0, $process->run(), $process->getOutput() . $process->getErrorOutput()); + self::assertStringNotContainsString('