From e033f85a7dbfc1e69f17d91b48f53f03583832ce Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Tue, 13 Oct 2026 09:07:57 +1100 Subject: [PATCH 1/2] [#581] Rendered the Drupal version and command wrapper text in generated projects from the installer answers. --- .eddy/CLAUDE.md | 23 ++- .eddy/tests/src/Unit/InstallHelpersTest.php | 211 ++++++++++++++++++++ .eddy/tests/src/Unit/InstallProcessTest.php | 187 +++++++++++++++++ .editorconfig | 2 + .gitattributes | 4 + AGENTS.md | 67 ++++++- install.php | 164 +++++++++++++++ 7 files changed, 645 insertions(+), 13 deletions(-) diff --git a/.eddy/CLAUDE.md b/.eddy/CLAUDE.md index 281eaf7f..0099fd7b 100644 --- a/.eddy/CLAUDE.md +++ b/.eddy/CLAUDE.md @@ -55,14 +55,27 @@ A broken block fails quietly. Every file stays valid, and `composer update-snaps `TemplateMarkersTest` checks all 5 rules across the template. +In Markdown, open a block with the blank line that separates it from the text before it, the way `CONTRIBUTING.dist.md` does. A removed block then takes its blank line with it, where a blank line on each side of it would leave a double blank line behind. `InstallProcessTest` checks the generated `AGENTS.md`, `CONTRIBUTING.md` and `README.md` for that across the command wrapper and Drupal major selections. + +### Text rendered from the answers + +A block removes whole lines, so it can't trim a line that names several options. `install.php` renders those lines instead. The template holds the text for every option, and `install.php` replaces it with the same function's text for the selected options: + +- `core_version_requirement()` renders `core_version_requirement` in `your_extension.info.yml`, such as `^10 || ^11` for Drupal 10 and 11. +- `phpunit_config_comment()` renders the comment that opens `phpunit.xml`. It leaves out a major with a `phpunit.d.xml` of its own, which `eddy-assemble` uses for that major instead, so with Drupal 10 and 11 selected it names Drupal 11 only. +- `command_wrapper_text()` renders "`make` or `ahoy`", and "`make build` or `ahoy build`" for the `build`, `lint` and `test` commands. Ahoy alone turns them into "`ahoy`" and "`ahoy build`". Without a wrapper, these phrases sit in `DEV_COMMAND_WRAPPER` blocks, and "The command wrappers and CI run it" in `AGENTS.md` becomes "CI runs it". + +A replacement finds nothing once the template text drifts from the every-option rendering, and the text then ships unrendered. `InstallProcessTest` renders each one for every selection, so it fails when a template edit breaks a replacement. A new phrase that names both wrappers needs its command added to `process_command_wrapper_text()`. + ### Adding a Drupal major 1. In `install.php`, add the major to `drupal_version_options()` and to the `INSTALL_DRUPAL_VERSION` entry of `print_help()`. -2. Add a `DRUPAL_` block wherever the existing majors have one. `git grep -n '#;< DRUPAL_'` lists them: today that's the `lint` and `test` matrices in `.github/workflows/test.yml` and the badges in `README.dist.md`. -3. To have the major start checked, add it to `drupal_version_default()` and update the defaults that `print_help()` and `README.md` name. Then set `extra.eddy.drupal-version` in `composer.dev.json` to the highest major `drupal_version_default()` returns: `InstallHelpersTest` fails until they agree. -4. Review `.eddy/tooling/src/eddy-assemble`. It picks the PHPUnit, `symfony/phpunit-bridge` and Rector constraints by major, and it uses `phpunit.d.xml` in place of `phpunit.xml` when that file exists. -5. Update `README.md` where it lists the majors (the badges, the CI job table and the branch protection table) and the matrix line in `AGENTS.md`. -6. Update the expected majors in `InstallHelpersTest` and the Drupal data providers in `InstallProcessTest`, add a `d_only` dataset to `InstallTest`, then regenerate the snapshot fixtures. +2. Add a `DRUPAL_` block wherever the existing majors have one. `git grep -n '#;< DRUPAL_'` lists them: today that's the `lint` and `test` matrices in `.github/workflows/test.yml`, the badges in `README.dist.md` and the matrix list in `AGENTS.md`. +3. Add the major to `core_version_requirement` in `your_extension.info.yml`, and to the comment that opens `phpunit.xml` unless the major gets a `phpunit.d.xml` of its own (see [Text rendered from the answers](#text-rendered-from-the-answers)). +4. To have the major start checked, add it to `drupal_version_default()` and update the defaults that `print_help()` and `README.md` name. Then set `extra.eddy.drupal-version` in `composer.dev.json` to the highest major `drupal_version_default()` returns: `InstallHelpersTest` fails until they agree. +5. Review `.eddy/tooling/src/eddy-assemble`. It picks the PHPUnit, `symfony/phpunit-bridge` and Rector constraints by major, and it uses `phpunit.d.xml` in place of `phpunit.xml` when that file exists. +6. Update `README.md` where it lists the majors: the badges, the CI job table and the branch protection table. +7. Update the expected majors in `InstallHelpersTest` and the Drupal data providers in `InstallProcessTest`, add a `d_only` dataset to `InstallTest`, then regenerate the snapshot fixtures. ### Adding a development tool diff --git a/.eddy/tests/src/Unit/InstallHelpersTest.php b/.eddy/tests/src/Unit/InstallHelpersTest.php index 4633e341..ec402537 100644 --- a/.eddy/tests/src/Unit/InstallHelpersTest.php +++ b/.eddy/tests/src/Unit/InstallHelpersTest.php @@ -10,8 +10,10 @@ use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Process\Process; +use function command_wrapper_text; use function committed_workflow_branches; use function convert_string; +use function core_version_requirement; use function drupal_version_default; use function drupal_version_options; use function eddy_skills; @@ -20,10 +22,14 @@ use function is_binary_file; use function is_gitignored; use function is_template_path; +use function join_list; use function normalize_cspell_words; use function parse_branch_list; +use function phpunit_config_comment; use function print_help; use function process; +use function process_command_wrapper_text; +use function process_drupal_version_text; use function read_composer_dev_json; use function remove_dir; use function remove_special_comments; @@ -32,6 +38,7 @@ use function replace_string_content; use function set_composer_dev_eddy_value; use function set_workflow_branches; +use function sort_drupal_majors; use function template_version; use function uncomment_line; use function workflow_push_branches; @@ -177,6 +184,189 @@ public function testDrupalVersionShippedBuildDefault(): void { $this->assertSame($shipped_default, $config['extra']['eddy']['drupal-version'] ?? NULL); } + /** + * @param array $majors + * @param array $expected + */ + #[DataProvider('dataProviderSortDrupalMajors')] + public function testSortDrupalMajors(array $majors, array $expected): void { + $this->assertSame($expected, sort_drupal_majors($majors)); + } + + public static function dataProviderSortDrupalMajors(): \Iterator { + yield 'none' => [[], []]; + yield 'sorted' => [['10', '11'], ['10', '11']]; + yield 'numeric order' => [['12', '9', '10'], ['9', '10', '12']]; + yield 'duplicates' => [['11', '10', '11'], ['10', '11']]; + yield 'integers' => [[11, 10], ['10', '11']]; + } + + /** + * @param array $majors + */ + #[DataProvider('dataProviderCoreVersionRequirement')] + public function testCoreVersionRequirement(array $majors, string $expected): void { + $this->assertSame($expected, core_version_requirement($majors)); + } + + public static function dataProviderCoreVersionRequirement(): \Iterator { + yield '1 major' => [['11'], 'core_version_requirement: ^11']; + yield '2 majors' => [['10', '11'], 'core_version_requirement: ^10 || ^11']; + yield '3 majors' => [['10', '11', '12'], 'core_version_requirement: ^10 || ^11 || ^12']; + yield 'unordered' => [['12', '10'], 'core_version_requirement: ^10 || ^12']; + } + + /** + * @param array $majors + */ + #[DataProvider('dataProviderPhpunitConfigComment')] + public function testPhpunitConfigComment(array $majors, string $expected): void { + $this->assertSame($expected, phpunit_config_comment($majors)); + } + + public static function dataProviderPhpunitConfigComment(): \Iterator { + yield 'no major' => [[], '']; + yield '1 major' => [['11'], '']; + yield '2 majors' => [['11', '12'], '']; + yield '3 majors' => [['10', '11', '12'], '']; + yield 'unordered' => [['12', '11'], '']; + } + + /** + * @param array $majors + * @param array $files + * @param array $expected + */ + #[DataProvider('dataProviderProcessDrupalVersionText')] + public function testProcessDrupalVersionText(array $majors, array $files, array $expected): void { + chdir(self::$sut); + + foreach ($files as $name => $content) { + file_put_contents(self::$sut . '/' . $name, $content); + } + + process_drupal_version_text($majors); + + foreach ($expected as $name => $content) { + $this->assertSame($content, file_get_contents(self::$sut . '/' . $name), $name . ' content mismatch.'); + } + } + + public static function dataProviderProcessDrupalVersionText(): \Iterator { + $info = static fn(string $constraint): string => "name: Test\ncore_version_requirement: " . $constraint . "\n"; + $phpunit = static fn(string $comment): string => "\n" . $comment . "\n\n"; + $template = [ + 'test.info.yml' => $info('^10 || ^11 || ^12'), + 'phpunit.xml' => $phpunit(''), + 'phpunit.d10.xml' => $phpunit(''), + ]; + + yield 'every major' => [['10', '11', '12'], $template, $template]; + + yield 'a major with a configuration of its own' => [ + ['10', '11'], + $template, + [ + 'test.info.yml' => $info('^10 || ^11'), + 'phpunit.xml' => $phpunit(''), + 'phpunit.d10.xml' => $phpunit(''), + ], + ]; + + yield 'only a major with a configuration of its own' => [ + ['10'], + $template, + [ + 'test.info.yml' => $info('^10'), + 'phpunit.xml' => $phpunit(''), + ], + ]; + + yield 'no configuration of their own' => [ + ['10', '12'], + [ + 'test.info.yml' => $info('^10 || ^11 || ^12'), + 'phpunit.xml' => $phpunit(''), + ], + [ + 'test.info.yml' => $info('^10 || ^12'), + 'phpunit.xml' => $phpunit(''), + ], + ]; + + yield 'unordered selection' => [ + ['12', '11'], + $template, + [ + 'test.info.yml' => $info('^11 || ^12'), + 'phpunit.xml' => $phpunit(''), + ], + ]; + + yield 'text for other majors stays' => [ + ['11'], + ['test.info.yml' => $info('^10 || ^11'), 'phpunit.xml' => $phpunit('')], + ['test.info.yml' => $info('^10 || ^11'), 'phpunit.xml' => $phpunit('')], + ]; + } + + /** + * @param array $command_wrapper + */ + #[DataProvider('dataProviderCommandWrapperText')] + public function testCommandWrapperText(array $command_wrapper, string $command, string $expected): void { + $this->assertSame($expected, command_wrapper_text($command_wrapper, $command)); + } + + public static function dataProviderCommandWrapperText(): \Iterator { + yield 'both wrappers' => [['ahoy', 'makefile'], 'build', '`make build` or `ahoy build`']; + yield 'both wrappers, selected in the other order' => [['makefile', 'ahoy'], 'build', '`make build` or `ahoy build`']; + yield 'ahoy' => [['ahoy'], 'lint', '`ahoy lint`']; + yield 'makefile' => [['makefile'], 'test', '`make test`']; + yield 'binaries' => [['ahoy', 'makefile'], '', '`make` or `ahoy`']; + yield 'no wrapper' => [[], 'build', '']; + yield 'unknown wrapper' => [['unknown'], 'build', '']; + } + + /** + * @param array $command_wrapper + */ + #[DataProvider('dataProviderProcessCommandWrapperText')] + public function testProcessCommandWrapperText(array $command_wrapper, string $expected): void { + chdir(self::$sut); + file_put_contents(self::$sut . '/AGENTS.md', self::wrapperText('`make` or `ahoy`', '`make build` or `ahoy build`', '`make lint` or `ahoy lint`', '`make test` or `ahoy test`', 'The command wrappers and CI run it')); + + process_command_wrapper_text($command_wrapper); + + $this->assertSame($expected, file_get_contents(self::$sut . '/AGENTS.md')); + } + + public static function dataProviderProcessCommandWrapperText(): \Iterator { + $template = self::wrapperText('`make` or `ahoy`', '`make build` or `ahoy build`', '`make lint` or `ahoy lint`', '`make test` or `ahoy test`', 'The command wrappers and CI run it'); + + yield 'both wrappers' => [['ahoy', 'makefile'], $template]; + yield 'ahoy' => [['ahoy'], self::wrapperText('`ahoy`', '`ahoy build`', '`ahoy lint`', '`ahoy test`', 'The command wrappers and CI run it')]; + yield 'makefile' => [['makefile'], self::wrapperText('`make`', '`make build`', '`make lint`', '`make test`', 'The command wrappers and CI run it')]; + yield 'no wrapper' => [[], self::wrapperText('`make` or `ahoy`', '`make build` or `ahoy build`', '`make lint` or `ahoy lint`', '`make test` or `ahoy test`', 'CI runs it')]; + } + + /** + * @param array $items + */ + #[DataProvider('dataProviderJoinList')] + public function testJoinList(array $items, string $conjunction, string $expected): void { + $this->assertSame($expected, join_list($items, $conjunction)); + } + + public static function dataProviderJoinList(): \Iterator { + yield 'no items' => [[], 'and', '']; + yield '1 item' => [['A'], 'and', 'A']; + yield '2 items' => [['A', 'B'], 'and', 'A and B']; + yield '3 items' => [['A', 'B', 'C'], 'and', 'A, B and C']; + yield 'keyed items' => [[3 => 'A', 7 => 'B'], 'and', 'A and B']; + yield 'other conjunction' => [['A', 'B', 'C'], 'or', 'A, B or C']; + } + #[DataProvider('dataProviderReadComposerDevJson')] public function testReadComposerDevJson(?string $contents, ?array $expected): void { chdir(self::$sut); @@ -1039,4 +1229,25 @@ public static function dataProviderProcessValidation(): \Iterator { yield 'unsupported drupal versions' => ['Name', 'machine', 'module', ['9', '13'], ['ahoy'], 'Unsupported Drupal version: 9, 13.']; } + /** + * Build text that names command wrappers the way 'AGENTS.md' does. + * + * @param string $binaries + * The wrapper binaries. + * @param string $build + * The build commands. + * @param string $lint + * The lint commands. + * @param string $test + * The test commands. + * @param string $tooling + * The clause that names what runs the tooling installer. + * + * @return string + * The text. + */ + protected static function wrapperText(string $binaries, string $build, string $lint, string $test, string $tooling): string { + return 'When ' . $binaries . " exposes a command.\n1. Build: " . $build . "\n3. Lint: " . $lint . "\n4. Test: " . $test . "\n" . $tooling . " before the tooling commands.\n"; + } + } diff --git a/.eddy/tests/src/Unit/InstallProcessTest.php b/.eddy/tests/src/Unit/InstallProcessTest.php index 5c1830bc..d76dbd5e 100644 --- a/.eddy/tests/src/Unit/InstallProcessTest.php +++ b/.eddy/tests/src/Unit/InstallProcessTest.php @@ -460,6 +460,10 @@ public function testProcessRemovesWrapperDocs(array $command_wrapper, bool $expe $this->assertSame($expect_ahoy, str_contains($agents, $ahoy_rule), 'AGENTS.md ahoy rule block presence mismatch.'); $this->assertSame($expect_make, str_contains($agents, $make_command), 'AGENTS.md make command list presence mismatch.'); $this->assertSame($expect_ahoy, str_contains($agents, $ahoy_command), 'AGENTS.md ahoy command list presence mismatch.'); + $this->assertSame($expect_make, preg_match('/\bmake\b/i', $agents) === 1, 'AGENTS.md mentions make without the Makefile, or omits it with the Makefile.'); + $this->assertSame($expect_ahoy, preg_match('/\bahoy\b/i', $agents) === 1, 'AGENTS.md mentions Ahoy without it, or omits it with it.'); + $this->assertStringNotContainsString('(default)', $agents, 'AGENTS.md calls a wrapper the default.'); + $this->assertStringNotContainsString('(alternative)', $agents, 'AGENTS.md calls a wrapper the alternative.'); } public static function dataProviderProcessRemovesWrapperDocs(): \Iterator { @@ -493,6 +497,154 @@ public static function dataProviderProcessPrunesContributingCommands(): \Iterato yield from self::dataProviderProcessRemovesWrapperDocs(); } + /** + * The 'AGENTS.md' text that names the command wrappers follows the selection. + * + * @param array $command_wrapper + * @param array $expected_lines + * @param array $expected_phrases + */ + #[DataProvider('dataProviderProcessRendersWrapperText')] + public function testProcessRendersWrapperText(array $command_wrapper, array $expected_lines, array $expected_phrases): void { + process('My Extension', 'my_extension', 'module', ['10', '11'], $command_wrapper, [], FALSE, FALSE); + + $agents = (string) file_get_contents(self::$sut . '/AGENTS.md'); + + foreach ($expected_lines as $line) { + $this->assertMatchesRegularExpression('/^' . preg_quote($line, '/') . '$/m', $agents, 'AGENTS.md misses the line: ' . $line); + } + + foreach ($expected_phrases as $phrase) { + $this->assertStringContainsString($phrase, $agents); + } + } + + public static function dataProviderProcessRendersWrapperText(): \Iterator { + $tooling = static fn(string $runners): string => '- `scripts/eddy-tooling` - Installs the tooling package into `vendor/` from the constraint in `composer.dev.json`. ' . $runners . ' before the tooling commands'; + $wrapper_rule = static fn(string $binaries): array => ['When ' . $binaries . ' exposes a command for a task', 'extend the ' . $binaries . ' target']; + + yield 'both wrappers' => [ + ['ahoy', 'makefile'], + [ + '**Using Make:**', + '**Using Ahoy:**', + '1. Build environment: `make build` or `ahoy build`', + '3. Check standards: `make lint` or `ahoy lint`', + '4. Run tests: `make test` or `ahoy test`', + $tooling('The command wrappers and CI run it'), + ], + $wrapper_rule('`make` or `ahoy`'), + ]; + + yield 'ahoy only' => [ + ['ahoy'], + [ + '**Using Ahoy:**', + '1. Build environment: `ahoy build`', + '3. Check standards: `ahoy lint`', + '4. Run tests: `ahoy test`', + $tooling('The command wrappers and CI run it'), + ], + $wrapper_rule('`ahoy`'), + ]; + + yield 'makefile only' => [ + ['makefile'], + [ + '**Using Make:**', + '1. Build environment: `make build`', + '3. Check standards: `make lint`', + '4. Run tests: `make test`', + $tooling('The command wrappers and CI run it'), + ], + $wrapper_rule('`make`'), + ]; + + yield 'no wrappers' => [ + [], + [ + '- `scripts/eddy-tooling` - Install the tooling commands into `vendor/bin/`. Run it before the commands below', + '- `vendor/bin/eddy-assemble` - Assemble codebase with dependencies', + '- `build/vendor/bin/drush -l "$(vendor/bin/eddy-info site-url)" ` - Run Drush commands', + 'Run the linters and the tests with the commands that `CONTRIBUTING.md` lists.', + '1. Build environment: `scripts/eddy-tooling`, then `vendor/bin/eddy-assemble`, `vendor/bin/eddy-start` and `vendor/bin/eddy-provision`', + '3. Check standards: run the linters that `CONTRIBUTING.md` lists', + '4. Run tests: run the tests that `CONTRIBUTING.md` lists', + $tooling('CI runs it'), + ], + ['**HARD RULE - run each tool exactly as `CONTRIBUTING.md` shows.** This project has no command wrapper'], + ]; + } + + /** + * The '.gitattributes' and '.editorconfig' wrapper entries follow the + * command wrapper selection. + * + * @param array $command_wrapper + */ + #[DataProvider('dataProviderProcessPrunesWrapperConfig')] + public function testProcessPrunesWrapperConfig(array $command_wrapper, bool $expect_make, bool $expect_ahoy): void { + process('My Extension', 'my_extension', 'module', ['10', '11'], $command_wrapper, [], FALSE, FALSE); + + $gitattributes = (string) file_get_contents(self::$sut . '/.gitattributes'); + $editorconfig = (string) file_get_contents(self::$sut . '/.editorconfig'); + + $this->assertSame($expect_make, preg_match('/^Makefile +export-ignore$/m', $gitattributes) === 1, '.gitattributes Makefile entry presence mismatch.'); + $this->assertSame($expect_ahoy, preg_match('/^\.ahoy\.yml +export-ignore$/m', $gitattributes) === 1, '.gitattributes .ahoy.yml entry presence mismatch.'); + $this->assertSame($expect_make, str_contains($editorconfig, '[Makefile*]'), '.editorconfig Makefile section presence mismatch.'); + $this->assertStringNotContainsString('#;', $gitattributes . $editorconfig, 'A wrapper config file keeps a marker line.'); + $this->assertStringEndsNotWith("\n\n", $editorconfig, '.editorconfig ends with a blank line.'); + } + + public static function dataProviderProcessPrunesWrapperConfig(): \Iterator { + yield from self::dataProviderProcessRemovesWrapperDocs(); + } + + /** + * The 'AGENTS.md' overview describes the extension rather than the template. + */ + #[DataProvider('dataProviderProcessRendersOverview')] + public function testProcessRendersOverview(string $name, string $machine_name, string $type, string $expected): void { + process($name, $machine_name, $type, ['10', '11'], ['ahoy'], [], FALSE, FALSE); + + $agents = (string) file_get_contents(self::$sut . '/AGENTS.md'); + $overview = preg_match('/^## Overview\n(.*?)^## /ms', $agents, $matches) === 1 ? $matches[1] : ''; + + $this->assertStringStartsWith("\n" . $expected . ' The project provides', $overview); + $this->assertStringNotContainsStringIgnoringCase('template', $overview); + } + + public static function dataProviderProcessRendersOverview(): \Iterator { + yield 'module' => ['My Extension', 'my_extension', 'module', 'My Extension (`my_extension`) is a Drupal module.']; + yield 'theme' => ['My Theme', 'my_theme', 'theme', 'My Theme (`my_theme`) is a Drupal theme.']; + } + + /** + * Removed blocks leave no double blank line in the Markdown files. + * + * @param array $drupal_versions + * @param array $command_wrapper + */ + #[DataProvider('dataProviderProcessLeavesNoDoubleBlankLines')] + public function testProcessLeavesNoDoubleBlankLines(array $drupal_versions, array $command_wrapper): void { + process('My Extension', 'my_extension', 'module', $drupal_versions, $command_wrapper, [], FALSE, FALSE); + + foreach (['AGENTS.md', 'CONTRIBUTING.md', 'README.md'] as $file) { + $this->assertStringNotContainsString("\n\n\n", (string) file_get_contents(self::$sut . '/' . $file), $file . ' holds a double blank line.'); + } + } + + public static function dataProviderProcessLeavesNoDoubleBlankLines(): \Iterator { + yield 'both wrappers' => [['10', '11'], ['ahoy', 'makefile']]; + yield 'ahoy only' => [['10', '11'], ['ahoy']]; + yield 'makefile only' => [['10', '11'], ['makefile']]; + yield 'no wrappers' => [['10', '11'], []]; + yield 'all majors' => [['10', '11', '12'], ['ahoy']]; + yield 'd10 only' => [['10'], ['ahoy']]; + yield 'd11 only' => [['11'], ['ahoy']]; + yield 'd12 only' => [['12'], ['ahoy']]; + } + /** * @param array $tools_remove * @param array $expected_absent @@ -590,6 +742,41 @@ public static function dataProviderProcessPrunesDrupalBadges(): \Iterator { yield 'd12 only' => [['12'], ['Drupal 12'], ['Drupal 10', 'Drupal 11']]; } + /** + * The text that names the Drupal majors follows the selection. + * + * @param array $drupal_versions + * @param array $expected_matrix + */ + #[DataProvider('dataProviderProcessRendersDrupalVersionText')] + public function testProcessRendersDrupalVersionText(array $drupal_versions, string $expected_requirement, string $expected_phpunit_comment, array $expected_matrix): void { + process('My Extension', 'my_extension', 'module', $drupal_versions, ['ahoy'], [], FALSE, FALSE); + + $info = (string) file_get_contents(self::$sut . '/my_extension.info.yml'); + $this->assertSame([$expected_requirement], array_values(preg_grep('/^core_version_requirement:/', explode("\n", $info)) ?: []), 'my_extension.info.yml core_version_requirement mismatch.'); + + $phpunit = explode("\n", (string) file_get_contents(self::$sut . '/phpunit.xml')); + $this->assertSame($expected_phpunit_comment, $phpunit[1], 'phpunit.xml opening comment mismatch.'); + + preg_match_all('/^ - (Drupal \d+ on PHP .+)$/m', (string) file_get_contents(self::$sut . '/AGENTS.md'), $matches); + $this->assertSame($expected_matrix, $matches[1], 'AGENTS.md matrix mismatch.'); + } + + public static function dataProviderProcessRendersDrupalVersionText(): \Iterator { + $d10 = 'Drupal 10 on PHP 8.3-8.4'; + $d11 = 'Drupal 11 on PHP 8.3-8.5'; + $d12 = 'Drupal 12 on PHP 8.5'; + + yield 'all majors' => [['10', '11', '12'], 'core_version_requirement: ^10 || ^11 || ^12', '', [$d10, $d11, $d12]]; + yield 'd10 and d11' => [['10', '11'], 'core_version_requirement: ^10 || ^11', '', [$d10, $d11]]; + yield 'd10 and d12' => [['10', '12'], 'core_version_requirement: ^10 || ^12', '', [$d10, $d12]]; + yield 'd11 and d12' => [['11', '12'], 'core_version_requirement: ^11 || ^12', '', [$d11, $d12]]; + yield 'd10 only' => [['10'], 'core_version_requirement: ^10', '', [$d10]]; + yield 'd11 only' => [['11'], 'core_version_requirement: ^11', '', [$d11]]; + yield 'd12 only' => [['12'], 'core_version_requirement: ^12', '', [$d12]]; + yield 'unordered selection' => [['12', '10'], 'core_version_requirement: ^10 || ^12', '', [$d10, $d12]]; + } + /** * The local build default follows the highest selected major. * diff --git a/.editorconfig b/.editorconfig index c191bf5b..d2ad0188 100644 --- a/.editorconfig +++ b/.editorconfig @@ -26,6 +26,8 @@ indent_size = 2 [*.xml] indent_size = 4 +#;< DEV_MAKEFILE [Makefile*] indent_style = tab +#;> DEV_MAKEFILE diff --git a/.gitattributes b/.gitattributes index 525b4b77..1168fe6a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,7 +4,9 @@ # AGENTS.md export-ignore # CLAUDE.md export-ignore # .claude export-ignore +#;< DEV_AHOY # .ahoy.yml export-ignore +#;> DEV_AHOY #;< DEV_CSPELL # .cspell.json export-ignore #;> DEV_CSPELL @@ -27,7 +29,9 @@ #;< DEV_TWIGCS # .twig-cs-fixer.php export-ignore #;> DEV_TWIGCS +#;< DEV_MAKEFILE # Makefile export-ignore +#;> DEV_MAKEFILE # composer.dev.json export-ignore #;< DEV_JEST # jest.config.js export-ignore diff --git a/AGENTS.md b/AGENTS.md index 4210a180..762bcee6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,13 +4,23 @@ This file provides guidance to AI agents when working with code in this reposito ## Overview -This is a Drupal extension scaffold template for creating contributed modules or themes. The project provides a complete development environment with CI configuration, testing setup, and deployment automation for publishing to Drupal.org. + +This is a Drupal extension scaffold template for creating contributed modules or themes. `install.php` turns the example extension below into yours. + + +Your Extension (`your_extension`) is a Drupal module. The project provides a complete development environment with CI configuration, testing setup, and deployment automation for publishing to Drupal.org. ## Development Commands + -**HARD RULE - use the provided command wrappers, never the tool binaries directly.** When `make` or `ahoy` exposes a command for a task, use that command; do not call the underlying binary directly. Each wrapper `chdir`s into `build/` and runs the tool with the config, plugins, and environment that CI uses, so a raw invocation from the repository root silently diverges from CI - it can pass locally while CI fails (or vice versa), or crash outright when a relative path resolves against the wrong directory. If no wrapped command covers what you need, extend the `make` / `ahoy` target rather than making a one-off raw call; if that is not feasible, stop and ask. +**HARD RULE - use the provided command wrappers, never the tool binaries directly.** When `make` or `ahoy` exposes a command for a task, use that command; do not call the underlying binary directly. Each wrapper `chdir`s into `build/` and runs the tool with the config, plugins, and environment that CI uses, so a raw invocation from the repository root silently diverges from CI - it can pass locally while CI fails (or vice versa), or crash outright when a relative path resolves against the wrong directory. If no wrapped command covers what you need, extend the `make` or `ahoy` target rather than making a one-off raw call; if that is not feasible, stop and ask. + + +**HARD RULE - run each tool exactly as `CONTRIBUTING.md` shows.** This project has no command wrapper, so the commands below run directly. Most tools run from `build/`, which holds the config, plugins, and environment that CI uses, so a raw invocation from the repository root silently diverges from CI - it can pass locally while CI fails (or vice versa), or crash outright when a relative path resolves against the wrong directory. + + Run each tool through its `make` wrapper, never the binary directly: @@ -39,8 +49,8 @@ Run each tool through its `make` wrapper, never the binary directly: - **Drush**: `make drush ` - never `build/vendor/bin/drush` directly. - + Run each tool through its `ahoy` wrapper, never the binary directly: @@ -71,9 +81,9 @@ Run each tool through its `ahoy` wrapper, never the binary directly: ### Build and Environment Management - -**Using Make (default):** + +**Using Make:** - `make build` - Complete build (stop → assemble → start → provision) - `make assemble` - Assemble codebase with dependencies - `make start` - Start PHP development server @@ -81,9 +91,9 @@ Run each tool through its `ahoy` wrapper, never the binary directly: - `make provision` - Install/provision Drupal site - `make reset` - Clean build directory, logs and installed tooling (aliases: `make delete`, `make destroy`) - -**Using Ahoy (alternative):** + +**Using Ahoy:** - `ahoy build` - Complete build process - `ahoy assemble` - Assemble codebase - `ahoy start` - Start development server @@ -91,8 +101,17 @@ Run each tool through its `ahoy` wrapper, never the binary directly: - `ahoy provision` - Provision Drupal site - `ahoy reset` - Clean build directory, logs and installed tooling (aliases: `ahoy delete`, `ahoy destroy`) + + +- `scripts/eddy-tooling` - Install the tooling commands into `vendor/bin/`. Run it before the commands below +- `vendor/bin/eddy-assemble` - Assemble codebase with dependencies +- `vendor/bin/eddy-start` - Start PHP development server +- `vendor/bin/eddy-stop` - Stop development server +- `vendor/bin/eddy-provision` - Install/provision Drupal site + ### Code Quality + **Linting:** @@ -141,6 +160,11 @@ Run each tool through its `ahoy` wrapper, never the binary directly: - `ahoy browser-stop` - Stop the browser + + + +Run the linters and the tests with the commands that `CONTRIBUTING.md` lists. + ### Drupal Commands @@ -152,6 +176,10 @@ Run each tool through its `ahoy` wrapper, never the binary directly: - `ahoy drush ` - Run Drush commands - `ahoy login` - Get one-time login link + +- `build/vendor/bin/drush -l "$(vendor/bin/eddy-info site-url)" ` - Run Drush commands +- `build/vendor/bin/drush -l "$(vendor/bin/eddy-info site-url)" uli` - Get one-time login link + ### Diagnostics @@ -161,6 +189,9 @@ Run each tool through its `ahoy` wrapper, never the binary directly: - `ahoy info` - Print a read-only summary of PHP/Drupal/Composer/Drush/Node/npm versions, webserver host/port (with source), site URL, XDebug state, WebDriver port, build directory, database path, and active profile. (alias: `ahoy describe`) + +- `vendor/bin/eddy-info` - Print a read-only summary of PHP/Drupal/Composer/Drush/Node/npm versions, webserver host/port (with source), site URL, XDebug state, WebDriver port, build directory, database path, and active profile. + ## Project Structure @@ -207,10 +238,21 @@ Run each tool through its `ahoy` wrapper, never the binary directly: Run `php install.php` once, in a fresh copy of the template, to turn it into your extension. The script deletes itself when it finishes. Then: + 1. Build environment: `make build` or `ahoy build` + + +1. Build environment: `scripts/eddy-tooling`, then `vendor/bin/eddy-assemble`, `vendor/bin/eddy-start` and `vendor/bin/eddy-provision` + 2. Develop your extension code in `src/` + 3. Check standards: `make lint` or `ahoy lint` 4. Run tests: `make test` or `ahoy test` + + +3. Check standards: run the linters that `CONTRIBUTING.md` lists +4. Run tests: run the tests that `CONTRIBUTING.md` lists + 5. Access site at http://localhost:8000 ## Code Quality Tools @@ -234,7 +276,16 @@ Run `php install.php` once, in a fresh copy of the template, to turn it into you ## CI/CD Support - **GitHub Actions**: `.github/workflows/test.yml` and deployment -- **Matrix testing**: PHP 8.3-8.5, Drupal 10-12 +- **Matrix testing**: + + - Drupal 10 on PHP 8.3-8.4 + + + - Drupal 11 on PHP 8.3-8.5 + + + - Drupal 12 on PHP 8.5 + - **Automated deployment**: Mirror to Drupal.org after CI passes ## Important Notes diff --git a/install.php b/install.php index 19fc9240..1333ccf7 100755 --- a/install.php +++ b/install.php @@ -334,6 +334,8 @@ function process(string $extension_name, string $extension_machine_name, string } } + process_drupal_version_text($selected_majors); + // A local build defaults to the highest selected major. set_composer_dev_eddy_value('drupal-version', (string) max(array_map(intval(...), $selected_majors))); @@ -363,6 +365,8 @@ function process(string $extension_name, string $extension_machine_name, string remove_tokens_with_content('DEV_NO_COMMAND_WRAPPER'); } + process_command_wrapper_text($command_wrapper); + trim_claude_settings_permissions($command_wrapper); remove_tools($tools_remove); @@ -390,6 +394,165 @@ function process(string $extension_name, string $extension_machine_name, string } } +/** + * Render the text that names the targeted Drupal majors. + * + * The template names every supported major in the 'core_version_requirement' + * of the extension and in the comment that opens 'phpunit.xml'. Each is + * replaced with the same text for the selected majors. + * + * @param array $majors + * The selected Drupal majors. + */ +function process_drupal_version_text(array $majors): void { + $supported = array_map(strval(...), array_keys(drupal_version_options())); + + replace_string_content(core_version_requirement($supported), core_version_requirement($majors)); + + // A 'phpunit.d.xml' replaces 'phpunit.xml' in the build of its major. + $own_config_majors = array_filter($supported, static fn(string $major): bool => file_exists('phpunit.d' . $major . '.xml')); + replace_string_content(phpunit_config_comment(array_diff($supported, $own_config_majors)), phpunit_config_comment(array_diff($majors, $own_config_majors))); +} + +/** + * Render the text that names the selected command wrappers. + * + * The template names both wrappers in phrases such as "`make build` or + * `ahoy build`". Each is replaced with the same phrase for the selected + * wrappers. Without a wrapper, these phrases sit in 'DEV_COMMAND_WRAPPER' + * blocks instead, so only the 'AGENTS.md' note on what runs the tooling + * installer changes. + * + * @param array $command_wrapper + * The selected command wrappers. + */ +function process_command_wrapper_text(array $command_wrapper): void { + if ($command_wrapper === []) { + replace_string_content('The command wrappers and CI run it', 'CI runs it'); + + return; + } + + $wrappers = array_keys(command_wrapper_binaries()); + + foreach (['', 'build', 'lint', 'test'] as $command) { + replace_string_content(command_wrapper_text($wrappers, $command), command_wrapper_text($command_wrapper, $command)); + } +} + +/** + * Render the 'core_version_requirement' of an extension. + * + * @param array $majors + * The Drupal majors, in any order. + * + * @return string + * The line, such as 'core_version_requirement: ^10 || ^11'. + */ +function core_version_requirement(array $majors): string { + $constraints = array_map(static fn(string $major): string => '^' . $major, sort_drupal_majors($majors)); + + return 'core_version_requirement: ' . implode(' || ', $constraints); +} + +/** + * Render the comment that opens 'phpunit.xml'. + * + * @param array $majors + * The Drupal majors whose builds use 'phpunit.xml', in any order. + * + * @return string + * The comment, such as ''. + * It names no major when there are none. + */ +function phpunit_config_comment(array $majors): string { + $majors = sort_drupal_majors($majors); + + if ($majors === []) { + return ''; + } + + return ''; +} + +/** + * Sort Drupal majors in numeric order and drop duplicates. + * + * @param array $majors + * The Drupal majors. + * + * @return list + * The sorted majors. + */ +function sort_drupal_majors(array $majors): array { + $majors = array_unique(array_map(intval(...), $majors)); + sort($majors); + + return array_map(strval(...), $majors); +} + +/** + * Map each command wrapper to the binary that runs it. + * + * @return array + * The binaries, keyed by wrapper, in the order the template names them. + */ +function command_wrapper_binaries(): array { + return [ + 'makefile' => 'make', + 'ahoy' => 'ahoy', + ]; +} + +/** + * Render a command for each of the given command wrappers. + * + * @param array $command_wrapper + * The command wrappers. + * @param string $command + * The command, or an empty string for the binaries alone. + * + * @return string + * The commands, such as "`make build` or `ahoy build`". + */ +function command_wrapper_text(array $command_wrapper, string $command = ''): string { + $commands = []; + + foreach (command_wrapper_binaries() as $wrapper => $binary) { + if (in_array($wrapper, $command_wrapper, TRUE)) { + $commands[] = '`' . ($command === '' ? $binary : $binary . ' ' . $command) . '`'; + } + } + + return join_list($commands, 'or'); +} + +/** + * Join items into a list for a sentence, such as 'A, B and C'. + * + * @param array $items + * The items. + * @param string $conjunction + * The word before the last item. + * + * @return string + * The list, or an empty string for no items. + */ +function join_list(array $items, string $conjunction = 'and'): string { + $items = array_values($items); + $last = array_pop($items); + + if ($last === NULL) { + return ''; + } + + if ($items === []) { + return $last; + } + + return implode(', ', $items) . ' ' . $conjunction . ' ' . $last; +} + /** * Trim wrapper-specific permissions from Claude settings to match selection. * @@ -500,6 +663,7 @@ function process_internal(string $extension_name, string $extension_machine_name replace_string_content('Drupal module scaffold FE example used for template testing', 'Provides ' . $extension_machine_name . ' functionality.'); replace_string_content('Drupal extension scaffold', $extension_name); replace_string_content('type: module', 'type: ' . $extension_type); + replace_string_content('is a Drupal module.', 'is a Drupal ' . $extension_type . '.'); replace_string_content('[EXTENSION_NAME]', $extension_machine_name); // Restore the attribution link. From df9e3032e01a0d8280d1355d991a9e031e09bacc Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Tue, 13 Oct 2026 09:11:15 +1100 Subject: [PATCH 2/2] Updated snapshots. --- .../fixtures/install/_baseline/.editorconfig | 3 - .../fixtures/install/_baseline/AGENTS.md | 18 +++--- .../install/_baseline/force_crystal.info.yml | 2 +- .../fixtures/install/_baseline/phpunit.xml | 2 +- .../tests/fixtures/install/d10_only/AGENTS.md | 8 +++ .../install/d10_only/force_crystal.info.yml | 9 +++ .../fixtures/install/d10_only/phpunit.xml | 7 +++ .../tests/fixtures/install/d11_only/AGENTS.md | 8 +++ .../install/d11_only/force_crystal.info.yml | 9 +++ .../tests/fixtures/install/d12_only/AGENTS.md | 10 ++++ .../install/d12_only/force_crystal.info.yml | 9 +++ .../fixtures/install/d12_only/phpunit.xml | 7 +++ .../install/gha_both_wrappers/.editorconfig | 7 +++ .../install/gha_both_wrappers/AGENTS.md | 36 +++++++++--- .../install/gha_makefile/.editorconfig | 7 +++ .../fixtures/install/gha_makefile/AGENTS.md | 57 ++++++++++++------- .../install/gha_no_command_wrapper/AGENTS.md | 50 +++++++++++++--- .../fixtures/install/no_cspell/AGENTS.md | 4 +- .../no_functional_javascript/AGENTS.md | 4 +- .../install/no_javascript_lint/AGENTS.md | 2 +- .../tests/fixtures/install/no_jest/AGENTS.md | 4 +- .../fixtures/install/no_php_lint/AGENTS.md | 4 +- .../fixtures/install/no_phpunit/AGENTS.md | 8 +-- .../fixtures/install/no_tools/.editorconfig | 9 ++- .../tests/fixtures/install/no_tools/AGENTS.md | 33 +++++++---- .eddy/tests/fixtures/install/theme/AGENTS.md | 9 +++ .../install/theme/force_crystal.info.yml | 2 +- 27 files changed, 249 insertions(+), 79 deletions(-) create mode 100644 .eddy/tests/fixtures/install/d10_only/AGENTS.md create mode 100644 .eddy/tests/fixtures/install/d10_only/force_crystal.info.yml create mode 100644 .eddy/tests/fixtures/install/d10_only/phpunit.xml create mode 100644 .eddy/tests/fixtures/install/d11_only/AGENTS.md create mode 100644 .eddy/tests/fixtures/install/d11_only/force_crystal.info.yml create mode 100644 .eddy/tests/fixtures/install/d12_only/AGENTS.md create mode 100644 .eddy/tests/fixtures/install/d12_only/force_crystal.info.yml create mode 100644 .eddy/tests/fixtures/install/d12_only/phpunit.xml create mode 100644 .eddy/tests/fixtures/install/gha_both_wrappers/.editorconfig create mode 100644 .eddy/tests/fixtures/install/gha_makefile/.editorconfig create mode 100644 .eddy/tests/fixtures/install/theme/AGENTS.md diff --git a/.eddy/tests/fixtures/install/_baseline/.editorconfig b/.eddy/tests/fixtures/install/_baseline/.editorconfig index 1e0d504c..cdd86be1 100644 --- a/.eddy/tests/fixtures/install/_baseline/.editorconfig +++ b/.eddy/tests/fixtures/install/_baseline/.editorconfig @@ -24,6 +24,3 @@ indent_size = 2 [*.xml] indent_size = 4 - -[Makefile*] -indent_style = tab diff --git a/.eddy/tests/fixtures/install/_baseline/AGENTS.md b/.eddy/tests/fixtures/install/_baseline/AGENTS.md index bc7968af..c7c809b9 100644 --- a/.eddy/tests/fixtures/install/_baseline/AGENTS.md +++ b/.eddy/tests/fixtures/install/_baseline/AGENTS.md @@ -4,12 +4,11 @@ This file provides guidance to AI agents when working with code in this reposito ## Overview -This is a Force Crystal template for creating contributed modules or themes. The project provides a complete development environment with CI configuration, testing setup, and deployment automation for publishing to Drupal.org. +Force Crystal (`force_crystal`) is a Drupal module. The project provides a complete development environment with CI configuration, testing setup, and deployment automation for publishing to Drupal.org. ## Development Commands -**HARD RULE - use the provided command wrappers, never the tool binaries directly.** When `make` or `ahoy` exposes a command for a task, use that command; do not call the underlying binary directly. Each wrapper `chdir`s into `build/` and runs the tool with the config, plugins, and environment that CI uses, so a raw invocation from the repository root silently diverges from CI - it can pass locally while CI fails (or vice versa), or crash outright when a relative path resolves against the wrong directory. If no wrapped command covers what you need, extend the `make` / `ahoy` target rather than making a one-off raw call; if that is not feasible, stop and ask. - +**HARD RULE - use the provided command wrappers, never the tool binaries directly.** When `ahoy` exposes a command for a task, use that command; do not call the underlying binary directly. Each wrapper `chdir`s into `build/` and runs the tool with the config, plugins, and environment that CI uses, so a raw invocation from the repository root silently diverges from CI - it can pass locally while CI fails (or vice versa), or crash outright when a relative path resolves against the wrong directory. If no wrapped command covers what you need, extend the `ahoy` target rather than making a one-off raw call; if that is not feasible, stop and ask. Run each tool through its `ahoy` wrapper, never the binary directly: @@ -25,8 +24,7 @@ Run each tool through its `ahoy` wrapper, never the binary directly: ### Build and Environment Management - -**Using Ahoy (alternative):** +**Using Ahoy:** - `ahoy build` - Complete build process - `ahoy assemble` - Assemble codebase - `ahoy start` - Start development server @@ -90,10 +88,10 @@ Run each tool through its `ahoy` wrapper, never the binary directly: ## Development Workflow -1. Build environment: `make build` or `ahoy build` +1. Build environment: `ahoy build` 2. Develop Force Crystal code in `src/` -3. Check standards: `make lint` or `ahoy lint` -4. Run tests: `make test` or `ahoy test` +3. Check standards: `ahoy lint` +4. Run tests: `ahoy test` 5. Access site at http://localhost:8000 ## Code Quality Tools @@ -107,7 +105,9 @@ Run each tool through its `ahoy` wrapper, never the binary directly: ## CI/CD Support - **GitHub Actions**: `.github/workflows/test.yml` and deployment -- **Matrix testing**: PHP 8.3-8.5, Drupal 10-12 +- **Matrix testing**: + - Drupal 10 on PHP 8.3-8.4 + - Drupal 11 on PHP 8.3-8.5 - **Automated deployment**: Mirror to Drupal.org after CI passes ## Important Notes diff --git a/.eddy/tests/fixtures/install/_baseline/force_crystal.info.yml b/.eddy/tests/fixtures/install/_baseline/force_crystal.info.yml index adb76fc6..978e0b69 100644 --- a/.eddy/tests/fixtures/install/_baseline/force_crystal.info.yml +++ b/.eddy/tests/fixtures/install/_baseline/force_crystal.info.yml @@ -2,7 +2,7 @@ name: Force Crystal type: module description: Force Crystal example. package: Testing -core_version_requirement: ^10 || ^11 || ^12 +core_version_requirement: ^10 || ^11 dependencies: - drupal:config diff --git a/.eddy/tests/fixtures/install/_baseline/phpunit.xml b/.eddy/tests/fixtures/install/_baseline/phpunit.xml index b8a0a0da..ce40c934 100644 --- a/.eddy/tests/fixtures/install/_baseline/phpunit.xml +++ b/.eddy/tests/fixtures/install/_baseline/phpunit.xml @@ -1,5 +1,5 @@ - + ++ + + + ++ + + +