Proof of concept for PhpSpec 9#1511
Conversation
47a29bb to
3c692b8
Compare
3c692b8 to
f1d53d0
Compare
|
@torchello @Jean85 @stof @everzet @ciaranmcnulty I also put together a website with the new concept |
|
Questions:
|
|
This looks like a totally different test framework. Maybe it should be released as a different package instead of a new major version of |
| operating-system: ubuntu-latest | ||
| composer-flags: --prefer-lowest | ||
|
|
||
| - name: Run static analysis (psalm) |
There was a problem hiding this comment.
Why removing static analysis from the CI setup ?
There was a problem hiding this comment.
I had stan locally, didn't realise we had psalm until merging. Restored 7e23af2
There was a problem hiding this comment.
Well, I'm fine with using phpstan (this is what I'm using in all my projects, as I find it superior these days, especially when it comes to extensions). but it should be part of the CI
There was a problem hiding this comment.
Ok, reverting it to stan (I prefer it also), restored 7e1284b
| asset_path: phpspec.phar | ||
| asset_name: phpspec.phar | ||
| asset_content_type: application/zip | ||
| - uses: actions/download-artifact@v4 |
There was a problem hiding this comment.
this should not downgrade the action
| "symfony/console": "^7.0 || ^8.0", | ||
| "symfony/yaml": "^7.0 || ^8.0", | ||
| "ext-dom": "*", | ||
| "ext-curl": "*", |
There was a problem hiding this comment.
why making ext-curl a mandatory requirement ?
There was a problem hiding this comment.
Moving it to suggest. It is used with some optional AI features 7fecb99
| @@ -0,0 +1,6 @@ | |||
| parameters: | |||
| level: 1 | |||
There was a problem hiding this comment.
level 1 of phpstan is basically useless. A new codebase should actually start at level 8 or higher.
There was a problem hiding this comment.
restored psalm anyway
There was a problem hiding this comment.
| * Supports Background, Scenario, Scenario Outline with Examples tables, DataTables, | ||
| * Doc strings, tags, and And/But step keywords. | ||
| */ | ||
| final class GherkinParser |
There was a problem hiding this comment.
Please don't write another Gherkin parser. Use cucumber/gherkin instead to use the official parser that is tested against the Gherkin shared testsuite (or use behat/gherkin if you prefer, but that would be better to use the official parser to be future-proof).
The current parser implementation is not spec compliant.
| * Manages a scope stack to track the current context/example registry during spec | ||
| * file loading, and dispatches events to registered subscribers and listeners. | ||
| */ | ||
| final class Dispatcher |
There was a problem hiding this comment.
is it expected that the dispatcher is all based on global state while not being internal ?
There was a problem hiding this comment.
It is pretty common for function based testing frameworks to keep the state somewhere global. However we can use a slightly more OO approach for that f997733
| ], | ||
| handler: function (array $args) use ($filesystem) { | ||
| $path = $args['path']; | ||
| if (!str_starts_with($path, '/')) { |
There was a problem hiding this comment.
this won't work on Windows. Absolute paths don't always start with /
| return "File not found: {$args['path']}"; | ||
| } | ||
|
|
||
| return $filesystem->read($path); |
There was a problem hiding this comment.
shouldn't this protect against directory traversal like ../../../etc/password or /etc/password which are outside the project ?
| * Static registry holding the Browser Client instance. | ||
| * Lazily creates the client from Configuration on first access. | ||
| */ | ||
| class BrowserRegistry |
There was a problem hiding this comment.
shouldn't this class be registered in its own file following PSR-4 ?
There was a problem hiding this comment.
thus, it is currently defined outside the phpspec namespace, which is even worse.
| * Generates PHP class files from a fully qualified class name. | ||
| * Creates the directory structure and writes a minimal class skeleton. | ||
| */ | ||
| final class ClassGenerator |
There was a problem hiding this comment.
Most of these classes should probably be tagged as @internal, to properly define the public API covered by semver.
| */ | ||
| private function yamlEscape(string $value): string | ||
| { | ||
| if (str_contains($value, "\n") || str_contains($value, '"') || str_contains($value, ':')) { |
There was a problem hiding this comment.
A string starting with a single quote will also need escaping, to avoid being parsed as a single-quoted string.
| "php": "^8.2 || ^8.3 || ^8.4 || ^8.5", | ||
| "symfony/console": "^7.0 || ^8.0", | ||
| "symfony/yaml": "^7.0 || ^8.0", | ||
| "ext-dom": "*", |
There was a problem hiding this comment.
isn't ext-dom needed only for the JUnit formatter ? If yes, it should be an optional dependency IMO.
|
|
||
| ## [9.0.0-poc] — Proof of Concept | ||
|
|
||
| ### Breaking Changes |
There was a problem hiding this comment.
another breaking change: breaks compat with all existing phpspec extensions, as it is basically a different project.
There was a problem hiding this comment.
Yes, this is intentional — the extension API has changed significantly (event dispatcher, formatter interface, etc.). We'll document a migration guide for extension authors. The new extension interfaces (ListenerExtension, MatcherExtension, FormatterExtension, CommandExtension) are simpler than the current system. Releasing as a separate package (per your earlier suggestion) also avoids breaking existing extensions that target phpspec/phpspec ^8.
| @@ -0,0 +1,35 @@ | |||
| # Changelog — PhpSpec 9 | |||
There was a problem hiding this comment.
why using a separate changelog file ?
There was a problem hiding this comment.
Fixed ef8d11b — consolidated into CHANGES.md, removed CHANGES-v9.md
| <?php | ||
|
|
||
| /** | ||
| * Sanity-checks a release for consistency |
There was a problem hiding this comment.
Fixed ef8d11b — restored check-release.php adapted for the v9 structure
|
@MarcelloDuarte the website using gray text on a black background is almost unreadable. Please change the website styles to use good contrasting colors. |
@Jean85 thanks for the feedback! 🙏 On the AI question — the code is original, written with AI as a development tool (same as using an IDE with autocomplete). No third-party code was incorporated, so no license concerns. I kept Konstantin in the copyright and added Ciaran for his longstanding contributions as a maitainer. On the migration — we'd provide a migration tool to mechanically convert ObjectBehavior specs to the new syntax, and maintain 8.x with security/compatibility fixes during a transition period. Were you thinking of anything beyond that? |
|
@stof thanks for your feedback. I will go over one by one.
That is an excellent idea. Releasing this as phpspec/phpspec-9 or phpspec/phpspec-next makes the migration story much better. It would help with the issues raised by @Jean85 as well. The downside is it fragments the brand/community a little, but for adoption it's arguably the pragmatic choice. If the adoption takes off, we could move to phpspec/phpspec on 9.1 to counter that. |
|
Maybe using |
Restores Psalm (level 4 with baseline) as the static analysis tool, matching the upstream project's tooling choice.
checkout@v4 → v5, download-artifact@v4 → v5
4c20551 to
9a9d91b
Compare
Move version-dependent UndefinedInterfaceMethod suppression from baseline to psalm.xml config to avoid stale baseline entries across different Symfony Console versions.
ef897a4 to
e6b613b
Compare
Only needed by the optional AI provider packages, not by core.
Rewrite the hand-rolled Gherkin parser as a thin adapter over cucumber/gherkin v39, the spec-compliant parser tested against the Cucumber shared test suite. This adds i18n support, Rules, and correct handling of all Gherkin edge cases. The internal node types (FeatureNode, ScenarioNode, StepNode, etc.) are unchanged — only the parsing layer is swapped.
Remove unused FeatureChild import and fix fn() spacing.
Cover the parse-error and null-document branches to satisfy the 90% coverage threshold.
Extract global state from Dispatcher into a separate @internal DispatcherRegistry service locator. The Dispatcher is now a clean instance with no static properties; all call sites go through DispatcherRegistry::dispatcher() to access the singleton. This addresses the review feedback about the Dispatcher being based on global state while not being internal.
Switch static analysis from Psalm to PHPStan, running at level 8 with zero errors. Fixes all type issues across the codebase including missing type annotations, nullable safety, return type mismatches, and undefined method calls. Adds GeneratedDouble interface for type-safe access to eval'd test double methods.
The PHPStan level 8 type fixes added guard clauses and instanceof checks that created uncovered branches. Add specs covering error capture, result classification, mock lifecycle, formatters, and context hooks to bring coverage from 89.6% back above 90%.
Use DIRECTORY_SEPARATOR and a regex for Windows drive letters (e.g. C:\) instead of hardcoding '/'.
Resolve path segments and verify the resolved path stays within the project root before reading files or listing directories. Blocks ../../../etc/passwd and absolute paths outside the project.
Was defined inline in functions.php outside any namespace. Now lives in PhpSpec\Browser\BrowserRegistry following PSR-4.
Tag all implementation-detail classes as @internal, leaving only the public API (DSL functions, Expectation matchers, extension interfaces, Configuration, and StoryBDD user-facing types) uncovered by the annotation.
ClassGenerator::resolveFqcn() now accepts an optional psr4_prefix
parameter. When set, matching namespace segments are stripped from
the directory path so App\Model\User generates src/Model/User.php
instead of src/App/Model/User.php.
Configurable via phpspec.yaml:
psr4_prefix: App
or per suite:
suites:
default:
namespace: App
src: src
Traits cannot be instantiated and enums cannot be extended, so attempting to mock them should fail early with a clear message.
- Use FQCN hash in mock class names to prevent short-name collisions - Generate mocks in PhpspecDouble namespace to avoid global conflicts - Prefix internal properties with ______phpspec_ to avoid parent conflicts - Handle intersection return types by implementing all interfaces - Fix formatTypeSyntax to recurse into union/intersection members - Keep ReflectionType objects instead of overwriting with strings - Include class hash in MockedMethodFor wrapper names for uniqueness - Reject all internal interfaces (Traversable, DateTimeInterface, etc.) not just Throwable
Cover the new code paths from the Mock\Double refactor to restore coverage above 90%. Also fix trait_exists/enum_exists check in the initial class validation.
The class has a mutable call count so it cannot be readonly.
The stub configuration methods (toReturn, toThrow, toReturnUsing) and identity methods (______PhpSpecGetDouble, ______PhpSpecGetMethod) were duplicated across 4 eval'd class templates. Extract them into a trait for single-source maintenance.
Replace 14 .view.php template files with a single PrettyViews class containing static methods with proper type signatures. Removes TemplateRenderer and the require-based rendering approach. Each method accepts OutputInterface and typed result objects, enabling full static analysis and eliminating the separate PHP tags / extracted variables pattern.
Handle all YAML special characters including single quotes, indicators (?, :, -, etc.), flow collection markers, and control characters. Previously only newlines, double quotes and colons triggered quoting.
ext-dom is only needed for the JUnit XML formatter and Clover coverage reports, not for core functionality.
Drop XDEBUG_CC_DEAD_CODE flag which causes segfaults on large suites, and add periodic flush() that drains Xdebug's internal buffers every 50 examples to prevent memory corruption.
The periodic flush mechanism stopped and restarted Xdebug coverage between batches, which lost context about previously loaded files and dropped coverage from 90% to 82%. Revert to the simple single-session approach which works correctly on CI. The macOS segfault during local coverage collection is a known Xdebug/macOS issue unrelated to this code.
Merge CHANGES-v9.md into CHANGES.md and remove the separate file. Restore check-release.php adapted for the v9 project structure, verifying version consistency across bin/phpspec, composer.json branch alias, and CHANGES.md headings.
The parser was hardcoding 'inline.feature' as the URI, so parse errors didn't identify which file failed. Now the Loader passes the real file path through to parseString().
Use pickles instead of walking the GherkinDocument AST directly. Pickles handle Rule children, background merging, and Scenario Outline expansion automatically. Step keywords are preserved via AST node ID lookup.
Cover doc strings, data tables, keyword resolution, and tag filtering to restore coverage above 90%.
8dfddae to
b8ed517
Compare
Was defined inline in functions.php outside any namespace. Now lives in PhpSpec\StoryBDD\StoryBDDRegistry following PSR-4.
Replace eager init() call in functions.php with lazy accessors steps() and hooks() that create registries on first access. Avoids running initialization when autoloading the file outside of phpspec execution.
When AI is not configured, the welcome screen and error messages now indicate that an ai: section in phpspec.yaml is needed, rather than showing AI examples that won't work.
|
Ok, let me know if we have more questions, feedback. I can redirect this to another repository either:
|
|
Hey folks! Sorry, I missed the PR, catching up.... @MarcelloDuarte First: this is impressive work. I need a few days to go through the code properly, but I think this direction is genuinely exciting and worth pushing forward. New packageI agree this should be released as a new package, and New styleI'm personally not a fan of describe/it style: describe(Calculator::class, function () {
it('adds numbers', function () {
expect((new Calculator())->add(2, 3))->toBe(5);
});
});Mostly because AI featuresI’m very curious to try this and hear real feedback. AI-oriented tooling fits the current moment and can bring a lot of attention to the framework. One side note: I expect AI to take over more of the spec and implementation loop over time, so I’d also like us to think about agent-driven workflows, not only human-authored ones. Since the AI features are optional, that feels like a good foundation for experimenting without forcing a particular direction too early. |
|
Hey @torchello, Good to hear from you!
👍
The attempt here was to normalise phpspec a bit more with other spec frameworks like jasmin and rspec. And yes,
Exactly right! And phpspec goals has always been make test a natural part of the development. If people naturally interacts with AI nowadays, this will fit in with current styles and could potentially integrate with IDEs plugins/extensions as well.
Yep. That would be a nice direction to explore. |
Summary
This is a proof of concept for PhpSpec 9 — a ground-up modernisation of the framework.
Why PhpSpec 9?
PhpSpec has served the PHP community well for over a decade, but the ecosystem has moved on:
PHP itself has evolved dramatically. Enums, fibers, readonly classes, first-class callables, named arguments — modern PHP enables patterns that weren't possible when PhpSpec was designed. The current architecture can't take full advantage of these.
The dependency surface has grown fragile. Maintaining compatibility across Prophecy, Doctrine Instantiator, Sebastian Exporter, four Symfony packages, and their cascading version matrices is an ever-growing maintenance burden. Every upstream release risks breakage. PhpSpec 9 reduces runtime dependencies from 9 packages to 2 (symfony/console + symfony/yaml).
The
ObjectBehaviorpattern creates friction. Developers must learn a DSL that maps$this->shouldReturn()to assertions and$this->beConstructedWith()to constructors. It's powerful but opaque. Thedescribe/it/expectpattern (used by virtually every other testing framework across languages) is immediately familiar.Story BDD shouldn't require a separate tool. Currently phpspec users need Behat for feature-level testing. PhpSpec 9 has built-in Gherkin support with
given()/when()/then()step definitions, unifying Spec BDD and Story BDD in a single tool.AI-assisted development is the new reality for BDD. AI coding assistants can generate classes, methods, and entire modules in seconds — but speed without direction is dangerous. An AI will happily produce code that looks correct but doesn't match the intended behaviour. This is exactly the problem BDD was designed to solve: a spec is a machine-readable contract that validates behaviour, whether a human or an AI writes the implementation. BDD becomes more important with AI, not less. PhpSpec 9 embraces this by making AI a first-class participant in the BDD cycle, not an afterthought.
What changes
Syntax — Jasmine/RSpec-style closures replace ObjectBehavior:
Mocking — Built-in, no Prophecy:
Story BDD — Built-in Gherkin support:
Dependencies — Dramatically reduced:
AI-powered pair programming
PhpSpec 9 includes three AI commands —
pair,next, andrefactor— that embed the AI inside the BDD cycle rather than running alongside it. All AI features are opt-in: they require asuggestdependency (papi-ai/papi-core+ a provider package) and anai:section in your config. Without them, PhpSpec works exactly as it does without AI — zero runtime cost, no API keys needed.Why AI belongs in the BDD tool
AI assistants generate code fast, but they don't know what behaviour is correct. Specs do. By placing the AI inside PhpSpec, every generated class and method is immediately validated against specs. The BDD cycle becomes the contract that keeps both human and AI-generated code honest:
The AI accelerates the cycle; the specs guarantee correctness. Neither replaces the other.
pair— Interactive BDD REPLStart an interactive pair programming session:
Or send a single prompt:
bin/phpspec pair --prompt "write a spec for a Calculator that adds two numbers"The pair command launches a REPL where built-in commands (
describe,exemplify,run) work without AI, and free-form input routes to the AI assistant when configured. Smart routing detects intent:The AI is agentic — it has tools to generate specs, features, step definitions, write/update files, run specs, and read project files. It receives your project's directory structure, existing step definitions, the full DSL reference, and matcher/mock syntax as context, so it writes code that matches your project's patterns and reuses existing steps.
A typical pairing flow:
Conversation history is maintained across the session — the AI remembers what you've been working on and builds on previous exchanges. All interactions are logged to
.phpspec/pair.log.next— What should I work on?Scans your project's source, specs, and features, then suggests the single most impactful next step. It follows the scenario-first workflow — recommends a feature scenario before a spec, a spec before an implementation:
refactor— AI-powered, behaviour-preserving refactoringThe command:
AI configuration
Add an
ai:section tophpspec.yaml:Supported providers: Google (Gemini), Anthropic (Claude), OpenAI (GPT). The provider abstraction is behind an interface (
ProviderInterface) — adding new providers is straightforward.What's included
docs/Status
This is a proof of concept for discussion. It demonstrates the direction, not a final implementation. Feedback on the approach, syntax choices, and migration path is welcome.
Test plan
Core
composer installphp bin/phpspec run— all specs passphp bin/phpspec run features/— all feature scenarios passvendor/bin/php-cs-fixer fix --dry-run— code style cleanvendor/bin/phpstan analyse -l 1 src/— static analysis cleanAI pair programming (optional — requires API key)
To try the AI flow, install a provider and add config:
composer require papi-ai/papi-core papi-ai/google # or papi-ai/anthropic, papi-ai/openaiThen experiment with the BDD cycle:
php bin/phpspec pair— enter the interactive REPL; try built-in commands (describe App/Greeter,run) without AI firstwrite a feature scenario for greeting users— verify it creates.featureand.steps.phpfilesrun features/→describe App/Greeter→ ask the AI to add methods →rununtil greenphp bin/phpspec pair --prompt "write a spec for a Calculator"— single-prompt mode, verify it generates a spec filephp bin/phpspec next— verify it scans the project and suggests a next step following the scenario-first workflowphp bin/phpspec refactor "App\Greeter"— verify it runs specs, applies a refactoring, and re-runs specs (or reports no refactoring needed)