diff --git a/.github/.release-please-config.json b/.github/.release-please-config.json
index e86e6dee..b0d2fc21 100644
--- a/.github/.release-please-config.json
+++ b/.github/.release-please-config.json
@@ -35,6 +35,12 @@
"include-component-in-tag": true,
"changelog-path": "CHANGELOG.md"
},
+ "plugin/spec": {
+ "package-name": "testo/spec",
+ "component": "spec",
+ "include-component-in-tag": true,
+ "changelog-path": "CHANGELOG.md"
+ },
"plugin/filter": {
"package-name": "testo/filter",
"component": "filter",
diff --git a/.github/workflows/split-publish.yml b/.github/workflows/split-publish.yml
index 7176ed51..c49d7e8d 100644
--- a/.github/workflows/split-publish.yml
+++ b/.github/workflows/split-publish.yml
@@ -29,6 +29,7 @@ on: # yamllint disable-line rule:truthy
- 'lifecycle-[0-9]*'
- 'repeat-[0-9]*'
- 'retry-[0-9]*'
+ - 'spec-[0-9]*'
- 'test-[0-9]*'
name: 📦 Split publish
diff --git a/composer.json b/composer.json
index a84265fb..7e482713 100644
--- a/composer.json
+++ b/composer.json
@@ -60,6 +60,7 @@
"spiral/code-style": "^2.2.2",
"testo/bridge-infection": "^0.1.6",
"testo/facade": "^0.1.1",
+ "testo/spec": "^0.1.0",
"vimeo/psalm": "^7.0@dev"
},
"suggest": {
@@ -91,6 +92,7 @@
"Tests\\Lifecycle\\": "plugin/lifecycle/tests/",
"Tests\\Repeat\\": "plugin/repeat/tests/",
"Tests\\Retry\\": "plugin/retry/tests/",
+ "Tests\\Spec\\": "plugin/spec/tests/",
"Tests\\Test\\": "plugin/test/tests/"
},
"files": [
@@ -115,6 +117,7 @@
"testo/lifecycle": "0.1.x-dev",
"testo/repeat": "0.1.x-dev",
"testo/retry": "0.1.x-dev",
+ "testo/spec": "0.1.x-dev",
"testo/test": "0.1.x-dev"
}
}
diff --git a/plugin/spec/CHANGELOG.md b/plugin/spec/CHANGELOG.md
new file mode 100644
index 00000000..825c32f0
--- /dev/null
+++ b/plugin/spec/CHANGELOG.md
@@ -0,0 +1 @@
+# Changelog
diff --git a/plugin/spec/README.md b/plugin/spec/README.md
new file mode 100644
index 00000000..ccdd17b0
--- /dev/null
+++ b/plugin/spec/README.md
@@ -0,0 +1,145 @@
+
+
+
+
+Spec-driven plugin
+
+
+
+[](https://php-testo.github.io)
+[](https://boosty.to/roxblnfk)
+
+
+
+
+
+> [!IMPORTANT]
+> ## 🪞 This is a read-only mirror.
+>
+> Active development of the Testo project lives in [**php-testo/testo**](https://github.com/php-testo/testo) under `plugin/spec/`. This repository is **automatically synchronized** from there on every release.
+>
+> File issues and pull requests in the [main monorepo](https://github.com/php-testo/testo/issues), not here.
+
+## About
+
+Blends **BDD**, **Spec-Driven** and **TDD** workflows: write the behaviour you expect as a `#[Spec(...)]`
+fragment — a user story or a slice of the product specification — right next to the test that proves it.
+
+At runtime every fragment is published to the `spec.md` messenger channel, so it travels with the test
+output. When you want living documentation, flip on generation (the `--spec` flag or the plugin's
+`collect` option) and Testo renders the collected fragments into Markdown files, one per Test Case.
+
+## Install
+
+```bash
+composer require --dev testo/spec
+```
+
+[](https://packagist.org/packages/testo/spec)
+[](https://packagist.org/packages/testo/spec)
+[](https://github.com/php-testo/testo/blob/1.x/LICENSE.md)
+[](https://packagist.org/packages/testo/spec/stats)
+
+## Usage
+
+Attach a spec fragment to a test (method, function, or a whole class). `#[Spec]` carries the
+*content* (the story), `#[SpecHeader]` carries the *heading and number*:
+
+```php
+use Testo\Spec;
+use Testo\Spec\SpecHeader;
+use Testo\Test;
+
+#[Test]
+#[SpecHeader('5', 'Checkout')] // class = numbered section
+final class CheckoutTest
+{
+ #[Spec(
+ story: <<<'MD'
+ **As a** customer
+ **I want** my cart total to include tax
+ **so that** the price I pay matches the price I see.
+ MD,
+ tags: ['checkout', 'JIRA-128'],
+ )]
+ public function totalIncludesTax(): void // -> item 5.1
+ {
+ // ...assertions that prove the story...
+ }
+
+ #[Test]
+ #[Spec(story: 'A valid coupon lowers the total.')]
+ #[SpecHeader(title: 'Coupon applies')] // override the item title, still auto-numbered -> 5.2
+ public function couponApplies(): void
+ {
+ // ...
+ }
+}
+```
+
+### Numbering & ordering
+
+- `#[SpecHeader]` on a **class** opens a section: `number` is the section number (maps onto your
+ external spec document), `title` is the heading. Either may be omitted — a section with no number
+ falls to the end, a section with no title falls back to the class name.
+- `#[SpecHeader]` on a **method** overrides that item's title and/or pins its number.
+- Items are auto-numbered `{section}.{n}` in source order; a pinned method number is kept as-is.
+- **Collisions** (e.g. two cases sharing a section number) are disambiguated with a ` (1)`, ` (2)` …
+ suffix in document order — numbers are never silently dropped.
+
+The example above renders to:
+
+```markdown
+# 5. Checkout
+
+## 5.1 totalIncludesTax
+
+**As a** customer
+**I want** my cart total to include tax
+**so that** the price I pay matches the price I see.
+
+_Tags: `checkout` `JIRA-128`_
+
+## 5.2 Coupon applies
+
+A valid coupon lowers the total.
+```
+
+Sections without a number are gathered into a trailing **Uncategorized** block — items with a header
+render as a bullet list, items without one as plain paragraphs.
+
+### Execution order follows the numbers
+
+By default the plugin also **reorders test execution** to match the document: Test Cases run in
+section-number order, and tests within a case run in item-number order (unnumbered ones keep source
+order and run last). Turn it off with `new SpecPlugin(reorder: false)` if your tests must keep their
+discovered order.
+
+### Generate documentation
+
+Register the plugin in `testo.php`:
+
+```php
+use Testo\Spec\SpecPlugin;
+
+// In ApplicationConfig::$plugins or a SuiteConfig::$plugins:
+new SpecPlugin(outputDir: __DIR__ . '/docs/specs'),
+```
+
+Reordering is on as soon as the plugin is registered. File generation is separate — enable it with
+`collect: true` on the plugin, or from the CLI:
+
+```bash
+# Generate into the plugin's configured directory
+vendor/bin/testo --spec
+
+# Generate into a custom directory
+vendor/bin/testo --spec-dir=docs/specs
+```
+
+The whole run is rendered into a single ordered `spec.md` in the target directory. Even without the
+plugin the fragments are still emitted to the `spec.md` channel — generation and reordering are the
+optional halves.
diff --git a/plugin/spec/Spec.php b/plugin/spec/Spec.php
new file mode 100644
index 00000000..5328e615
--- /dev/null
+++ b/plugin/spec/Spec.php
@@ -0,0 +1,51 @@
+ */
+ public array $tags;
+
+ /**
+ * @param non-empty-string $story The user story or specification fragment, written in Markdown. This is the
+ * behaviour the test verifies — keep it human-readable, it ends up verbatim in the report.
+ * @param list $tags Free-form labels (e.g. a feature key, a Jira id) used to
+ * group or filter fragments in generated documents.
+ */
+ public function __construct(
+ public string $story,
+ array $tags = [],
+ ) {
+ \trim($story) === '' and throw new \InvalidArgumentException('Spec story must not be empty.');
+
+ $this->tags = \array_values(\array_filter(
+ $tags,
+ static fn(string $tag): bool => $tag !== '',
+ ));
+ }
+}
diff --git a/plugin/spec/composer.json b/plugin/spec/composer.json
new file mode 100644
index 00000000..e4ccfc81
--- /dev/null
+++ b/plugin/spec/composer.json
@@ -0,0 +1,44 @@
+{
+ "name": "testo/spec",
+ "description": "Spec-driven plugin for the Testo testing framework: attach BDD/spec fragments to tests and generate living documentation.",
+ "license": "BSD-3-Clause",
+ "type": "library",
+ "keywords": [
+ "testo",
+ "spec",
+ "bdd",
+ "documentation",
+ "test"
+ ],
+ "authors": [
+ {
+ "name": "Aleksei Gagarin (roxblnfk)",
+ "homepage": "https://github.com/roxblnfk"
+ }
+ ],
+ "funding": [
+ {
+ "type": "boosty",
+ "url": "https://boosty.to/roxblnfk"
+ }
+ ],
+ "require": {
+ "php": ">=8.2",
+ "testo/testo": "0.10.26 - 1"
+ },
+ "autoload": {
+ "psr-4": {
+ "Testo\\Spec\\": "src/"
+ },
+ "files": [
+ "Spec.php"
+ ]
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "extra": {
+ "branch-alias": {
+ "dev-1.x": "1.x-dev"
+ }
+ }
+}
diff --git a/plugin/spec/src/Internal/SpecCaseOrderInterceptor.php b/plugin/spec/src/Internal/SpecCaseOrderInterceptor.php
new file mode 100644
index 00000000..c514e176
--- /dev/null
+++ b/plugin/spec/src/Internal/SpecCaseOrderInterceptor.php
@@ -0,0 +1,44 @@
+definition->reflection)?->number;
+
+ $items = [];
+ foreach ($info->definition->tests->getTests() as $name => $definition) {
+ $items[$name] = [
+ 'number' => SpecHeaderReader::item($definition->reflection)?->number,
+ 'line' => $definition->reflection->getStartLine() ?: 0,
+ ];
+ }
+
+ $position = \array_flip(SpecNumberer::orderKeys($items, $section));
+
+ $info->definition->tests->sort(static fn(TestDefinition $a, TestDefinition $b): int =>
+ ($position[$a->reflection->getShortName()] ?? 0) <=> ($position[$b->reflection->getShortName()] ?? 0));
+
+ return $next($info);
+ }
+}
diff --git a/plugin/spec/src/Internal/SpecCollector.php b/plugin/spec/src/Internal/SpecCollector.php
new file mode 100644
index 00000000..53b43458
--- /dev/null
+++ b/plugin/spec/src/Internal/SpecCollector.php
@@ -0,0 +1,156 @@
+ */
+ private array $entries = [];
+
+ /**
+ * @param non-empty-string $outputDir Directory the Markdown document is written to.
+ */
+ public function __construct(
+ private readonly string $outputDir,
+ ) {}
+
+ public function onTestSuiteFinished(TestSuiteFinished $event): void
+ {
+ $this->addSuite($event->suiteResult);
+ $this->flush();
+ }
+
+ /**
+ * Accumulate every spec fragment recorded in the given suite, in execution order.
+ */
+ public function addSuite(SuiteResult $suite): void
+ {
+ foreach ($suite->results as $case) {
+ \assert($case instanceof CaseResult);
+ foreach ($case->results as $test) {
+ \assert($test instanceof TestResult);
+ foreach ($test->messages->channel(SpecInterceptor::CHANNEL) as $message) {
+ $entry = SpecEntry::fromMessage($message);
+ $entry === null or $this->entries[] = $entry;
+ }
+ }
+ }
+ }
+
+ /**
+ * Render everything accumulated so far into the document file. Returns the written path, or null
+ * when nothing has been collected.
+ *
+ * @return non-empty-string|null
+ */
+ public function flush(): ?string
+ {
+ if ($this->entries === []) {
+ return null;
+ }
+
+ \is_dir($this->outputDir) || \mkdir($this->outputDir, 0o775, recursive: true);
+
+ $path = $this->outputDir . \DIRECTORY_SEPARATOR . self::FILENAME;
+ \file_put_contents($path, $this->render(SpecNumberer::build($this->entries)));
+
+ return $path;
+ }
+
+ /**
+ * @param array{sections: list}>}>, extra: list}>}>} $model
+ */
+ public function render(array $model): string
+ {
+ $blocks = [];
+
+ foreach ($model['sections'] as $section) {
+ $out = "# {$section['number']}. {$section['title']}\n";
+ foreach ($section['items'] as $item) {
+ $out .= "\n## {$item['number']} {$item['title']}\n\n" . \trim($item['story']) . "\n";
+ $out .= self::tags($item['tags'], '');
+ }
+ $blocks[] = $out;
+ }
+
+ if ($model['extra'] !== []) {
+ $blocks[] = $this->renderExtra($model['extra']);
+ }
+
+ return \implode("\n", $blocks);
+ }
+
+ /**
+ * @param list $tags
+ */
+ private static function tags(array $tags, string $indent): string
+ {
+ if ($tags === []) {
+ return '';
+ }
+
+ $rendered = \implode(' ', \array_map(static fn(string $t): string => '`' . $t . '`', $tags));
+
+ return "\n{$indent}_Tags: {$rendered}_\n";
+ }
+
+ private static function indent(string $text): string
+ {
+ return \implode("\n", \array_map(
+ static fn(string $line): string => $line === '' ? '' : ' ' . $line,
+ \explode("\n", $text),
+ ));
+ }
+
+ /**
+ * @param list}>}> $extra
+ */
+ private function renderExtra(array $extra): string
+ {
+ $out = "# Uncategorized\n";
+ foreach ($extra as $block) {
+ $out .= "\n";
+ $block['title'] === null or $out .= "## {$block['title']}\n\n";
+
+ foreach ($block['items'] as $item) {
+ if ($item['title'] !== null) {
+ // Has a header → bullet list item with the story indented under it.
+ $out .= "- {$item['title']}\n" . self::indent(\trim($item['story'])) . "\n";
+ $out .= self::tags($item['tags'], ' ');
+ } else {
+ // No header → a plain paragraph.
+ $out .= \trim($item['story']) . "\n";
+ $out .= self::tags($item['tags'], '');
+ }
+ }
+ }
+
+ return $out;
+ }
+}
diff --git a/plugin/spec/src/Internal/SpecEntry.php b/plugin/spec/src/Internal/SpecEntry.php
new file mode 100644
index 00000000..cb17035a
--- /dev/null
+++ b/plugin/spec/src/Internal/SpecEntry.php
@@ -0,0 +1,97 @@
+ $tags
+ */
+ public function __construct(
+ public string $case,
+ public string $test,
+ public ?string $title,
+ public ?string $number,
+ public ?string $sectionTitle,
+ public ?string $sectionNumber,
+ public int $line,
+ public string $story,
+ public array $tags,
+ ) {}
+
+ /**
+ * Rebuild an entry from a channel message, or null when the message carries no spec context
+ * (e.g. it was written to the channel by something other than {@see SpecInterceptor}).
+ */
+ public static function fromMessage(Message $message): ?self
+ {
+ $context = $message->context;
+ $story = $context['story'] ?? null;
+ $case = $context['case'] ?? null;
+ $test = $context['test'] ?? null;
+
+ if (!\is_string($story) || !\is_string($case) || $case === '' || !\is_string($test) || $test === '') {
+ return null;
+ }
+
+ /** @var list $tags */
+ $tags = \is_array($context['tags'] ?? null)
+ ? \array_values(\array_filter($context['tags'], static fn(mixed $t): bool => \is_string($t) && $t !== ''))
+ : [];
+
+ return new self(
+ case: $case,
+ test: $test,
+ title: self::nonEmptyString($context['title'] ?? null),
+ number: self::nonEmptyString($context['number'] ?? null),
+ sectionTitle: self::nonEmptyString($context['sectionTitle'] ?? null),
+ sectionNumber: self::nonEmptyString($context['sectionNumber'] ?? null),
+ line: \is_int($context['line'] ?? null) ? $context['line'] : 0,
+ story: $story,
+ tags: $tags,
+ );
+ }
+
+ /**
+ * Heading to show for this item: the explicit title or, failing that, the test name.
+ *
+ * @return non-empty-string
+ */
+ public function heading(): string
+ {
+ return $this->title ?? $this->test;
+ }
+
+ /**
+ * @return non-empty-string|null
+ */
+ private static function nonEmptyString(mixed $value): ?string
+ {
+ return \is_string($value) && $value !== '' ? $value : null;
+ }
+}
diff --git a/plugin/spec/src/Internal/SpecHeaderReader.php b/plugin/spec/src/Internal/SpecHeaderReader.php
new file mode 100644
index 00000000..a6491e57
--- /dev/null
+++ b/plugin/spec/src/Internal/SpecHeaderReader.php
@@ -0,0 +1,37 @@
+getAttributes(SpecHeader::class));
+ }
+
+ public static function item(\ReflectionFunctionAbstract $reflection): ?SpecHeader
+ {
+ return self::first($reflection->getAttributes(SpecHeader::class));
+ }
+
+ /**
+ * @param array<\ReflectionAttribute> $attributes
+ */
+ private static function first(array $attributes): ?SpecHeader
+ {
+ return ($attributes[0] ?? null)?->newInstance();
+ }
+}
diff --git a/plugin/spec/src/Internal/SpecInput.php b/plugin/spec/src/Internal/SpecInput.php
new file mode 100644
index 00000000..19779c6b
--- /dev/null
+++ b/plugin/spec/src/Internal/SpecInput.php
@@ -0,0 +1,47 @@
+` enables generation and overrides the target directory.
+ *
+ * @internal
+ * @psalm-internal Testo\Spec
+ */
+#[InflectableConfig]
+final class SpecInput
+{
+ /** Enable spec document generation into the default directory. */
+ #[InputOption('spec')]
+ public bool $spec = false;
+
+ /** Target directory for generated spec documents (`--spec-dir=`); implies `--spec`. */
+ #[InputOption('spec-dir')]
+ public ?string $dir = null;
+
+ /**
+ * Whether the user asked for spec generation via the CLI.
+ */
+ public function isEnabled(): bool
+ {
+ return $this->spec || ($this->dir !== null && $this->dir !== '');
+ }
+
+ /**
+ * The directory requested on the CLI, or null to fall back to the plugin's configured directory.
+ *
+ * @return non-empty-string|null
+ */
+ public function resolveDir(): ?string
+ {
+ return $this->dir !== null && $this->dir !== '' ? $this->dir : null;
+ }
+}
diff --git a/plugin/spec/src/Internal/SpecInterceptor.php b/plugin/spec/src/Internal/SpecInterceptor.php
new file mode 100644
index 00000000..02c24ad5
--- /dev/null
+++ b/plugin/spec/src/Internal/SpecInterceptor.php
@@ -0,0 +1,98 @@
+testDefinition->reflection);
+ $section = SpecHeaderReader::section($info->caseInfo->definition->reflection);
+
+ $this->messenger->log(
+ self::CHANNEL,
+ $this->render($item?->title ?? $info->name, $item?->number),
+ Level::Info,
+ [
+ 'title' => $item?->title,
+ 'story' => $this->options->story,
+ 'tags' => $this->options->tags,
+ 'number' => $item?->number,
+ 'sectionTitle' => $section?->title,
+ 'sectionNumber' => $section?->number,
+ 'line' => $info->testDefinition->reflection->getStartLine() ?: 0,
+ 'test' => $info->name,
+ 'case' => $info->caseInfo->name,
+ ],
+ );
+
+ return $next($info);
+ }
+
+ /**
+ * Render the fragment as a self-contained Markdown section. The final number is the collector's
+ * job; a manual item number is shown here, otherwise just the title.
+ */
+ private function render(string $title, ?string $number): string
+ {
+ $heading = $number === null ? $title : "{$number} {$title}";
+ $out = "### {$heading}\n\n" . \trim($this->options->story) . "\n";
+
+ if ($this->options->tags !== []) {
+ $tags = \implode(' ', \array_map(
+ static fn(string $tag): string => '`' . $tag . '`',
+ $this->options->tags,
+ ));
+ $out .= "\n_Tags: {$tags}_\n";
+ }
+
+ return $out;
+ }
+}
diff --git a/plugin/spec/src/Internal/SpecNumberer.php b/plugin/spec/src/Internal/SpecNumberer.php
new file mode 100644
index 00000000..c6701fa2
--- /dev/null
+++ b/plugin/spec/src/Internal/SpecNumberer.php
@@ -0,0 +1,189 @@
+}`
+ * - numberedItem: `array{number: non-empty-string, title: non-empty-string, story: string, tags: list}`
+ * - extra section: `array{title: non-empty-string|null, items: list}`
+ * - plainItem: `array{title: non-empty-string|null, story: string, tags: list}`
+ *
+ * @internal
+ * @psalm-internal Testo\Spec
+ */
+final readonly class SpecNumberer
+{
+ /**
+ * @param list $entries
+ * @return array{sections: list}>}>, extra: list}>}>}
+ */
+ public static function build(array $entries): array
+ {
+ // Group by case, preserving first-seen (run) order for the unnumbered tail.
+ $cases = [];
+ foreach ($entries as $entry) {
+ $cases[$entry->case] ??= [
+ 'id' => $entry->case,
+ 'number' => $entry->sectionNumber,
+ 'title' => $entry->sectionTitle,
+ 'entries' => [],
+ ];
+ $cases[$entry->case]['entries'][] = $entry;
+ }
+
+ $numbered = [];
+ $unnumbered = [];
+ $index = 0;
+ foreach ($cases as $case) {
+ $case['index'] = $index++;
+ $case['number'] === null ? $unnumbered[] = $case : $numbered[] = $case;
+ }
+
+ \usort($numbered, static function (array $a, array $b): int {
+ $cmp = \strnatcmp((string) $a['number'], (string) $b['number']);
+ return $cmp !== 0 ? $cmp : $a['index'] <=> $b['index'];
+ });
+
+ $sections = [];
+ foreach ($numbered as $case) {
+ $section = (string) $case['number'];
+ $counter = 0;
+ $items = [];
+ foreach (self::order($case['entries'], $section) as $entry) {
+ $items[] = [
+ 'number' => $entry->number ?? $section . '.' . ++$counter,
+ 'title' => $entry->heading(),
+ 'story' => $entry->story,
+ 'tags' => $entry->tags,
+ ];
+ }
+ $sections[] = ['number' => $section, 'title' => $case['title'] ?? $case['id'], 'items' => $items];
+ }
+
+ self::disambiguate($sections);
+
+ $extra = [];
+ foreach ($unnumbered as $case) {
+ $items = [];
+ foreach (self::order($case['entries'], null) as $entry) {
+ $items[] = ['title' => $entry->title, 'story' => $entry->story, 'tags' => $entry->tags];
+ }
+ $extra[] = ['title' => $case['title'], 'items' => $items];
+ }
+
+ return ['sections' => $sections, 'extra' => $extra];
+ }
+
+ /**
+ * Compare two section (case) numbers for execution/document ordering: numbered before unnumbered,
+ * numbered sorted naturally. Equal/both-null keep their original order (stable sort).
+ */
+ public static function compareSections(?string $a, ?string $b): int
+ {
+ if (($a === null) !== ($b === null)) {
+ return $a === null ? 1 : -1;
+ }
+
+ return $a === null ? 0 : \strnatcmp($a, $b);
+ }
+
+ /**
+ * Order test keys within a case the same way {@see build()} orders items, so execution order
+ * matches the generated document.
+ *
+ * @param array $items
+ * @param non-empty-string|null $section
+ * @return list
+ *
+ * @template TKey of array-key
+ */
+ public static function orderKeys(array $items, ?string $section): array
+ {
+ $byLine = $items;
+ \uasort($byLine, static fn(array $a, array $b): int => $a['line'] <=> $b['line']);
+
+ $rank = [];
+ $position = 0;
+ foreach ($byLine as $key => $_) {
+ $rank[$key] = ++$position;
+ }
+
+ $effective = [];
+ foreach ($items as $key => $item) {
+ $effective[$key] = $item['number'] ?? ($section !== null ? $section . '.' . $rank[$key] : null);
+ }
+
+ $keys = \array_keys($items);
+ \usort($keys, static function ($x, $y) use ($effective, $items): int {
+ $a = $effective[$x];
+ $b = $effective[$y];
+ if (($a === null) !== ($b === null)) {
+ return $a === null ? 1 : -1;
+ }
+ if ($a !== null && ($cmp = \strnatcmp($a, $b)) !== 0) {
+ return $cmp;
+ }
+
+ return $items[$x]['line'] <=> $items[$y]['line'];
+ });
+
+ return $keys;
+ }
+
+ /**
+ * @param list $entries
+ * @param non-empty-string|null $section
+ * @return list
+ */
+ private static function order(array $entries, ?string $section): array
+ {
+ $items = [];
+ foreach ($entries as $i => $entry) {
+ $items[$i] = ['number' => $entry->number, 'line' => $entry->line];
+ }
+
+ return \array_map(static fn(int $i): SpecEntry => $entries[$i], self::orderKeys($items, $section));
+ }
+
+ /**
+ * Append ` (1)`, ` (2)` … to item numbers that repeat across the whole document, in document order.
+ *
+ * @param list}>}> $sections
+ * @param-out list}>}> $sections
+ */
+ private static function disambiguate(array &$sections): void
+ {
+ $counts = [];
+ foreach ($sections as $section) {
+ foreach ($section['items'] as $item) {
+ $counts[$item['number']] = ($counts[$item['number']] ?? 0) + 1;
+ }
+ }
+
+ $seen = [];
+ foreach ($sections as $s => $section) {
+ foreach ($section['items'] as $i => $item) {
+ if (($counts[$item['number']] ?? 0) < 2) {
+ continue;
+ }
+ $n = $seen[$item['number']] = ($seen[$item['number']] ?? 0) + 1;
+ $sections[$s]['items'][$i]['number'] = $item['number'] . " ({$n})";
+ }
+ }
+ }
+}
diff --git a/plugin/spec/src/Internal/SpecSuiteOrderInterceptor.php b/plugin/spec/src/Internal/SpecSuiteOrderInterceptor.php
new file mode 100644
index 00000000..201601a3
--- /dev/null
+++ b/plugin/spec/src/Internal/SpecSuiteOrderInterceptor.php
@@ -0,0 +1,35 @@
+testCases->sort(static fn(CaseDefinition $a, CaseDefinition $b): int => SpecNumberer::compareSections(
+ SpecHeaderReader::section($a->reflection)?->number,
+ SpecHeaderReader::section($b->reflection)?->number,
+ ));
+
+ return $next($info);
+ }
+}
diff --git a/plugin/spec/src/SpecHeader.php b/plugin/spec/src/SpecHeader.php
new file mode 100644
index 00000000..10647427
--- /dev/null
+++ b/plugin/spec/src/SpecHeader.php
@@ -0,0 +1,54 @@
+number = $number;
+
+ $this->title === null && $this->number === null and throw new \InvalidArgumentException(
+ 'Spec header requires a title or a number.',
+ );
+ }
+}
diff --git a/plugin/spec/src/SpecPlugin.php b/plugin/spec/src/SpecPlugin.php
new file mode 100644
index 00000000..59bfba73
--- /dev/null
+++ b/plugin/spec/src/SpecPlugin.php
@@ -0,0 +1,73 @@
+reorder and $this->configureReordering($container);
+
+ $input = $container->get(SpecInput::class);
+ ($this->collect || $input->isEnabled()) and $this->configureGeneration($container, $input);
+ }
+
+ private function configureReordering(Container $container): void
+ {
+ $interceptors = $container->get(InterceptorCollector::class);
+ $interceptors->addInterceptor(new SpecSuiteOrderInterceptor());
+ $interceptors->addInterceptor(new SpecCaseOrderInterceptor());
+ }
+
+ private function configureGeneration(Container $container, SpecInput $input): void
+ {
+ // Idempotent across suites: a single session-scoped collector owns the whole run tree.
+ if ($container->has(SpecCollector::class)) {
+ return;
+ }
+
+ $collector = new SpecCollector($input->resolveDir() ?? $this->outputDir);
+ $container->set($collector);
+
+ $container->get(EventListenerCollector::class)
+ ->addListener(TestSuiteFinished::class, $collector->onTestSuiteFinished(...));
+ }
+}
diff --git a/plugin/spec/tests/Feature/SpecDocumentFeatureTest.php b/plugin/spec/tests/Feature/SpecDocumentFeatureTest.php
new file mode 100644
index 00000000..e43a73fe
--- /dev/null
+++ b/plugin/spec/tests/Feature/SpecDocumentFeatureTest.php
@@ -0,0 +1,142 @@
+ $plugins
+ */
+ private static function run(array $plugins, ?string $dir = null): RunResult
+ {
+ $app = Application::createFromInput(
+ inputOptions: $dir === null ? [] : ['spec-dir' => $dir],
+ );
+ $app->getContainer()->set(
+ new ApplicationConfig(
+ src: [],
+ suites: [
+ new SuiteConfig(
+ 'SpecStubs',
+ location: new FinderConfig(include: [__DIR__ . '/../Stub']),
+ ),
+ ],
+ plugins: $plugins,
+ ),
+ ApplicationConfig::class,
+ );
+
+ return $app->run();
+ }
+
+ /**
+ * Distinct Test Case names in execution order.
+ *
+ * @return list
+ */
+ private static function caseOrder(RunResult $result): array
+ {
+ $order = [];
+ foreach ($result->results as $suite) {
+ foreach ($suite->results as $case) {
+ foreach ($case->results as $test) {
+ $name = $test->info->caseInfo->name;
+ \in_array($name, $order, true) or $order[] = $name;
+ }
+ }
+ }
+
+ return $order;
+ }
+}
diff --git a/plugin/spec/tests/Feature/SpecFeatureTest.php b/plugin/spec/tests/Feature/SpecFeatureTest.php
new file mode 100644
index 00000000..3d6723d9
--- /dev/null
+++ b/plugin/spec/tests/Feature/SpecFeatureTest.php
@@ -0,0 +1,83 @@
+messages->channel(SpecInterceptor::CHANNEL);
+ Assert::same(\count($messages), 1);
+ Assert::true(\str_contains($messages[0]->content, 'As a user I want X so that Y.'));
+ }
+
+ #[Spec(story: <<<'MD'
+ A class-level `#[SpecHeader]` names and numbers the section that every test in the case belongs
+ to; the section title and number ride along with each fragment.
+ MD)]
+ #[SpecHeader(title: 'A class header opens a section')]
+ public function classLevelSectionHeaderReachesTheContext(): void
+ {
+ $result = TestRunner::runTest([SpecStub::class, 'methodLevelSpec']);
+
+ $context = $result->messages->channel(SpecInterceptor::CHANNEL)[0]->context;
+ Assert::same($context['sectionTitle'], 'Checkout');
+ Assert::same($context['sectionNumber'], '5');
+ Assert::null($context['title']);
+ Assert::same($context['test'], 'methodLevelSpec');
+ }
+
+ #[Spec(story: <<<'MD'
+ A method-level `#[SpecHeader]` overrides the item heading, and tags declared on `#[Spec]` are
+ carried through to the fragment.
+ MD)]
+ #[SpecHeader(title: 'A method header titles an item')]
+ public function methodLevelItemHeaderReachesTheContext(): void
+ {
+ $result = TestRunner::runTest([SpecStub::class, 'specWithHeader']);
+
+ $context = $result->messages->channel(SpecInterceptor::CHANNEL)[0]->context;
+ Assert::same($context['title'], 'Tax in total');
+ Assert::null($context['number']);
+ Assert::same($context['tags'], ['checkout']);
+ }
+
+ #[Spec(story: 'Documenting a test with `#[Spec]` is observational: it never changes the pass/fail outcome.')]
+ #[SpecHeader(title: 'Specs never change the verdict')]
+ public function specDoesNotAlterTestOutcome(): void
+ {
+ $result = TestRunner::runTest([SpecStub::class, 'methodLevelSpec']);
+
+ Assert::same($result->status, Status::Passed);
+ }
+}
diff --git a/plugin/spec/tests/Stub/AuthStub.php b/plugin/spec/tests/Stub/AuthStub.php
new file mode 100644
index 00000000..4b21f7ca
--- /dev/null
+++ b/plugin/spec/tests/Stub/AuthStub.php
@@ -0,0 +1,28 @@
+addSuite(self::suite([
+ self::test('Checkout [test]', 'tax', 'Tax is included.', sectionNumber: '5', sectionTitle: 'Checkout'),
+ ]));
+ $collector->addSuite(self::suite([
+ self::test('Auth [test]', 'login', 'A user logs in.', sectionNumber: '2', sectionTitle: 'Auth'),
+ ]));
+
+ $content = (string) \file_get_contents((string) $collector->flush());
+ Assert::true(\str_contains($content, '# 2. Auth'));
+ Assert::true(\str_contains($content, '# 5. Checkout'));
+ }
+
+ public function ignoresTestsWithoutSpecMessages(): void
+ {
+ $collector = self::collector(__FUNCTION__);
+ $collector->addSuite(self::suite([new TestResult(info: self::info(), status: Status::Passed)]));
+
+ Assert::null($collector->flush());
+ }
+
+ public function flushReturnsNullWhenNothingCollected(): void
+ {
+ Assert::null(self::collector(__FUNCTION__)->flush());
+ }
+
+ public function writesASingleDocument(): void
+ {
+ $collector = self::collector(__FUNCTION__);
+ $collector->addSuite(self::suite([
+ self::test('Checkout [test]', 'tax', 'Tax is included.', sectionNumber: '5', sectionTitle: 'Checkout'),
+ ]));
+
+ $path = $collector->flush();
+
+ Assert::notNull($path);
+ Assert::true(\str_ends_with((string) $path, 'spec.md'));
+ $content = (string) \file_get_contents((string) $path);
+ Assert::true(\str_contains($content, '# 5. Checkout'));
+ Assert::true(\str_contains($content, '## 5.1 tax'));
+ }
+
+ public function rendersNumberedSections(): void
+ {
+ $model = [
+ 'sections' => [
+ ['number' => '5', 'title' => 'Checkout', 'items' => [
+ ['number' => '5.1', 'title' => 'tax', 'story' => 'Tax is included.', 'tags' => ['checkout']],
+ ]],
+ ],
+ 'extra' => [],
+ ];
+
+ $out = self::collector(__FUNCTION__)->render($model);
+
+ Assert::true(\str_contains($out, '# 5. Checkout'));
+ Assert::true(\str_contains($out, "## 5.1 tax\n\nTax is included."));
+ Assert::true(\str_contains($out, '`checkout`'));
+ }
+
+ public function rendersUncategorizedTailWithBulletsAndParagraphs(): void
+ {
+ $model = [
+ 'sections' => [],
+ 'extra' => [
+ ['title' => 'Notes', 'items' => [
+ ['title' => 'A side note', 'story' => 'Noted behaviour.', 'tags' => []],
+ ['title' => null, 'story' => 'Plain behaviour.', 'tags' => []],
+ ]],
+ ],
+ ];
+
+ $out = self::collector(__FUNCTION__)->render($model);
+
+ Assert::true(\str_contains($out, '# Uncategorized'));
+ Assert::true(\str_contains($out, '## Notes'));
+ Assert::true(\str_contains($out, "- A side note\n Noted behaviour."));
+ Assert::true(\str_contains($out, "\nPlain behaviour.\n"));
+ }
+
+ private static function test(
+ string $case,
+ string $name,
+ string $story,
+ ?string $sectionNumber = null,
+ ?string $sectionTitle = null,
+ ): TestResult {
+ $message = new Message(
+ time: 0.0,
+ channel: SpecInterceptor::CHANNEL,
+ level: Level::Info,
+ content: "### {$name}\n\n{$story}\n",
+ context: [
+ 'title' => null,
+ 'story' => $story,
+ 'tags' => [],
+ 'number' => null,
+ 'sectionTitle' => $sectionTitle,
+ 'sectionNumber' => $sectionNumber,
+ 'line' => 1,
+ 'test' => $name,
+ 'case' => $case,
+ ],
+ );
+
+ return new TestResult(info: self::info($name), status: Status::Passed, messages: new MessageLog([$message]));
+ }
+
+ /**
+ * @param list $tests
+ */
+ private static function suite(array $tests): SuiteResult
+ {
+ return new SuiteResult([new CaseResult($tests, Status::Passed)], Status::Passed);
+ }
+
+ private static function info(string $name = 'test'): TestInfo
+ {
+ $reflection = new \ReflectionMethod(self::class, 'info');
+ $caseInfo = new CaseInfo(definition: new CaseDefinition(name: 'TestCase', type: 'test'));
+
+ return new TestInfo(
+ name: $name,
+ caseInfo: $caseInfo,
+ testDefinition: new TestDefinition(reflection: $reflection),
+ );
+ }
+
+ private static function collector(string $name): SpecCollector
+ {
+ $dir = __DIR__ . '/../../runtime/' . $name;
+ if (\is_dir($dir)) {
+ foreach ((array) \glob($dir . '/*.md') as $file) {
+ \is_string($file) and @\unlink($file);
+ }
+ }
+
+ return new SpecCollector($dir);
+ }
+}
diff --git a/plugin/spec/tests/Unit/Internal/SpecNumbererTest.php b/plugin/spec/tests/Unit/Internal/SpecNumbererTest.php
new file mode 100644
index 00000000..aafafba4
--- /dev/null
+++ b/plugin/spec/tests/Unit/Internal/SpecNumbererTest.php
@@ -0,0 +1,126 @@
+ $s['number'], $model['sections']), ['2', '10']);
+ }
+
+ public function autoNumbersItemsBySourceLine(): void
+ {
+ $model = SpecNumberer::build([
+ self::entry('Checkout', 'second', line: 30, sectionNumber: '5'),
+ self::entry('Checkout', 'first', line: 10, sectionNumber: '5'),
+ ]);
+
+ $items = $model['sections'][0]['items'];
+ Assert::same($items[0]['number'], '5.1');
+ Assert::same($items[0]['title'], 'first');
+ Assert::same($items[1]['number'], '5.2');
+ Assert::same($items[1]['title'], 'second');
+ }
+
+ public function keepsManualItemNumber(): void
+ {
+ $model = SpecNumberer::build([
+ self::entry('Checkout', 'pinned', line: 10, sectionNumber: '5', number: '5.9'),
+ ]);
+
+ Assert::same($model['sections'][0]['items'][0]['number'], '5.9');
+ }
+
+ public function disambiguatesCollidingNumbersInDocumentOrder(): void
+ {
+ $model = SpecNumberer::build([
+ self::entry('CheckoutA', 'a', line: 10, sectionNumber: '5', sectionTitle: 'Checkout A'),
+ self::entry('CheckoutB', 'b', line: 10, sectionNumber: '5', sectionTitle: 'Checkout B'),
+ ]);
+
+ Assert::same($model['sections'][0]['items'][0]['number'], '5.1 (1)');
+ Assert::same($model['sections'][1]['items'][0]['number'], '5.1 (2)');
+ }
+
+ public function unnumberedSectionsGoToExtra(): void
+ {
+ $model = SpecNumberer::build([
+ self::entry('Numbered', 'a', line: 10, sectionNumber: '1'),
+ self::entry('Loose', 'b', line: 10, title: 'A note'),
+ ]);
+
+ Assert::same(\count($model['sections']), 1);
+ Assert::same(\count($model['extra']), 1);
+ Assert::same($model['extra'][0]['items'][0]['title'], 'A note');
+ }
+
+ public function fallsBackToCaseNameForSectionTitle(): void
+ {
+ $model = SpecNumberer::build([
+ self::entry('CheckoutCase', 'a', line: 10, sectionNumber: '5'),
+ ]);
+
+ Assert::same($model['sections'][0]['title'], 'CheckoutCase');
+ }
+
+ public function compareSectionsPutsUnnumberedLast(): void
+ {
+ Assert::true(SpecNumberer::compareSections('5', null) < 0);
+ Assert::true(SpecNumberer::compareSections(null, '5') > 0);
+ Assert::same(SpecNumberer::compareSections(null, null), 0);
+ Assert::true(SpecNumberer::compareSections('2', '10') < 0);
+ }
+
+ public function orderKeysSortsByEffectiveNumber(): void
+ {
+ $order = SpecNumberer::orderKeys([
+ 'late' => ['number' => null, 'line' => 30],
+ 'early' => ['number' => null, 'line' => 10],
+ ], '5');
+
+ Assert::same($order, ['early', 'late']);
+ }
+
+ /**
+ * @param list $tags
+ */
+ private static function entry(
+ string $case,
+ string $test,
+ int $line,
+ ?string $sectionNumber = null,
+ ?string $sectionTitle = null,
+ ?string $number = null,
+ ?string $title = null,
+ array $tags = [],
+ ): SpecEntry {
+ return new SpecEntry(
+ case: $case,
+ test: $test,
+ title: $title,
+ number: $number,
+ sectionTitle: $sectionTitle,
+ sectionNumber: $sectionNumber,
+ line: $line,
+ story: "Story of {$test}.",
+ tags: $tags,
+ );
+ }
+}
diff --git a/plugin/spec/tests/Unit/Internal/SpecOrderInterceptorTest.php b/plugin/spec/tests/Unit/Internal/SpecOrderInterceptorTest.php
new file mode 100644
index 00000000..c750be93
--- /dev/null
+++ b/plugin/spec/tests/Unit/Internal/SpecOrderInterceptorTest.php
@@ -0,0 +1,87 @@
+runTestSuite($info, static fn(): SuiteResult => new SuiteResult([], Status::Passed));
+
+ $names = \array_map(static fn(CaseDefinition $c): ?string => $c->name, $cases->getCases());
+ Assert::same($names, ['NumberedSectionLow', 'NumberedSectionHigh', 'UnnumberedSection']);
+ }
+
+ public function sortsTestsWithinACaseBySpecNumber(): void
+ {
+ $class = new \ReflectionClass(OrderingCase::class);
+ $tests = TestDefinitions::fromArray(
+ third: new TestDefinition($class->getMethod('third')),
+ first: new TestDefinition($class->getMethod('first')),
+ second: new TestDefinition($class->getMethod('second')),
+ );
+ $definition = new CaseDefinition(name: 'OrderingCase', type: 'test', reflection: $class, tests: $tests);
+ $info = new CaseInfo(definition: $definition);
+
+ (new SpecCaseOrderInterceptor())->runTestCase($info, static fn(): CaseResult => new CaseResult([], Status::Passed));
+
+ Assert::same(\array_keys($definition->tests->getTests()), ['first', 'second', 'third']);
+ }
+
+ public function callsTheNextHandler(): void
+ {
+ $info = new SuiteInfo(name: 'S', testCases: CaseDefinitions::fromArray(self::case(UnnumberedSection::class)));
+ $called = false;
+ $next = static function () use (&$called): SuiteResult {
+ $called = true;
+ return new SuiteResult([], Status::Passed);
+ };
+
+ (new SpecSuiteOrderInterceptor())->runTestSuite($info, $next);
+
+ Assert::true($called);
+ }
+
+ /**
+ * @param class-string $class
+ */
+ private static function case(string $class): CaseDefinition
+ {
+ return new CaseDefinition(
+ name: (new \ReflectionClass($class))->getShortName(),
+ type: 'test',
+ reflection: new \ReflectionClass($class),
+ );
+ }
+}
diff --git a/plugin/spec/tests/Unit/SpecHeaderTest.php b/plugin/spec/tests/Unit/SpecHeaderTest.php
new file mode 100644
index 00000000..6d54a5ec
--- /dev/null
+++ b/plugin/spec/tests/Unit/SpecHeaderTest.php
@@ -0,0 +1,82 @@
+title, 'Checkout');
+ }
+
+ public function numberDefaultsToNull(): void
+ {
+ $header = new SpecHeader(title: 'Checkout');
+
+ Assert::null($header->number);
+ }
+
+ public function stringNumberIsKept(): void
+ {
+ $header = new SpecHeader('5.1', 'Tax');
+
+ Assert::same($header->number, '5.1');
+ }
+
+ public function intNumberIsCastToString(): void
+ {
+ $header = new SpecHeader(5, 'Checkout');
+
+ Assert::same($header->number, '5');
+ }
+
+ public function numberOnlyHeaderIsAllowed(): void
+ {
+ $header = new SpecHeader('5');
+
+ Assert::null($header->title);
+ Assert::same($header->number, '5');
+ }
+
+ public function blankTitleFails(): never
+ {
+ Expect::exception(\InvalidArgumentException::class)
+ ->withMessage('Spec header title must not be empty when provided.');
+
+ new SpecHeader(title: ' ');
+ }
+
+ public function emptyHeaderFails(): never
+ {
+ Expect::exception(\InvalidArgumentException::class)
+ ->withMessage('Spec header requires a title or a number.');
+
+ new SpecHeader();
+ }
+
+ /**
+ * @param string $number A provided number must carry an actual value.
+ */
+ #[DataSet([''], 'empty')]
+ #[DataSet([' '], 'whitespace')]
+ public function blankNumberFails(string $number): never
+ {
+ Expect::exception(\InvalidArgumentException::class)
+ ->withMessage('Spec header number must not be empty when provided.');
+
+ new SpecHeader($number, 'Checkout');
+ }
+}
diff --git a/plugin/spec/tests/Unit/SpecInterceptorTest.php b/plugin/spec/tests/Unit/SpecInterceptorTest.php
new file mode 100644
index 00000000..c729ba66
--- /dev/null
+++ b/plugin/spec/tests/Unit/SpecInterceptorTest.php
@@ -0,0 +1,181 @@
+runTest(self::createTestInfo(), $next);
+
+ Assert::same($callCount, 1);
+ Assert::same($result->status, Status::Passed);
+ }
+
+ public function publishesFragmentToTheChannel(): void
+ {
+ $messenger = self::createMessenger();
+ $interceptor = new SpecInterceptor(new Spec(story: 'As a user I want X.'), $messenger);
+
+ $interceptor->runTest(self::createTestInfo(), self::passing());
+
+ $messages = $messenger->getMessages()->channel(SpecInterceptor::CHANNEL);
+ Assert::same(\count($messages), 1);
+ Assert::true(\str_contains($messages[0]->content, 'As a user I want X.'));
+ }
+
+ public function titleIsNullWithoutAHeaderButRendersTheTestName(): void
+ {
+ $messenger = self::createMessenger();
+ $interceptor = new SpecInterceptor(new Spec(story: 'story'), $messenger);
+
+ $interceptor->runTest(self::createTestInfo(), self::passing());
+
+ $message = self::firstMessage($messenger);
+ Assert::null($message->context['title']);
+ Assert::null($message->context['number']);
+ Assert::null($message->context['sectionTitle']);
+ Assert::same($message->context['test'], 'testMethod');
+ Assert::true(\str_contains($message->content, '### testMethod'));
+ }
+
+ public function contextCarriesStructuredFields(): void
+ {
+ $messenger = self::createMessenger();
+ $interceptor = new SpecInterceptor(new Spec(story: 'story', tags: ['checkout']), $messenger);
+
+ $interceptor->runTest(self::createTestInfo(), self::passing());
+
+ $context = self::firstMessage($messenger)->context;
+ Assert::same($context['story'], 'story');
+ Assert::same($context['tags'], ['checkout']);
+ Assert::same($context['case'], 'TestCase [test]');
+ Assert::true($context['line'] > 0);
+ }
+
+ public function readsSectionHeaderFromTheClass(): void
+ {
+ $context = self::runFixture('withoutHeader');
+
+ Assert::same($context['sectionTitle'], 'Checkout');
+ Assert::same($context['sectionNumber'], '5');
+ }
+
+ public function readsItemHeaderFromTheMethod(): void
+ {
+ $context = self::runFixture('withHeader');
+
+ Assert::same($context['title'], 'Tax in total');
+ Assert::same($context['number'], '5.1');
+ }
+
+ public function methodTitleOverridesTestNameInRenderedContent(): void
+ {
+ $messenger = self::createMessenger();
+ $interceptor = new SpecInterceptor(new Spec(story: 'story'), $messenger);
+
+ $interceptor->runTest(self::fixtureInfo('withHeader'), self::passing());
+
+ Assert::true(\str_contains(self::firstMessage($messenger)->content, '5.1 Tax in total'));
+ }
+
+ public function rendersTagsLine(): void
+ {
+ $messenger = self::createMessenger();
+ $interceptor = new SpecInterceptor(new Spec(story: 'story', tags: ['checkout', 'JIRA-1']), $messenger);
+
+ $interceptor->runTest(self::createTestInfo(), self::passing());
+
+ Assert::true(\str_contains(self::firstMessage($messenger)->content, '`checkout` `JIRA-1`'));
+ }
+
+ /**
+ * @return array
+ */
+ private static function runFixture(string $method): array
+ {
+ $messenger = self::createMessenger();
+ $interceptor = new SpecInterceptor(new Spec(story: 'story'), $messenger);
+
+ $interceptor->runTest(self::fixtureInfo($method), self::passing());
+
+ return self::firstMessage($messenger)->context;
+ }
+
+ /**
+ * @return \Closure(TestInfo): TestResult
+ */
+ private static function passing(): \Closure
+ {
+ return static fn(TestInfo $info): TestResult => new TestResult(info: $info, status: Status::Passed);
+ }
+
+ private static function firstMessage(Messenger $messenger): Message
+ {
+ return $messenger->getMessages()->channel(SpecInterceptor::CHANNEL)[0];
+ }
+
+ private static function createMessenger(): Messenger
+ {
+ return new MessengerHub(new class implements EventDispatcherInterface {
+ #[\Override]
+ public function dispatch(object $event): object
+ {
+ return $event;
+ }
+ });
+ }
+
+ private static function createTestInfo(): TestInfo
+ {
+ $reflection = new \ReflectionMethod(self::class, 'createTestInfo');
+ $caseInfo = new CaseInfo(definition: new CaseDefinition(name: 'TestCase', type: 'test'));
+
+ return new TestInfo(
+ name: 'testMethod',
+ caseInfo: $caseInfo,
+ testDefinition: new TestDefinition(reflection: $reflection),
+ );
+ }
+
+ private static function fixtureInfo(string $method): TestInfo
+ {
+ $class = new \ReflectionClass(HeaderedCase::class);
+ $caseInfo = new CaseInfo(definition: new CaseDefinition(name: HeaderedCase::class, type: 'test', reflection: $class));
+
+ return new TestInfo(
+ name: $method,
+ caseInfo: $caseInfo,
+ testDefinition: new TestDefinition(reflection: $class->getMethod($method)),
+ );
+ }
+}
diff --git a/plugin/spec/tests/Unit/SpecPluginTest.php b/plugin/spec/tests/Unit/SpecPluginTest.php
new file mode 100644
index 00000000..7a1bc88f
--- /dev/null
+++ b/plugin/spec/tests/Unit/SpecPluginTest.php
@@ -0,0 +1,171 @@
+configure($container);
+
+ Assert::same($container->interceptors, [SpecSuiteOrderInterceptor::class, SpecCaseOrderInterceptor::class]);
+ }
+
+ public function reorderingCanBeDisabled(): void
+ {
+ $container = self::container(new SpecInput());
+
+ (new SpecPlugin(reorder: false))->configure($container);
+
+ Assert::same($container->interceptors, []);
+ }
+
+ public function generationStaysOffByDefault(): void
+ {
+ $container = self::container(new SpecInput());
+
+ (new SpecPlugin())->configure($container);
+
+ Assert::false($container->has(SpecCollector::class));
+ Assert::same($container->listenedEvents, []);
+ }
+
+ public function generationActivatesWithTheCollectFlag(): void
+ {
+ $container = self::container(new SpecInput());
+
+ (new SpecPlugin(collect: true))->configure($container);
+
+ Assert::true($container->has(SpecCollector::class));
+ Assert::same($container->listenedEvents, [TestSuiteFinished::class]);
+ }
+
+ public function generationActivatesFromTheCliFlag(): void
+ {
+ $input = new SpecInput();
+ $input->spec = true;
+ $container = self::container($input);
+
+ (new SpecPlugin())->configure($container);
+
+ Assert::true($container->has(SpecCollector::class));
+ }
+
+ public function cliDirectoryOverridesConfiguredDirectory(): void
+ {
+ $input = new SpecInput();
+ $input->dir = 'cli/dir';
+ $container = self::container($input);
+
+ (new SpecPlugin(outputDir: 'config/dir', collect: true))->configure($container);
+
+ $collector = $container->services[SpecCollector::class];
+ $dir = (new \ReflectionProperty(SpecCollector::class, 'outputDir'))->getValue($collector);
+ Assert::same($dir, 'cli/dir');
+ }
+
+ public function generationDoesNotRegisterTwice(): void
+ {
+ $input = new SpecInput();
+ $input->spec = true;
+ $container = self::container($input);
+ $container->services[SpecCollector::class] = new SpecCollector('existing');
+
+ (new SpecPlugin(reorder: false))->configure($container);
+
+ Assert::same($container->listenedEvents, []);
+ }
+
+ private static function container(SpecInput $input): Container
+ {
+ return new class($input) implements Container {
+ /** @var array */
+ public array $services = [];
+
+ /** @var list */
+ public array $listenedEvents = [];
+
+ /** @var list */
+ public array $interceptors = [];
+
+ public function __construct(private readonly SpecInput $input)
+ {
+ $owner = $this;
+ $this->services[EventListenerCollector::class] = new class($owner) implements EventListenerCollector {
+ public function __construct(private readonly object $owner) {}
+
+ #[\Override]
+ public function addListener(string $eventName, callable $callback, int $priority = 0): void
+ {
+ $this->owner->listenedEvents[] = $eventName;
+ }
+ };
+ $this->services[InterceptorCollector::class] = new class($owner) implements InterceptorCollector {
+ public function __construct(private readonly object $owner) {}
+
+ #[\Override]
+ public function addInterceptor(Interceptor|string $interceptor): void
+ {
+ $this->owner->interceptors[] = \is_string($interceptor) ? $interceptor : $interceptor::class;
+ }
+ };
+ }
+
+ #[\Override]
+ public function get(string $id, array $arguments = []): object
+ {
+ return $id === SpecInput::class ? $this->input : $this->services[$id];
+ }
+
+ #[\Override]
+ public function has(string $id): bool
+ {
+ return isset($this->services[$id]);
+ }
+
+ #[\Override]
+ public function set(object $service, ?string $id = null, bool $destroy = false): void
+ {
+ $this->services[$id ?? $service::class] = $service;
+ }
+
+ #[\Override]
+ public function make(string $class, array $arguments = []): object
+ {
+ return $this->get($class);
+ }
+
+ #[\Override]
+ public function bind(string $id, \Closure|string|array|null $binding = null): void {}
+
+ #[\Override]
+ public function scope(\Closure $scope): mixed
+ {
+ return $scope($this);
+ }
+
+ #[\Override]
+ public function destroy(): void {}
+ };
+ }
+}
diff --git a/plugin/spec/tests/Unit/SpecTest.php b/plugin/spec/tests/Unit/SpecTest.php
new file mode 100644
index 00000000..4694e973
--- /dev/null
+++ b/plugin/spec/tests/Unit/SpecTest.php
@@ -0,0 +1,59 @@
+story, 'As a user I want X.');
+ }
+
+ public function tagsDefaultToEmpty(): void
+ {
+ $spec = new Spec(story: 'story');
+
+ Assert::same($spec->tags, []);
+ }
+
+ public function tagsArePreserved(): void
+ {
+ $spec = new Spec(story: 'story', tags: ['checkout', 'JIRA-1']);
+
+ Assert::same($spec->tags, ['checkout', 'JIRA-1']);
+ }
+
+ public function emptyTagsAreFilteredOut(): void
+ {
+ $spec = new Spec(story: 'story', tags: ['', 'kept', '']);
+
+ Assert::same($spec->tags, ['kept']);
+ }
+
+ /**
+ * @param string $story Blank stories carry no specification and are rejected.
+ */
+ #[DataSet([''], 'empty')]
+ #[DataSet([' '], 'whitespace')]
+ #[DataSet(["\n\t "], 'newlines')]
+ public function blankStoryFails(string $story): never
+ {
+ Expect::exception(\InvalidArgumentException::class)
+ ->withMessage('Spec story must not be empty.');
+
+ new Spec(story: $story);
+ }
+}
diff --git a/plugin/spec/tests/runtime/.gitignore b/plugin/spec/tests/runtime/.gitignore
new file mode 100644
index 00000000..d6b7ef32
--- /dev/null
+++ b/plugin/spec/tests/runtime/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/plugin/spec/tests/suites.php b/plugin/spec/tests/suites.php
new file mode 100644
index 00000000..f1574f1e
--- /dev/null
+++ b/plugin/spec/tests/suites.php
@@ -0,0 +1,31 @@
+