diff --git a/.changelog/twig-evaluation-contract.md b/.changelog/twig-evaluation-contract.md new file mode 100644 index 0000000..05c9c2f --- /dev/null +++ b/.changelog/twig-evaluation-contract.md @@ -0,0 +1,9 @@ +--- +category: added +type: minor +--- + +Add a reproducible, isolated Twig evaluation on the existing PHP matrix. Qualify +native Finder/Git intersection, explicit configuration overrides, grammar parsing, +deterministic native JSON and check/fix boundaries without adding a consumer CLI +profile or changing the package's runtime dependencies. diff --git a/.github/workflows/repository-ci.yml b/.github/workflows/repository-ci.yml index a8a609b..849c88b 100644 --- a/.github/workflows/repository-ci.yml +++ b/.github/workflows/repository-ci.yml @@ -32,4 +32,15 @@ jobs: COMPOSER_CACHE_DIR: ${{ runner.temp }}/standards-composer-cache - run: composer validate --strict - run: composer check + - name: Provision isolated Twig evaluation dependencies + run: composer install --working-dir=tests/Evaluation/Twig --no-dev --no-plugins --no-scripts --no-interaction --prefer-dist --no-progress + env: + COMPOSER_HOME: ${{ runner.temp }}/standards-twig-composer-home + COMPOSER_CACHE_DIR: ${{ runner.temp }}/standards-twig-composer-cache + COMPOSER_VENDOR_DIR: ${{ runner.temp }}/standards-twig-vendor + - name: Evaluate native Twig contracts + run: php tests/Evaluation/Twig/evaluate.php + env: + TWIG_EVALUATION_AUTOLOAD: ${{ runner.temp }}/standards-twig-vendor/autoload.php + TWIG_EVALUATION_TEMP: ${{ runner.temp }} - run: git diff --exit-code diff --git a/docs/twig-evaluation.rst b/docs/twig-evaluation.rst new file mode 100644 index 0000000..f2f124e --- /dev/null +++ b/docs/twig-evaluation.rst @@ -0,0 +1,121 @@ +Native Twig evaluation +====================== + +This contributor evaluation qualifies one formatter, Twig-CS-Fixer, together +with Twig's native syntax parser. It does not expose a Code Standards Twig +command, install anything during a check, or establish a supported consumer +profile. It covers the evaluation portion of `issue #9 +`_; that issue's +broader command, compatibility and acceptance criteria remain separate. + +The nested Composer lock selects published Twig-CS-Fixer 4.1.1, source +``53c1234b6e5d87ac2beaf686774bba013697f9c5``, and Twig 3.30.0, source +``8c737079b726af72ff8ef3c595be9f6a810ea1ef``. The evaluator verifies those actual +installed metadata records. Its graph is separate from the package's runtime +and contributor dependencies. The resolution platform is PHP 8.3.0; the existing +repository CI matrix executes the evaluation on PHP 8.3, 8.4 and 8.5. + +Explicit provisioning +--------------------- + +Dependency provisioning is a separate operation. The evaluator requires an +existing absolute ``vendor/autoload.php`` outside the checkout and fails with +an objective diagnostic when it is absent. It never invokes Composer. CI places +the isolated vendor, Composer home and cache under ``runner.temp`` and disables +plugins and scripts during installation. + +For a local checkout, provision into a newly owned external directory: + +.. code-block:: console + + twig_evaluation_runtime="$(mktemp -d "${TMPDIR:-/tmp}/standards-twig.XXXXXX")" + COMPOSER_HOME="$twig_evaluation_runtime/home" \ + COMPOSER_CACHE_DIR="$twig_evaluation_runtime/cache" \ + COMPOSER_VENDOR_DIR="$twig_evaluation_runtime/vendor" \ + composer install --working-dir=tests/Evaluation/Twig --no-dev --no-plugins --no-scripts --no-interaction --prefer-dist --no-progress + TWIG_EVALUATION_AUTOLOAD="$twig_evaluation_runtime/vendor/autoload.php" \ + TWIG_EVALUATION_TEMP="$twig_evaluation_runtime" \ + php tests/Evaluation/Twig/evaluate.php + +The harness creates and removes only its own randomly named synthetic fixture. +It preserves the caller-provisioned runtime. No real consumer checkout is +analysed or changed by this evaluation. + +Qualified path and rule contracts +-------------------------------- + +``ConfigResolver::resolveConfig([], $configPath, true)`` preserves the native +consumer Finder and disables the native cache. Explicit native CLI paths can +replace that Finder. The harness reproduces that gap and tests the narrower +integration: iterate the complete configured Finder result, then intersect it +with the Git-eligible candidate identities and materialize a sorted, deduplicated +file list. This includes appended Finder iterables, which do not necessarily +pass its directory filters. Configuration cannot expand the selected scan. + +The owned fixture covers Finder exclusions, Git-ignored force-added files, +unselected and outside-appended files, duplicates, spaces and newline-bearing +paths. Git's NUL-delimited output is retained. These assertions qualify the +native integration boundary; they do not introduce a second Git diff parser. + +The fixture selects native ``Standard\Twig`` explicitly, rather than silently +adding all rules from ``Standard\TwigCsFixer`` or a framework standard. Consumer +``overrideRule()`` and ``removeRule()`` remain authoritative. ``addRule()`` can +retain distinct configurations of the same class, so it is not treated as a +universal last-wins replacement. Owned defaults are not reapplied after an +explicit consumer configuration. + +Syntax, writes and reporting +---------------------------- + +Native style checks alone do not establish complete Twig grammar validity. +The test worker first calls native ``Environment::tokenize()`` and ``parse()`` +for all effective files. Grammar failure prevents any fix pass. The parser +receives the selected native Config's extensions, token parsers and node +visitors. A custom tag and registered application function parse successfully; +the function is not invoked and a missing include is not loaded. An unregistered +custom tag fails. No template is compiled or rendered. + +The style pass uses native ``Linter``, ``Tokenizer`` and an explicit ``Fixer`` +only for the requested write case. Check preserves source/configuration/lock +bytes; explicit fix is followed by check and a second idempotent fix. Native +JSON comes directly from ``GitlabReporter`` into a fresh output buffer, without +scraping ANSI or human-output prefixes. Every result gets a new reporter because +the native object retains fingerprint collision state. Repeated checks produce +byte-identical JSON, including escaped control characters in relative paths. +The native report's error/fatal count determines failure, independently of +presentation thresholds. + +Limits +------ + +Configuration and registered extension PHP are explicitly trusted executable +code. Parsing can execute token parsers and node visitors; absence of rendering +is not a sandbox guarantee. The native Config resolver does not automatically +import the consumer's ``vendor/autoload.php`` in the qualified version. A +sentinel regression checks that boundary; an explicit configuration can still +load an application intentionally. + +Twig-CS-Fixer Config 4.1.1 exposes extensions, token parsers and node visitors, not an arbitrary +complete application Environment, custom lexer, loader or environment options. +Those full-environment integrations are unsupported by this evaluation. Unknown +application functions and tags need native registration. The style engine's +stubbed environment is not substituted for the explicit syntax environment. +Parsing with Twig 3.30.0 does not prove compatibility with an older consumer Twig +minimum or the existence of imported application resources. + +Twigcs remains an overlapping style comparator, not a second mandatory pass. +The evaluated standalone twig-linter package has incompatible Symfony constraints +and is not added. This bounded strategy avoids three mandatory engines while +preserving the unresolved consumer-profile decisions in issue #9. + +Implementation references +------------------------- + +- `Native Config resolution and Finder precedence + `_ +- `Native rule overrides + `_ +- `Native JSON reporter + `_ +- `Twig Environment parsing API + `_ diff --git a/tests/Evaluation/Twig/composer.json b/tests/Evaluation/Twig/composer.json new file mode 100644 index 0000000..5e15ea2 --- /dev/null +++ b/tests/Evaluation/Twig/composer.json @@ -0,0 +1,22 @@ +{ + "name": "fixture/code-standards-twig-evaluation", + "description": "Isolated test dependencies for the native Twig evaluation", + "type": "project", + "license": "MIT", + "require": { + "php": "^8.3", + "symfony/console": "^7.4", + "symfony/filesystem": "^7.4", + "symfony/finder": "^7.4", + "symfony/process": "^7.4", + "twig/twig": "3.30.*", + "vincentlanglet/twig-cs-fixer": "4.1.*" + }, + "config": { + "allow-plugins": false, + "platform": { + "php": "8.3.0" + }, + "sort-packages": true + } +} diff --git a/tests/Evaluation/Twig/composer.lock b/tests/Evaluation/Twig/composer.lock new file mode 100644 index 0000000..872c396 --- /dev/null +++ b/tests/Evaluation/Twig/composer.lock @@ -0,0 +1,1185 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "a5630985ac92def1226f181ab1df9f52", + "packages": [ + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "21f1bc2296f7ff6043241a8dcbc09559b3732820" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/21f1bc2296f7ff6043241a8dcbc09559b3732820", + "reference": "21f1bc2296f7ff6043241a8dcbc09559b3732820", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-12T18:45:38+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/fd0e3462be6d32a0d130e8423e4940a7688162d8", + "reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-06T20:05:19+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.20", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", + "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.20" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-26T06:31:10+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:02:09+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-24T15:19:04+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "e5247f4a5e0797d232ad8e8e6803aeb341843c83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/e5247f4a5e0797d232ad8e8e6803aeb341843c83", + "reference": "e5247f4a5e0797d232ad8e8e6803aeb341843c83", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-08T16:56:12+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "9bc35d074ad16a58c21cd52b18c5cacc4f4c3981" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/9bc35d074ad16a58c21cd52b18c5cacc4f4c3981", + "reference": "9bc35d074ad16a58c21cd52b18c5cacc4f4c3981", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-03T15:15:30+00:00" + }, + { + "name": "twig/twig", + "version": "v3.30.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "8c737079b726af72ff8ef3c595be9f6a810ea1ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/8c737079b726af72ff8ef3c595be9f6a810ea1ef", + "reference": "8c737079b726af72ff8ef3c595be9f6a810ea1ef", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "php-cs-fixer/shim": "^3.0@stable", + "phpstan/phpstan": "^2.0@stable", + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.30.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-09-25T13:20:01+00:00" + }, + { + "name": "vincentlanglet/twig-cs-fixer", + "version": "4.1.1", + "source": { + "type": "git", + "url": "https://github.com/VincentLanglet/Twig-CS-Fixer.git", + "reference": "53c1234b6e5d87ac2beaf686774bba013697f9c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/53c1234b6e5d87ac2beaf686774bba013697f9c5", + "reference": "53c1234b6e5d87ac2beaf686774bba013697f9c5", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0.0", + "ext-ctype": "*", + "php": ">=8.1", + "symfony/console": "^5.4.9 || ^6.4 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/finder": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/string": "^5.4.42 || ^6.4.10 || ~7.0.10 || ^7.1.3 || ^8.0", + "twig/twig": "^3.15", + "webmozart/assert": "^1.10 || ^2.0" + }, + "require-dev": { + "composer/semver": "^3.2.0", + "dereuromark/composer-prefer-lowest": "^0.1.10", + "ergebnis/composer-normalize": "^2.29", + "friendsofphp/php-cs-fixer": "^3.13.0", + "infection/infection": "^0.26.16 || ^0.32.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpstan/phpstan-webmozart-assert": "^2.0", + "phpunit/phpunit": "^9.5.26 || ^11.5.18 || ^12.1.3", + "rector/rector": "^2.0.0", + "shipmonk/composer-dependency-analyser": "^1.6", + "symfony/process": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/twig-bridge": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/ux-twig-component": "^2.2.0", + "twig/cache-extra": "^3.2" + }, + "bin": [ + "bin/twig-cs-fixer" + ], + "type": "coding-standard", + "autoload": { + "psr-4": { + "TwigCsFixer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Vincent Langlet" + } + ], + "description": "A tool to automatically fix Twig code style", + "homepage": "https://github.com/VincentLanglet/Twig-CS-Fixer", + "support": { + "issues": "https://github.com/VincentLanglet/Twig-CS-Fixer/issues", + "source": "https://github.com/VincentLanglet/Twig-CS-Fixer/tree/4.1.1" + }, + "funding": [ + { + "url": "https://github.com/VincentLanglet", + "type": "github" + } + ], + "time": "2026-09-08T21:26:25+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.5.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "8a163706bbcac613733dba03f51e13b5eb1bb819" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/8a163706bbcac613733dba03f51e13b5eb1bb819", + "reference": "8a163706bbcac613733dba03f51e13b5eb1bb819", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.5.0" + }, + "time": "2026-10-05T23:14:19+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^8.3" + }, + "platform-dev": {}, + "platform-overrides": { + "php": "8.3.0" + }, + "plugin-api-version": "2.9.0" +} diff --git a/tests/Evaluation/Twig/evaluate.php b/tests/Evaluation/Twig/evaluate.php new file mode 100644 index 0000000..09a5b1c --- /dev/null +++ b/tests/Evaluation/Twig/evaluate.php @@ -0,0 +1,197 @@ + $arguments Actual executable and separate arguments; never shell fragments. + * @param array $environment Explicit synthetic-fixture environment. + */ +function process(array $arguments, string $directory, array $environment): Process +{ + $process = new Process($arguments, $directory, $environment, timeout: 20); + $process->run(); + + return $process; +} + +/** + * Captures hashes of owned source/configuration; generated test markers are deliberately separate. + * + * @return array Relative fixture names mapped to exact byte hashes. + */ +function snapshot(string $directory): array +{ + $result = []; + foreach (['composer.json', 'composer.lock', 'native-config.php', 'templates/keep a.twig', "templates/keep\nline.twig", + 'templates/excluded.twig', 'templates/unselected.twig', 'templates/gitignored.twig', 'templates/grammar.twig', + 'templates/custom.twig', '../outside.twig'] as $path) { + $hash = hash_file('sha256', $directory . '/' . $path); + if (false === $hash) { + throw new RuntimeException('Cannot snapshot owned fixture: ' . $path); + } + $result[$path] = $hash; + } + + return $result; +} + +/** + * Runs the native test worker; complete stdout must be native JSON, never a scraped human-output suffix. + * + * @param list $selected Git-eligible paths; native configuration can narrow this scope. + * @param array $environment Explicit synthetic-fixture environment. + */ +function evaluate(string $autoload, string $directory, string $config, string $mode, array $selected, array $environment): Process +{ + $process = process([PHP_BINARY, __DIR__ . '/worker.php', $autoload, $directory, $config, $mode, ...$selected], $directory, $environment); + if (in_array($process->getExitCode(), [0, 1], true)) { + json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + } + + return $process; +} + +$GLOBALS['evaluation_checks'] = []; +$fixture = null; +$status = 0; +try { + $autoload = getenv('TWIG_EVALUATION_AUTOLOAD'); + if (!is_string($autoload) || !str_starts_with($autoload, '/') || !is_file($autoload)) { + throw new InvalidArgumentException('Provision the isolated Twig lock first and set TWIG_EVALUATION_AUTOLOAD to its absolute vendor/autoload.php; the evaluator never installs dependencies.'); + } + $autoload = realpath($autoload); + $repository = realpath(dirname(__DIR__, 3)); + $temporary = realpath(getenv('TWIG_EVALUATION_TEMP') ?: sys_get_temp_dir()); + if (false === $temporary || !is_dir($temporary) || str_starts_with($temporary . '/', $repository . '/') + || str_starts_with($autoload, $repository . '/')) { + throw new InvalidArgumentException('Evaluation runtime and temporary fixtures must be provisioned outside the checkout.'); + } + require $autoload; + + $manifest = hash_file('sha256', __DIR__ . '/composer.json'); + $lock = hash_file('sha256', __DIR__ . '/composer.lock'); + $installed = json_decode(file_get_contents(dirname($autoload) . '/composer/installed.json'), true, flags: JSON_THROW_ON_ERROR); + $versions = []; + foreach ($installed['packages'] as $package) { + if (in_array($package['name'], ['vincentlanglet/twig-cs-fixer', 'twig/twig'], true)) { + $versions[$package['name']] = ['version' => ltrim($package['version'], 'v'), 'source' => $package['source']['reference']]; + } + } + verify(($versions['vincentlanglet/twig-cs-fixer'] ?? null) === ['version' => '4.1.1', 'source' => '53c1234b6e5d87ac2beaf686774bba013697f9c5'], 'published-formatter-runtime'); + verify(($versions['twig/twig'] ?? null) === ['version' => '3.30.0', 'source' => '8c737079b726af72ff8ef3c595be9f6a810ea1ef'], 'published-parser-runtime'); + + $fixture = $temporary . '/code-standards-twig-' . bin2hex(random_bytes(12)); + $directory = $fixture . '/consumer'; + $filesystem = new Filesystem(); + $filesystem->mkdir([$directory . '/templates', $directory . '/vendor', $directory . '/.runtime/tmp'], 0700); + $environment = array_fill_keys(array_keys(getenv()), false); + $environment['PATH'] = getenv('PATH') ?: '/usr/bin:/bin'; + $environment['HOME'] = $directory . '/.runtime'; + $environment['TMPDIR'] = $directory . '/.runtime/tmp'; + $environment['GIT_CONFIG_GLOBAL'] = '/dev/null'; + $environment['GIT_CONFIG_NOSYSTEM'] = '1'; + $git = static function (array $arguments) use ($directory, $environment): string { + $process = process(['git', ...$arguments], $directory, $environment); + if (!$process->isSuccessful()) { + throw new RuntimeException($process->getErrorOutput()); + } + + return $process->getOutput(); + }; + + file_put_contents($directory . '/composer.json', '{"name":"fixture/twig-consumer"}'); + file_put_contents($directory . '/composer.lock', '{"packages":[]}'); + file_put_contents($directory . '/.gitignore', "/vendor/\n/.runtime/\n/templates/gitignored.twig\n"); + file_put_contents($directory . '/vendor/autoload.php', ' in_array($path, [ + 'templates/keep a.twig', "templates/keep\nline.twig", 'templates/excluded.twig', 'templates/gitignored.twig', + ], true) && !isset($ignored[$path]))); + verify(!in_array('templates/gitignored.twig', $selected, true), 'git-ignored-tracked-template-excluded'); + + $before = snapshot($directory); + $check = evaluate($autoload, $directory, 'native-config.php', 'check', $selected, $environment); + verify(1 === $check->getExitCode(), 'native-style-failure:' . $check->getErrorOutput()); + verify(snapshot($directory) === $before, 'check-preserves-source-config-and-lock'); + $json = json_decode($check->getOutput(), true, flags: JSON_THROW_ON_ERROR); + $reported = array_values(array_unique(array_column(array_column($json, 'location'), 'path'))); + verify($reported === ["templates/keep\nline.twig", 'templates/keep a.twig'], 'finder-intersection-sorted-and-deduplicated'); + $again = evaluate($autoload, $directory, 'native-config.php', 'check', $selected, $environment); + verify($again->getOutput() === $check->getOutput() && 1 === $again->getExitCode(), 'native-json-byte-deterministic'); + verify(str_contains($check->getOutput(), 'keep\\nline.twig'), 'native-json-escapes-newline-path'); + + // Deliberately reproduce the upstream path replacement instead of proposing that proxy as the adapter. + $replaced = (new ConfigResolver($directory))->resolveConfig([$directory . '/templates/excluded.twig'], $directory . '/native-config.php', true); + verify(count(iterator_to_array($replaced->getFinder())) === 1, 'cli-path-replacement-gap-reproduced'); + verify(null === $replaced->getCacheManager() && null === $replaced->getCacheFile(), 'native-cache-disabled'); + + $override = str_replace('return $config;', '$config->getRuleset()->overrideRule(new TwigCsFixer\\Rules\\Delimiter\\DelimiterSpacingRule(beforeOverride: ["}}" => 0], afterOverride: ["{{" => 0])); return $config;', $config, $count); + verify(1 === $count, 'known-native-fixture-override-point'); + file_put_contents($directory . '/override.php', $override); + verify(0 === evaluate($autoload, $directory, 'override.php', 'check', $selected, $environment)->getExitCode(), 'native-rule-override-last'); + file_put_contents($directory . '/remove.php', str_replace('return $config;', '$config->getRuleset()->removeRule(TwigCsFixer\\Rules\\Delimiter\\DelimiterSpacingRule::class); return $config;', $config)); + verify(0 === evaluate($autoload, $directory, 'remove.php', 'check', $selected, $environment)->getExitCode(), 'native-rule-removal-last'); + verify(snapshot($directory) === $before, 'override-checks-do-not-write'); + + verify(0 === evaluate($autoload, $directory, 'native-config.php', 'fix', $selected, $environment)->getExitCode(), 'explicit-native-fix'); + verify(file_get_contents($directory . '/templates/keep a.twig') === "{{ value }}\n", 'native-fixed-bytes'); + $fixed = snapshot($directory); + verify(0 === evaluate($autoload, $directory, 'native-config.php', 'check', $selected, $environment)->getExitCode(), 'native-post-fix-check'); + verify(0 === evaluate($autoload, $directory, 'native-config.php', 'fix', $selected, $environment)->getExitCode() && snapshot($directory) === $fixed, 'second-fix-idempotent'); + foreach (['templates/excluded.twig', 'templates/unselected.twig', 'templates/gitignored.twig', '../outside.twig', 'composer.json', 'composer.lock', 'native-config.php'] as $path) { + verify($fixed[$path] === $before[$path], 'outside-effective-selection-unchanged:' . $path); + } + + file_put_contents($directory . '/templates/keep a.twig', "{{needs_fix}}\n"); + $grammarBefore = snapshot($directory); + $grammar = evaluate($autoload, $directory, 'native-config.php', 'fix', ['templates/grammar.twig', 'templates/keep a.twig'], $environment); + $syntax = json_decode($grammar->getOutput(), true, flags: JSON_THROW_ON_ERROR); + verify(1 === $grammar->getExitCode() && ($syntax[0]['severity'] ?? null) === 'critical', 'native-grammar-rejected-before-fix'); + verify(snapshot($directory) === $grammarBefore, 'syntax-failure-performs-no-fix'); + verify(0 === evaluate($autoload, $directory, 'native-config.php', 'check', ['templates/custom.twig'], $environment)->getExitCode(), 'custom-tag-function-and-include-parse'); + verify(is_file($directory . '/tag-parsed') && !file_exists($directory . '/function-called'), 'custom-parser-used-without-rendering'); + file_put_contents($directory . '/bare.php', 'setFinder((new TwigCsFixer\\File\\Finder())->in(__DIR__."/templates")); return $config;'); + verify(1 === evaluate($autoload, $directory, 'bare.php', 'check', ['templates/custom.twig'], $environment)->getExitCode(), 'unregistered-custom-tag-rejected'); + verify(!file_exists($directory . '/autoload-executed') && !file_exists($directory . '/.twig-cs-fixer.cache'), 'no-implicit-consumer-autoload-or-project-cache'); + + $missing = process([PHP_BINARY, __FILE__], $directory, $environment); + verify(2 === $missing->getExitCode() && str_contains($missing->getErrorOutput(), 'never installs dependencies'), 'missing-provisioned-loader-fails-objectively'); + verify($manifest === hash_file('sha256', __DIR__ . '/composer.json') && $lock === hash_file('sha256', __DIR__ . '/composer.lock'), 'evaluation-does-not-change-its-dependency-inputs'); + echo json_encode(['result' => 'passed', 'checks' => count($GLOBALS['evaluation_checks']), 'versions' => $versions], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), PHP_EOL; +} catch (Throwable $error) { + fwrite(STDERR, $error->getMessage() . PHP_EOL); + $status = 2; +} finally { + if (null !== $fixture) { + (new Filesystem())->remove($fixture); + } +} + +exit($status); diff --git a/tests/Evaluation/Twig/fixtures/native-config.php b/tests/Evaluation/Twig/fixtures/native-config.php new file mode 100644 index 0000000..ed3d7bb --- /dev/null +++ b/tests/Evaluation/Twig/fixtures/native-config.php @@ -0,0 +1,48 @@ +setRuleset((new Ruleset())->addStandard(new Twig())); +$config->setFinder((new Finder())->in(__DIR__ . '/templates')->notName('excluded.twig')->append([ + __DIR__ . '/../outside.twig', + __DIR__ . '/templates/keep a.twig', +])); +$config->addTwigExtension(new class extends AbstractExtension { + public function getFunctions(): array + { + return [new TwigFunction('application_function', static function (): void { + file_put_contents(__DIR__ . '/function-called', 'unexpected rendering'); + })]; + } + + public function getTokenParsers(): array + { + return [new class extends AbstractTokenParser { + public function parse(Token $token): Node + { + file_put_contents(__DIR__ . '/tag-parsed', 'native parsing'); + $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); + + return new Node([], [], $token->getLine()); + } + + public function getTag(): string + { + return 'fixture_mark'; + } + }]; + } +}); + +return $config; diff --git a/tests/Evaluation/Twig/worker.php b/tests/Evaluation/Twig/worker.php new file mode 100644 index 0000000..bf45af5 --- /dev/null +++ b/tests/Evaluation/Twig/worker.php @@ -0,0 +1,123 @@ + $paths Git-eligible template paths inside the owned fixture. + * @return list Sorted, deduplicated native file inputs. + */ +function selectedFiles(Config $config, array $paths, string $directory): array +{ + $selected = []; + foreach ($paths as $path) { + $real = realpath($path); + if (false === $real || !is_file($real) || is_link($path) || !str_starts_with($real, $directory . '/')) { + throw new InvalidArgumentException('Expected readable owned template paths inside the fixture.'); + } + $selected[$real] = true; + } + + $files = []; + foreach ($config->getFinder() as $file) { + $real = $file->getRealPath(); + if (false !== $real && isset($selected[$real])) { + $relative = substr($real, strlen($directory) + 1); + $files[$relative] = new SplFileInfo($relative); + } + } + ksort($files, SORT_STRING); + + return array_values($files); +} + +/** + * Parses with explicitly registered native extensions; never compiles, renders or discovers an application. + * + * @param list $files Effective native Finder/Git intersection. + */ +function syntaxReport(Config $config, array $files): Report +{ + $environment = new Environment(new ArrayLoader(), ['cache' => false]); + foreach ($config->getTwigExtensions() as $extension) { + $environment->addExtension($extension); + } + foreach ($config->getTokenParsers() as $parser) { + $environment->addTokenParser($parser); + } + foreach ($config->getNodeVisitors() as $visitor) { + $environment->addNodeVisitor($visitor); + } + + $report = new Report($files); + foreach ($files as $file) { + $content = file_get_contents($file->getPathname()); + if (false === $content) { + throw new RuntimeException('Cannot read selected fixture template.'); + } + try { + $source = new Source($content, $file->getPathname()); + $environment->parse($environment->tokenize($source)); + } catch (Error $error) { + $report->addViolation(new Violation( + Violation::LEVEL_FATAL, + $error->getRawMessage(), + $file->getPathname(), + identifier: new ViolationId(line: $error->getTemplateLine()), + )); + } + } + + return $report; +} + +// Fixed test-worker arguments: preinstalled autoloader, owned fixture, native config, mode and selected paths. +try { + if ($argc < 6 || !in_array($argv[4], ['check', 'fix'], true) || !is_file($argv[1])) { + throw new InvalidArgumentException('Twig evaluation worker requires its provisioned runtime and owned fixture arguments.'); + } + require $argv[1]; + $directory = realpath($argv[2]); + if (false === $directory || !is_dir($directory) || !chdir($directory)) { + throw new InvalidArgumentException('Cannot enter the owned Twig fixture directory.'); + } + + $config = (new ConfigResolver($directory))->resolveConfig([], $argv[3], true); + $files = selectedFiles($config, array_slice($argv, 5), $directory); + $report = syntaxReport($config, $files); + if (0 === $report->getTotalErrors()) { + $environment = new StubbedEnvironment($config->getTwigExtensions(), $config->getTokenParsers(), $config->getNodeVisitors()); + $tokenizer = new Tokenizer($environment); + $report = (new Linter($environment, $tokenizer, $config->getCacheManager()))->run( + $files, + $config->getRuleset(), + 'fix' === $argv[4] ? new Fixer($tokenizer) : null, + ); + } + + $output = new BufferedOutput(); + // Native reporter instances retain fingerprint collision state; each result gets a fresh one. + (new GitlabReporter())->display($output, $report, null, false); + echo $output->fetch(); + exit(0 === $report->getTotalErrors() ? 0 : 1); +} catch (Throwable $error) { + fwrite(STDERR, 'Twig evaluation worker: ' . $error->getMessage() . PHP_EOL); + exit(2); +}