From c1fd56bf25e002796a9839a692ec515c2f22a585 Mon Sep 17 00:00:00 2001 From: riki137 Date: Wed, 28 Jan 2026 16:16:35 +0100 Subject: [PATCH 01/12] optional tags argument in TaskTreeBuilder --- CHANGELOG.md | 3 +- src/Tree/PatternTaskNodeFactory.php | 11 +++++-- src/Tree/TaskTreeBuilder.php | 47 ++++++++++++++++++++--------- 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85b4346..74d709c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] - +### Added +- optional $tags argument in TaskTreeBuilder ## [1.0.0] - 2025-11-07 diff --git a/src/Tree/PatternTaskNodeFactory.php b/src/Tree/PatternTaskNodeFactory.php index b2fb27a..90a65ad 100644 --- a/src/Tree/PatternTaskNodeFactory.php +++ b/src/Tree/PatternTaskNodeFactory.php @@ -7,17 +7,22 @@ class PatternTaskNodeFactory { /** - * @param TaskNode[] $children + * @param string $id The ID of the task node. + * @param string $pattern Comma-separated list of fnmatch patterns to match task IDs or tags + * @param TaskNode[] $children Child task nodes to filter. + * @param string[] $tags Tags associated with this filter node. */ public static function create( string $id, string $pattern, array $children = [], + array $tags = [], ): TaskNode { $patterns = array_map(fn($p) => strtr($p, ['%' => '*']), explode(',', $pattern)); return new TaskNode( $id, children: $children, + tags: $tags, postProcess: function (array $tasks) use ($patterns): iterable { $selected = []; @@ -46,7 +51,9 @@ public static function create( } /** - * @param string[] $patterns + * Check if a task matches any of the given patterns by ID or tags. + * @param CompiledTaskNode $task The task to check. + * @param string[] $patterns Array of fnmatch patterns */ private static function matches(CompiledTaskNode $task, array $patterns): bool { diff --git a/src/Tree/TaskTreeBuilder.php b/src/Tree/TaskTreeBuilder.php index d158044..c4da390 100644 --- a/src/Tree/TaskTreeBuilder.php +++ b/src/Tree/TaskTreeBuilder.php @@ -25,10 +25,11 @@ public function __construct(private ?ContainerInterface $container) * @param string $id Unique identifier for the task node. * @param Closure(): Task $factory Factory that returns the Task instance. * @param array $dependencies List of task IDs this node depends on. + * @param string[] $tags Tags associated with this task. */ - public function task(string $id, Closure $factory, array $dependencies = []): TaskNode + public function task(string $id, Closure $factory, array $dependencies = [], array $tags = []): TaskNode { - return new TaskNode($id, $factory, [], $dependencies); + return new TaskNode($id, $factory, [], $dependencies, $tags); } /** @@ -36,15 +37,17 @@ public function task(string $id, Closure $factory, array $dependencies = []): Ta * * @param class-string $class FQCN of the service to fetch from container. * @param array $dependencies Dependencies for the service task. + * @param string|null $id Optional identifier for the task. Defaults to short class name. + * @param string[] $tags Tags associated with this task. */ - public function service(string $class, array $dependencies = [], ?string $id = null): TaskNode + public function service(string $class, array $dependencies = [], ?string $id = null, array $tags = []): TaskNode { if ($this->container === null) { throw new LogicException('Cannot create service task: TaskTreeBuilderFactory has no container injected.' . ' Make sure an instance of ' . ContainerInterface::class . ' is autowired in your DI container OR is passed to ' . MultitronFactory::class . ' constructor.'); } - return $this->task($id ?? $this->shortClassName($class), fn() => $this->getTask($class), $dependencies); + return $this->task($id ?? $this->shortClassName($class), fn() => $this->getTask($class), $dependencies, $tags); } /** Fetch a task service from the container and ensure it implements Task. */ @@ -82,10 +85,11 @@ private function getPartitionedTask(string $class): PartitionedTaskInterface * @param string $id Group identifier. * @param TaskNode[] $children Child task nodes. * @param array $dependencies Dependencies for the group. + * @param string[] $tags Tags associated with this group. */ - public function group(string $id, array $children, array $dependencies = []): TaskNode + public function group(string $id, array $children, array $dependencies = [], array $tags = []): TaskNode { - return new TaskNode($id, null, $children, $dependencies); + return new TaskNode($id, null, $children, $dependencies, $tags); } /** @@ -95,14 +99,21 @@ public function group(string $id, array $children, array $dependencies = []): Ta * @param int $partitionCount Number of partitions. * @param array $dependencies Dependencies for the partitioned tasks. * @param string|null $id Optional base identifier for the partitioned tasks. Defaults to short class name. + * @param string[] $tags Tags associated with the partitioned tasks. */ - public function partitioned(string $class, int $partitionCount, array $dependencies = [], ?string $id = null): TaskNode - { + public function partitioned( + string $class, + int $partitionCount, + array $dependencies = [], + ?string $id = null, + array $tags = [] + ): TaskNode { return $this->partitionedClosure( $id ?? $class, fn(): PartitionedTaskInterface => $this->getPartitionedTask($class), $partitionCount, - $dependencies + $dependencies, + $tags ); } @@ -113,12 +124,14 @@ public function partitioned(string $class, int $partitionCount, array $dependenc * @param Closure(): (PartitionedTaskInterface|Task) $factory Factory for creating each partition. * @param int $partitionCount Number of partitions. * @param array $dependencies Dependencies for partitioned tasks. + * @param string[] $tags Tags associated with the partitioned tasks. */ public function partitionedClosure( string $id, Closure $factory, int $partitionCount, - array $dependencies = [] + array $dependencies = [], + array $tags = [] ): TaskNode { $shortId = $this->shortClassName($id); $children = []; @@ -138,19 +151,23 @@ function () use ($factory, $i, $partitionCount): Task { $task->setPartitioning($i, $partitionCount); return $task; - } + }, + tags: $tags ); } - return new TaskNode($shortId, null, $children, $dependencies); + return new TaskNode($shortId, null, $children, $dependencies, $tags); } /** - * @param TaskNode[] $children + * @param string $id Unique identifier for the pattern filter node. + * @param string $pattern Comma-separated fnmatch patterns to match task IDs or tags. + * @param TaskNode[] $children Child task nodes to filter. + * @param string[] $tags Tags associated with this filter node. */ - public function patternFilter(string $id, string $pattern, array $children = []): TaskNode + public function patternFilter(string $id, string $pattern, array $children = [], array $tags = []): TaskNode { - return PatternTaskNodeFactory::create($id, $pattern, $children); + return PatternTaskNodeFactory::create($id, $pattern, $children, $tags); } /** From 8d110b5b3e759651fac30edc41386c977b3c4c91 Mon Sep 17 00:00:00 2001 From: riki137 Date: Mon, 2 Feb 2026 09:00:57 +0100 Subject: [PATCH 02/12] add methods for managing task node properties in TaskNode class --- CHANGELOG.md | 2 +- src/Tree/TaskNode.php | 110 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 105 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74d709c..400d9ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added - optional $tags argument in TaskTreeBuilder - +- immutable setters in TaskNode ## [1.0.0] - 2025-11-07 ### Added diff --git a/src/Tree/TaskNode.php b/src/Tree/TaskNode.php index 6995a2e..2c20fb5 100644 --- a/src/Tree/TaskNode.php +++ b/src/Tree/TaskNode.php @@ -7,16 +7,18 @@ use Closure; use Multitron\Execution\Task; +/** + * Represents a node in the task tree before compilation. + */ final readonly class TaskNode { /** * @internal - * @param string $id - * @param ?Closure(): Task $factory - * @param TaskNode[] $children - * @param array $dependencies array of TaskNode IDs or TaskNode objects that this node depends on - * @param string[] $tags Tags can be used for post-processing, for example, to run only tasks with a specific tag. - * @param ?Closure(CompiledTaskNode[] $tasks): iterable $postProcess This can be used for filtering, for example. + * @param ?Closure(): Task $factory Creates the task instance. + * @param array $children Child task nodes. + * @param array $dependencies IDs or TaskNode objects this node depends on. + * @param array $tags Tags used for filtering or post-processing. + * @param ?Closure(array $tasks): iterable $postProcess Custom hook for post-processing compiled tasks. */ public function __construct( public string $id, @@ -27,4 +29,100 @@ public function __construct( public ?Closure $postProcess = null, ) { } + + /** + * Returns a new instance with the specified properties replaced. + * + * @param array|null $children + * @param array|null $dependencies + * @param array|null $tags + */ + public function with( + ?Closure $factory = null, + ?array $children = null, + ?array $dependencies = null, + ?array $tags = null, + ?Closure $postProcess = null, + ): self { + return new TaskNode( + id: $this->id, + factory: $factory ?? $this->factory, + children: $children ?? $this->children, + dependencies: $dependencies ?? $this->dependencies, + tags: $tags ?? $this->tags, + postProcess: $postProcess ?? $this->postProcess, + ); + } + + /** + * Returns a new instance with additional children, dependencies, and tags. + * + * @param array $children + * @param array $dependencies + * @param array $tags + */ + public function withAdded( + array $children = [], + array $dependencies = [], + array $tags = [], + ): self { + return $this->with( + children: array_merge($this->children, $children), + dependencies: array_merge($this->dependencies, $dependencies), + tags: array_merge($this->tags, $tags), + ); + } + + /** + * Returns a new instance with the specified properties removed or reset. + * + * @param bool|array $children Pass true to clear, or an array of nodes to remove. + * @param bool|array $dependencies Pass true to clear, or an array of items to remove. + * @param bool|array $tags Pass true to clear, or an array of tags to remove. + */ + public function without( + bool $factory = false, + bool|array $children = false, + bool|array $dependencies = false, + bool|array $tags = false, + bool $postProcess = false, + ): self { + return new TaskNode( + id: $this->id, + factory: $factory ? null : $this->factory, + children: $this->filterProperty($children, $this->children), + dependencies: $this->filterProperty($dependencies, $this->dependencies), + tags: $this->filterProperty($tags, $this->tags), + postProcess: $postProcess ? null : $this->postProcess, + ); + } + + /** + * Internal helper to handle the removal or clearing of collection properties. + * + * @template T + * @param bool|array $property + * @param array $current + * @return array + */ + private function filterProperty(bool|array $property, array $current): array + { + if ($property === false) { + return $current; + } + + if (is_array($property)) { + return array_values(array_filter($current, fn($item) => !in_array($item, $property, true))); + } + + return []; + } + + /** + * Whether the node has no children. + */ + public function isLeaf(): bool + { + return [] === $this->children; + } } From 36280fb2a6c062524ad8a147b7f1e2306e21c2c4 Mon Sep 17 00:00:00 2001 From: riki137 Date: Mon, 2 Feb 2026 09:04:32 +0100 Subject: [PATCH 03/12] TaskNodeTest --- tests/Unit/TaskNodeTest.php | 180 ++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 tests/Unit/TaskNodeTest.php diff --git a/tests/Unit/TaskNodeTest.php b/tests/Unit/TaskNodeTest.php new file mode 100644 index 0000000..1af1c43 --- /dev/null +++ b/tests/Unit/TaskNodeTest.php @@ -0,0 +1,180 @@ +assertSame('test-id', $node->id); + $this->assertNull($node->factory); + $this->assertSame([], $node->children); + $this->assertSame([], $node->dependencies); + $this->assertSame([], $node->tags); + $this->assertNull($node->postProcess); + } + + public function testConstructExplicitValues(): void + { + $factory = fn() => $this->createMock(Task::class); + $children = [new TaskNode('child')]; + $dependencies = ['dep1']; + $tags = ['tag1']; + $postProcess = fn(array $tasks) => $tasks; + + $node = new TaskNode( + id: 'test-id', + factory: $factory, + children: $children, + dependencies: $dependencies, + tags: $tags, + postProcess: $postProcess + ); + + $this->assertSame('test-id', $node->id); + $this->assertSame($factory, $node->factory); + $this->assertSame($children, $node->children); + $this->assertSame($dependencies, $node->dependencies); + $this->assertSame($tags, $node->tags); + $this->assertSame($postProcess, $node->postProcess); + } + + public function testWith(): void + { + $node = new TaskNode('test-id'); + + $factory = fn() => $this->createMock(Task::class); + $children = [new TaskNode('child')]; + $dependencies = ['dep1']; + $tags = ['tag1']; + $postProcess = fn(array $tasks) => $tasks; + + $newNode = $node->with( + factory: $factory, + children: $children, + dependencies: $dependencies, + tags: $tags, + postProcess: $postProcess + ); + + $this->assertNotSame($node, $newNode); + $this->assertSame('test-id', $newNode->id); + $this->assertSame($factory, $newNode->factory); + $this->assertSame($children, $newNode->children); + $this->assertSame($dependencies, $newNode->dependencies); + $this->assertSame($tags, $newNode->tags); + $this->assertSame($postProcess, $newNode->postProcess); + + // Test partial with + $partialNode = $newNode->with(tags: ['new-tag']); + $this->assertSame(['new-tag'], $partialNode->tags); + $this->assertSame($factory, $partialNode->factory); + } + + public function testWithAdded(): void + { + $node = new TaskNode( + id: 'test-id', + children: [new TaskNode('child1')], + dependencies: ['dep1'], + tags: ['tag1'] + ); + + $child2 = new TaskNode('child2'); + $newNode = $node->withAdded( + children: [$child2], + dependencies: ['dep2'], + tags: ['tag2'] + ); + + $this->assertCount(2, $newNode->children); + $this->assertSame($child2, $newNode->children[1]); + $this->assertSame(['dep1', 'dep2'], $newNode->dependencies); + $this->assertSame(['tag1', 'tag2'], $newNode->tags); + } + + public function testWithout(): void + { + $factory = fn() => $this->createMock(Task::class); + $child1 = new TaskNode('child1'); + $child2 = new TaskNode('child2'); + $dependencies = ['dep1', 'dep2']; + $tags = ['tag1', 'tag2']; + $postProcess = fn(array $tasks) => $tasks; + + $node = new TaskNode( + id: 'test-id', + factory: $factory, + children: [$child1, $child2], + dependencies: $dependencies, + tags: $tags, + postProcess: $postProcess + ); + + // Test clearing everything + $cleared = $node->without( + factory: true, + children: true, + dependencies: true, + tags: true, + postProcess: true + ); + + $this->assertNull($cleared->factory); + $this->assertSame([], $cleared->children); + $this->assertSame([], $cleared->dependencies); + $this->assertSame([], $cleared->tags); + $this->assertNull($cleared->postProcess); + + // Test removing specific items + $filtered = $node->without( + children: [$child1], + dependencies: ['dep2'], + tags: ['tag1'] + ); + + $this->assertSame([$child2], $filtered->children); + $this->assertSame(['dep1'], $filtered->dependencies); + $this->assertSame(['tag2'], $filtered->tags); + + // Test false values (no change) + $same = $node->without(); + $this->assertSame($factory, $same->factory); + $this->assertSame([$child1, $child2], $same->children); + $this->assertSame($dependencies, $same->dependencies); + $this->assertSame($tags, $same->tags); + $this->assertSame($postProcess, $same->postProcess); + } + + public function testWithoutWithMixedDependencies(): void + { + $child1 = new TaskNode('child1'); + $node = new TaskNode( + id: 'test', + dependencies: [$child1, 'dep1'] + ); + + $filtered = $node->without(dependencies: [$child1]); + $this->assertSame(['dep1'], $filtered->dependencies); + + $filtered2 = $node->without(dependencies: ['dep1']); + $this->assertSame([$child1], $filtered2->dependencies); + } + + public function testIsLeaf(): void + { + $node = new TaskNode('leaf'); + $this->assertTrue($node->isLeaf()); + + $parentNode = new TaskNode('parent', children: [$node]); + $this->assertFalse($parentNode->isLeaf()); + } +} From 24ff307da41b5479f0b4c6dfab35f6e866d32f0e Mon Sep 17 00:00:00 2001 From: riki137 Date: Mon, 2 Feb 2026 09:11:16 +0100 Subject: [PATCH 04/12] add symfony 8.0 to composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 4c24429..3969247 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ ], "require": { "php": "^8.2", - "symfony/console": "^7.2", + "symfony/console": "^7.2|^8.0", "riki137/stream-ipc": "^1.0", "psr/container": "^2.0", "psr/container-implementation": "*" From 32841f861c3f201631a760c0d5815ee6ecc5cc4a Mon Sep 17 00:00:00 2001 From: riki137 Date: Mon, 2 Feb 2026 09:14:44 +0100 Subject: [PATCH 05/12] symfony 8 support --- composer.json | 2 +- tests/Integration/TaskCommandIntegrationTest.php | 6 +++--- tests/Integration/WorkerCommandIntegrationTest.php | 4 ++-- tests/Unit/WorkerCommandTest.php | 12 ++++++------ 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/composer.json b/composer.json index 3969247..10dabcb 100644 --- a/composer.json +++ b/composer.json @@ -53,7 +53,7 @@ "contributte/psr11-container-interface": "^0.6.0", "phpunit/phpunit": "^11.5", "squizlabs/php_codesniffer": "^3.13|^4.0", - "symfony/dependency-injection": "^7.3", + "symfony/dependency-injection": "^7.3|^8.0", "illuminate/support": "^12", "illuminate/container": "^12", "pimple/pimple": "^3.5" diff --git a/tests/Integration/TaskCommandIntegrationTest.php b/tests/Integration/TaskCommandIntegrationTest.php index 9da0624..bdacbda 100644 --- a/tests/Integration/TaskCommandIntegrationTest.php +++ b/tests/Integration/TaskCommandIntegrationTest.php @@ -68,7 +68,7 @@ public function getNodes(TaskTreeBuilder $builder): array }; $app = new Application(); - $app->add($command); + $app->addCommand($command); $this->expectException(RuntimeException::class); $command->run(new ArrayInput([], $command->getDefinition()), new BufferedOutput()); @@ -90,8 +90,8 @@ public function getNodes(TaskTreeBuilder $builder): array $app = new Application(); $worker = new WorkerCommand($adapter); - $app->add($worker); - $app->add($command); + $app->addCommand($worker); + $app->addCommand($command); $result = $command->run(new ArrayInput([], $command->getDefinition()), new BufferedOutput()); $this->assertSame(0, $result); diff --git a/tests/Integration/WorkerCommandIntegrationTest.php b/tests/Integration/WorkerCommandIntegrationTest.php index 68b447c..64cd006 100644 --- a/tests/Integration/WorkerCommandIntegrationTest.php +++ b/tests/Integration/WorkerCommandIntegrationTest.php @@ -24,8 +24,8 @@ protected function setUp(): void $factory = new MultitronFactory(new AppContainer()); $app = new Application(); -$app->add($factory->getWorkerCommand()); -$app->add(new DemoCommand($factory->getTaskCommandDeps())); +$app->addCommand($factory->getWorkerCommand()); +$app->addCommand(new DemoCommand($factory->getTaskCommandDeps())); $app->run(); PHP; $this->script = $this->createWorkerScript(sprintf($script, var_export(self::$autoloadPath, true))); diff --git a/tests/Unit/WorkerCommandTest.php b/tests/Unit/WorkerCommandTest.php index 59e82cd..844778e 100644 --- a/tests/Unit/WorkerCommandTest.php +++ b/tests/Unit/WorkerCommandTest.php @@ -125,8 +125,8 @@ public function testExecuteFailsWhenCommandNotTaskCommand(): void $worker = new WorkerCommand($adapter); $app = new Application(); - $app->add($worker); - $app->add(new Command('foo')); + $app->addCommand($worker); + $app->addCommand(new Command('foo')); $tester = new CommandTester($worker); $this->expectException(RuntimeException::class); @@ -140,9 +140,9 @@ public function testExecuteFailsWhenTaskNotFound(): void $worker = new WorkerCommand($adapter); $app = new Application(); - $app->add($worker); + $app->addCommand($worker); $demo = new DemoCommand($this->createDeps()); - $app->add($demo); + $app->addCommand($demo); $tester = new CommandTester($worker); $this->expectException(RuntimeException::class); @@ -156,9 +156,9 @@ public function testExecuteRunsTaskSuccessfully(): void $worker = new WorkerCommand($adapter); $app = new Application(); - $app->add($worker); + $app->addCommand($worker); $demo = new DemoCommand($this->createDeps()); - $app->add($demo); + $app->addCommand($demo); $oldLimit = ini_get('memory_limit'); $tester = new CommandTester($worker); From 577de31ffecb99561c446c67d877eabfd3f6af63 Mon Sep 17 00:00:00 2001 From: riki137 Date: Mon, 2 Feb 2026 10:30:48 +0100 Subject: [PATCH 06/12] TaskNodeFactory --- CHANGELOG.md | 1 + src/Bridge/Native/MultitronFactory.php | 14 ++++++++++++++ src/Tree/TaskNodeFactory.php | 16 ++++++++++++++++ src/Tree/TaskTreeBuilder.php | 20 ++++++++++++-------- 4 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 src/Tree/TaskNodeFactory.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 400d9ef..343c2c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - optional $tags argument in TaskTreeBuilder - immutable setters in TaskNode +- optional TaskNodeFactory ## [1.0.0] - 2025-11-07 ### Added diff --git a/src/Bridge/Native/MultitronFactory.php b/src/Bridge/Native/MultitronFactory.php index b27e4fb..67be5b9 100644 --- a/src/Bridge/Native/MultitronFactory.php +++ b/src/Bridge/Native/MultitronFactory.php @@ -18,6 +18,7 @@ use Multitron\Orchestrator\Output\ProgressOutputFactory; use Multitron\Orchestrator\Output\TableOutputFactory; use Multitron\Orchestrator\TaskOrchestrator; +use Multitron\Tree\TaskNodeFactory; use Multitron\Tree\TaskTreeBuilderFactory; use Psr\Container\ContainerInterface; use StreamIpc\NativeIpcPeer; @@ -49,6 +50,8 @@ final class MultitronFactory private ?TaskTreeBuilderFactory $taskTreeBuilderFactory = null; + private ?TaskNodeFactory $taskNodeFactory = null; + public function __construct(private readonly ?ContainerInterface $container) { } @@ -101,6 +104,17 @@ public function setTaskTreeBuilderFactory(?TaskTreeBuilderFactory $taskTreeBuild return $this; } + public function getTaskNodeFactory(): ?TaskNodeFactory + { + return $this->taskNodeFactory; + } + + public function setTaskNodeFactory(?TaskNodeFactory $taskNodeFactory): self + { + $this->taskNodeFactory = $taskNodeFactory; + return $this; + } + public function getTaskOrchestrator(): TaskOrchestrator { return $this->taskOrchestrator ??= new TaskOrchestrator( diff --git a/src/Tree/TaskNodeFactory.php b/src/Tree/TaskNodeFactory.php new file mode 100644 index 0000000..550c2f5 --- /dev/null +++ b/src/Tree/TaskNodeFactory.php @@ -0,0 +1,16 @@ + $dependencies + * @param string[] $tags + */ + public function create(string $id, Closure $factory, array $dependencies = [], array $tags = [], ?string $class = null): TaskNode; +} diff --git a/src/Tree/TaskTreeBuilder.php b/src/Tree/TaskTreeBuilder.php index c4da390..345de5f 100644 --- a/src/Tree/TaskTreeBuilder.php +++ b/src/Tree/TaskTreeBuilder.php @@ -15,7 +15,7 @@ */ final readonly class TaskTreeBuilder { - public function __construct(private ?ContainerInterface $container) + public function __construct(private ?ContainerInterface $container = null, private ?TaskNodeFactory $nodeFactory = null) { } @@ -27,9 +27,10 @@ public function __construct(private ?ContainerInterface $container) * @param array $dependencies List of task IDs this node depends on. * @param string[] $tags Tags associated with this task. */ - public function task(string $id, Closure $factory, array $dependencies = [], array $tags = []): TaskNode + public function task(string $id, Closure $factory, array $dependencies = [], array $tags = [], ?string $class = null): TaskNode { - return new TaskNode($id, $factory, [], $dependencies, $tags); + return $this->nodeFactory?->create($id, $factory, $dependencies, $tags, $class) + ?? new TaskNode($id, $factory, [], $dependencies, $tags); } /** @@ -47,7 +48,7 @@ public function service(string $class, array $dependencies = [], ?string $id = n ' Make sure an instance of ' . ContainerInterface::class . ' is autowired in your DI container OR is passed to ' . MultitronFactory::class . ' constructor.'); } - return $this->task($id ?? $this->shortClassName($class), fn() => $this->getTask($class), $dependencies, $tags); + return $this->task($id ?? $this->shortClassName($class), fn() => $this->getTask($class), $dependencies, $tags, $class); } /** Fetch a task service from the container and ensure it implements Task. */ @@ -113,7 +114,8 @@ public function partitioned( fn(): PartitionedTaskInterface => $this->getPartitionedTask($class), $partitionCount, $dependencies, - $tags + $tags, + $class ); } @@ -131,7 +133,8 @@ public function partitionedClosure( Closure $factory, int $partitionCount, array $dependencies = [], - array $tags = [] + array $tags = [], + ?string $class = null ): TaskNode { $shortId = $this->shortClassName($id); $children = []; @@ -139,7 +142,7 @@ public function partitionedClosure( for ($i = 0; $i < $partitionCount; $i++) { $label = sprintf('%s %d/%d', $shortId, $i + 1, $partitionCount); - $children[] = new TaskNode( + $children[] = $this->task( $label, function () use ($factory, $i, $partitionCount): Task { $task = $factory(); @@ -152,7 +155,8 @@ function () use ($factory, $i, $partitionCount): Task { $task->setPartitioning($i, $partitionCount); return $task; }, - tags: $tags + tags: $tags, + class: $class ); } From 1f2c69ed5f9a36f6d455340371422ada1222b0e4 Mon Sep 17 00:00:00 2001 From: riki137 Date: Mon, 2 Feb 2026 11:56:19 +0100 Subject: [PATCH 07/12] refactor TaskTreeBuilderFactory to accept TaskNodeFactory --- src/Bridge/Native/MultitronFactory.php | 2 +- src/Tree/TaskTreeBuilderFactory.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Bridge/Native/MultitronFactory.php b/src/Bridge/Native/MultitronFactory.php index 67be5b9..4371fd7 100644 --- a/src/Bridge/Native/MultitronFactory.php +++ b/src/Bridge/Native/MultitronFactory.php @@ -95,7 +95,7 @@ public function setTaskCommandDeps(?TaskCommandDeps $taskCommandDeps): self public function getTaskTreeBuilderFactory(): TaskTreeBuilderFactory { - return $this->taskTreeBuilderFactory ??= new TaskTreeBuilderFactory($this->container); + return $this->taskTreeBuilderFactory ??= new TaskTreeBuilderFactory($this->container, $this->getTaskNodeFactory()); } public function setTaskTreeBuilderFactory(?TaskTreeBuilderFactory $taskTreeBuilderFactory): self diff --git a/src/Tree/TaskTreeBuilderFactory.php b/src/Tree/TaskTreeBuilderFactory.php index f3cef8a..3c24a61 100644 --- a/src/Tree/TaskTreeBuilderFactory.php +++ b/src/Tree/TaskTreeBuilderFactory.php @@ -8,12 +8,12 @@ final readonly class TaskTreeBuilderFactory { - public function __construct(private ?ContainerInterface $container) + public function __construct(private ?ContainerInterface $container, private ?TaskNodeFactory $nodeFactory = null) { } public function create(): TaskTreeBuilder { - return new TaskTreeBuilder($this->container); + return new TaskTreeBuilder($this->container, $this->nodeFactory); } } From ef88780831ef465bdbb3bf7299d57e24eabfddbf Mon Sep 17 00:00:00 2001 From: riki137 Date: Mon, 2 Feb 2026 12:53:27 +0100 Subject: [PATCH 08/12] feat: includeDependencies --- CHANGELOG.md | 1 + README.md | 7 ++- src/Bridge/Native/README.md | 5 ++ src/Console/TaskCommand.php | 17 ++++++- src/Tree/PatternTaskNodeFactory.php | 48 +++++++++++++++---- src/Tree/TaskTreeBuilder.php | 14 ++++-- .../PatternFilterIntegrationTest.php | 27 +++++++++-- .../TaskCommandIntegrationTest.php | 28 ++++++++++- tests/Unit/PatternTaskNodeFactoryTest.php | 17 ++++++- 9 files changed, 144 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 343c2c9..69306e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - optional $tags argument in TaskTreeBuilder - immutable setters in TaskNode - optional TaskNodeFactory +- pattern filtering can exclude dependencies (PatternTaskNodeFactory/TaskTreeBuilder flag + `--no-deps` CLI option) ## [1.0.0] - 2025-11-07 ### Added diff --git a/README.md b/README.md index f287859..d4d815b 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,12 @@ To limit which tasks run, pass a pattern as the first argument. Wildcards work t php bin/console app:tasks cache-* # run only tasks whose ID or tag matches "cache-*" ``` +By default, Multitron will also include transitive dependencies of the matched tasks so the resulting execution graph stays valid. If you want to run only the matched tasks (and ignore dependencies outside the selection), use `--no-deps`: + +```bash +php bin/console app:tasks cache-* --no-deps +``` + You can combine multiple patterns by separating them with commas. The filter applies to both task IDs and tags and is an OR pattern. @@ -318,4 +324,3 @@ Your feedback, issues, and contributions are highly encouraged. Open a GitHub is ## License Multitron is MIT licensed. See the [LICENSE](LICENSE) file for full details. - diff --git a/src/Bridge/Native/README.md b/src/Bridge/Native/README.md index daf7f38..de93312 100644 --- a/src/Bridge/Native/README.md +++ b/src/Bridge/Native/README.md @@ -120,6 +120,11 @@ $app->run(); ```bash ./bin/multitron.php app:tasks Test% ``` + By default, Multitron includes transitive dependencies of the matched tasks. Use `--no-deps` to run only the matched tasks: + + ```bash + ./bin/multitron.php app:tasks Test% --no-deps + ``` * **Limit concurrency** ```bash diff --git a/src/Console/TaskCommand.php b/src/Console/TaskCommand.php index 4eb2c56..cdc3a37 100644 --- a/src/Console/TaskCommand.php +++ b/src/Console/TaskCommand.php @@ -20,6 +20,8 @@ abstract class TaskCommand extends Command { + public const OPTION_DEPS = 'deps'; + private readonly TaskTreeBuilderFactory $builderFactory; private readonly TaskOrchestrator $orchestrator; @@ -52,6 +54,13 @@ protected function configure(): void InputArgument::OPTIONAL, 'fnmatch() pattern to filter tasks. You can optionally use % instead of * for wildcards. Works for groups too.' ); + $this->addOption( + self::OPTION_DEPS, + null, + InputOption::VALUE_NEGATABLE, + 'Include transitive dependencies when filtering by pattern (use --no-deps to run only matched tasks)', + true + ); $this->addOption(TaskOrchestrator::OPTION_CONCURRENCY, 'c', InputOption::VALUE_REQUIRED, 'Max concurrent tasks executed', $this->defaultConcurrency); $this->addOption(TaskOrchestrator::OPTION_UPDATE_INTERVAL, 'u', InputOption::VALUE_REQUIRED, 'Update interval in seconds', TaskOrchestrator::DEFAULT_UPDATE_INTERVAL); $this->addOption(TaskOrchestrator::OPTION_MEMORY_LIMIT, 'm', InputOption::VALUE_REQUIRED, 'PHP memory_limit for all processes', TaskOrchestrator::DEFAULT_MEMORY_LIMIT); @@ -79,7 +88,13 @@ final public function getTaskList(?InputInterface $input = null): TaskList $builder = $this->builderFactory->create(); $pattern = $input?->getArgument('pattern'); if (is_string($pattern) && trim($pattern) !== '') { - $node = $builder->patternFilter('root', $pattern, $this->getNodes($builder)); + $includeDeps = (bool)($input?->getOption(self::OPTION_DEPS) ?? true); + $node = $builder->patternFilter( + 'root', + $pattern, + $this->getNodes($builder), + includeDependencies: $includeDeps + ); } else { $node = new TaskNode('root', null, $this->getNodes($builder)); } diff --git a/src/Tree/PatternTaskNodeFactory.php b/src/Tree/PatternTaskNodeFactory.php index 90a65ad..6f4e0be 100644 --- a/src/Tree/PatternTaskNodeFactory.php +++ b/src/Tree/PatternTaskNodeFactory.php @@ -11,19 +11,27 @@ class PatternTaskNodeFactory * @param string $pattern Comma-separated list of fnmatch patterns to match task IDs or tags * @param TaskNode[] $children Child task nodes to filter. * @param string[] $tags Tags associated with this filter node. + * @param bool $includeDependencies When true (default), include transitive dependencies of matched tasks. + * When false, only matched tasks are returned and dependency edges pointing + * outside the matched set are removed. */ public static function create( string $id, string $pattern, array $children = [], array $tags = [], + bool $includeDependencies = true, ): TaskNode { - $patterns = array_map(fn($p) => strtr($p, ['%' => '*']), explode(',', $pattern)); + $patterns = array_values(array_filter(array_map( + static fn(string $p): string => strtr(trim($p), ['%' => '*']), + explode(',', $pattern) + ), static fn(string $p): bool => $p !== '')); + return new TaskNode( $id, children: $children, tags: $tags, - postProcess: function (array $tasks) use ($patterns): iterable { + postProcess: function (array $tasks) use ($patterns, $includeDependencies): iterable { $selected = []; foreach ($tasks as $task) { @@ -32,20 +40,40 @@ public static function create( } } - $queue = array_values($selected); - while ($queue) { - $current = array_pop($queue); - foreach ($current->dependencies as $dep) { - if (!isset($selected[$dep]) && isset($tasks[$dep])) { - $selected[$dep] = $tasks[$dep]; - $queue[] = $tasks[$dep]; + if ($includeDependencies) { + $queue = array_values($selected); + while ($queue) { + $current = array_pop($queue); + foreach ($current->dependencies as $dep) { + if (!isset($selected[$dep]) && isset($tasks[$dep])) { + $selected[$dep] = $tasks[$dep]; + $queue[] = $tasks[$dep]; + } } } + + foreach ($selected as $task) { + yield $task; + } + + return; } foreach ($selected as $task) { - yield $task; + $filteredDeps = array_values(array_filter( + $task->dependencies, + static fn(string $depId): bool => isset($selected[$depId]) + )); + + yield new CompiledTaskNode( + id: $task->id, + factory: $task->factory, + dependencies: $filteredDeps, + tags: $task->tags + ); } + + return; } ); } diff --git a/src/Tree/TaskTreeBuilder.php b/src/Tree/TaskTreeBuilder.php index 345de5f..2da5a17 100644 --- a/src/Tree/TaskTreeBuilder.php +++ b/src/Tree/TaskTreeBuilder.php @@ -168,10 +168,18 @@ class: $class * @param string $pattern Comma-separated fnmatch patterns to match task IDs or tags. * @param TaskNode[] $children Child task nodes to filter. * @param string[] $tags Tags associated with this filter node. + * @param bool $includeDependencies When true (default), include transitive dependencies of matched tasks. + * When false, only matched tasks are returned and dependency edges pointing + * outside the matched set are removed. */ - public function patternFilter(string $id, string $pattern, array $children = [], array $tags = []): TaskNode - { - return PatternTaskNodeFactory::create($id, $pattern, $children, $tags); + public function patternFilter( + string $id, + string $pattern, + array $children = [], + array $tags = [], + bool $includeDependencies = true + ): TaskNode { + return PatternTaskNodeFactory::create($id, $pattern, $children, $tags, $includeDependencies); } /** diff --git a/tests/Integration/PatternFilterIntegrationTest.php b/tests/Integration/PatternFilterIntegrationTest.php index 4f83747..afb37d4 100644 --- a/tests/Integration/PatternFilterIntegrationTest.php +++ b/tests/Integration/PatternFilterIntegrationTest.php @@ -12,7 +12,7 @@ final class PatternFilterIntegrationTest extends TestCase { - private function createTaskList(): TaskList + private function createTaskList(bool $includeDependencies = true): TaskList { $builder = new TaskTreeBuilder(new AppContainer()); @@ -34,7 +34,7 @@ private function createTaskList(): TaskList $dbGroup, $miscGroup, $report, - ]); + ], includeDependencies: $includeDependencies); return new TaskList($root); } @@ -92,5 +92,26 @@ public function testExecutionOrderRespectsDependencies(): void 'final-report', ], $order); } -} + public function testPatternFilterCanExcludeDependencies(): void + { + $list = $this->createTaskList(includeDependencies: false); + $nodes = $list->toArray(); + + $ids = array_keys($nodes); + sort($ids); + $this->assertSame([ + 'cache-clear', + 'cache-warm', + 'db-backup', + 'db-migrate', + 'final-report', + ], $ids); + + $this->assertSame([], $nodes['cache-clear']->dependencies); + $this->assertSame(['cache-clear'], $nodes['cache-warm']->dependencies); + $this->assertSame([], $nodes['db-backup']->dependencies); + $this->assertSame(['db-backup'], $nodes['db-migrate']->dependencies); + $this->assertSame(['cache-warm', 'db-migrate'], $nodes['final-report']->dependencies); + } +} diff --git a/tests/Integration/TaskCommandIntegrationTest.php b/tests/Integration/TaskCommandIntegrationTest.php index bdacbda..bbf0595 100644 --- a/tests/Integration/TaskCommandIntegrationTest.php +++ b/tests/Integration/TaskCommandIntegrationTest.php @@ -56,6 +56,33 @@ public function getNodes(TaskTreeBuilder $builder): array $this->assertArrayHasKey('first', $nodes); } + public function testGetTaskListCanExcludeDependenciesWhenFiltering(): void + { + $peer = new NativeIpcPeer(); + $deps = $this->createDeps($peer); + $command = new #[AsCommand('demo')] class($deps) extends TaskCommand { + public function getNodes(TaskTreeBuilder $builder): array + { + $b = $builder->task('b', fn() => new DummyTask()); + $a = $builder->task('a', fn() => new DummyTask(), [$b]); + return [$a, $b]; + } + }; + + $input = new ArrayInput( + [ + 'pattern' => 'a', + '--deps' => false, + ], + $command->getDefinition() + ); + $list = $command->getTaskList($input); + $nodes = $list->toArray(); + $this->assertCount(1, $nodes); + $this->assertArrayHasKey('a', $nodes); + $this->assertSame([], $nodes['a']->dependencies); + } + public function testExecuteFailsWhenWorkerMissing(): void { $peer = new NativeIpcPeer(); @@ -97,4 +124,3 @@ public function getNodes(TaskTreeBuilder $builder): array $this->assertSame(0, $result); } } - diff --git a/tests/Unit/PatternTaskNodeFactoryTest.php b/tests/Unit/PatternTaskNodeFactoryTest.php index b36cd4a..688fee5 100644 --- a/tests/Unit/PatternTaskNodeFactoryTest.php +++ b/tests/Unit/PatternTaskNodeFactoryTest.php @@ -27,5 +27,20 @@ public function testFilteringAndDependencyAdjustment(): void $this->assertSame(['beta'], $compiled['gamma']->dependencies); $this->assertSame([], $compiled['alpha']->dependencies); } -} + public function testFilteringWithoutDependenciesRemovesDependencyEdgesOutsideSelection(): void + { + $t1 = new TaskNode('alpha', fn() => new DummyTask()); + $t2 = new TaskNode('beta', fn() => new DummyTask(), [], ['alpha']); + $t3 = new TaskNode('gamma', fn() => new DummyTask(), [], ['beta']); + + $root = PatternTaskNodeFactory::create('filter', 'beta,gamma', [$t1, $t2, $t3], includeDependencies: false); + $compiled = (new TaskTreeCompiler())->compile($root); + + $ids = array_keys($compiled); + sort($ids); + $this->assertSame(['beta', 'gamma'], $ids); + $this->assertSame([], $compiled['beta']->dependencies); + $this->assertSame(['beta'], $compiled['gamma']->dependencies); + } +} From f3594bcaad6c7e0de1deb9d53830d07aab652d3f Mon Sep 17 00:00:00 2001 From: riki137 Date: Mon, 2 Feb 2026 13:55:23 +0100 Subject: [PATCH 09/12] add groups support to TaskNodeFactory --- src/Tree/TaskNodeFactory.php | 3 ++- src/Tree/TaskTreeBuilder.php | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Tree/TaskNodeFactory.php b/src/Tree/TaskNodeFactory.php index 550c2f5..01686ae 100644 --- a/src/Tree/TaskNodeFactory.php +++ b/src/Tree/TaskNodeFactory.php @@ -9,8 +9,9 @@ interface TaskNodeFactory { /** + * @param array $children * @param array $dependencies * @param string[] $tags */ - public function create(string $id, Closure $factory, array $dependencies = [], array $tags = [], ?string $class = null): TaskNode; + public function create(string $id, ?Closure $factory, array $children = [], array $dependencies = [], array $tags = [], ?string $class = null): TaskNode; } diff --git a/src/Tree/TaskTreeBuilder.php b/src/Tree/TaskTreeBuilder.php index 2da5a17..0482b46 100644 --- a/src/Tree/TaskTreeBuilder.php +++ b/src/Tree/TaskTreeBuilder.php @@ -29,7 +29,7 @@ public function __construct(private ?ContainerInterface $container = null, priva */ public function task(string $id, Closure $factory, array $dependencies = [], array $tags = [], ?string $class = null): TaskNode { - return $this->nodeFactory?->create($id, $factory, $dependencies, $tags, $class) + return $this->nodeFactory?->create($id, $factory, [], $dependencies, $tags, $class) ?? new TaskNode($id, $factory, [], $dependencies, $tags); } @@ -90,7 +90,8 @@ private function getPartitionedTask(string $class): PartitionedTaskInterface */ public function group(string $id, array $children, array $dependencies = [], array $tags = []): TaskNode { - return new TaskNode($id, null, $children, $dependencies, $tags); + return $this->nodeFactory?->create($id, null, $children, $dependencies, $tags) + ?? new TaskNode($id, null, $children, $dependencies, $tags); } /** @@ -160,7 +161,7 @@ class: $class ); } - return new TaskNode($shortId, null, $children, $dependencies, $tags); + return $this->group($shortId, $children, $dependencies, $tags); } /** From eb696d4cab1a3c4ac6ad80b45635f06a4390bbad Mon Sep 17 00:00:00 2001 From: riki137 Date: Thu, 12 Feb 2026 09:45:58 +0100 Subject: [PATCH 10/12] TaskNodeFactory coverage --- tests/Unit/MultitronFactoryTest.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/Unit/MultitronFactoryTest.php b/tests/Unit/MultitronFactoryTest.php index 6dbf23e..7c186c0 100644 --- a/tests/Unit/MultitronFactoryTest.php +++ b/tests/Unit/MultitronFactoryTest.php @@ -3,7 +3,9 @@ namespace Multitron\Tests\Unit; +use Closure; use InvalidArgumentException; +use LogicException; use Multitron\Bridge\Native\MultitronFactory; use Multitron\Comms\NativeIpcAdapter; use Multitron\Console\TaskCommandDeps; @@ -14,6 +16,8 @@ use Multitron\Orchestrator\Output\ProgressOutputFactory; use Multitron\Orchestrator\TaskOrchestrator; use Multitron\Tests\Mocks\AppContainer; +use Multitron\Tree\TaskNode; +use Multitron\Tree\TaskNodeFactory; use Multitron\Tree\TaskTreeBuilderFactory; use PHPUnit\Framework\TestCase; use StreamIpc\NativeIpcPeer; @@ -56,6 +60,22 @@ public function testAllSettersAndDefaults(): void $factory->setProgressOutputFactory($outFactory); $this->assertSame($outFactory, $factory->getProgressOutputFactory()); + $taskNodeFactory = new class() implements TaskNodeFactory { + public function create( + string $id, + ?Closure $factory, + array $children = [], + array $dependencies = [], + array $tags = [], + ?string $class = null + ): TaskNode { + throw new LogicException('Not needed for this test'); + } + }; + $factory->setTaskNodeFactory($taskNodeFactory); + $this->assertSame($taskNodeFactory, $factory->getTaskNodeFactory()); + + $factory->setIpcHandlerRegistryFactory($regFactory); $this->assertSame($regFactory, $factory->getIpcHandlerRegistryFactory()); From 7471c6bffb473034d3714a000fe0b6299eacb2e6 Mon Sep 17 00:00:00 2001 From: riki137 Date: Thu, 12 Feb 2026 09:53:30 +0100 Subject: [PATCH 11/12] ci fixes --- composer.json | 2 +- .../Handler/MasterCache/MasterCacheReadKeysRequest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 10dabcb..54e1bb2 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "psr/log": "^3.0", "nette/di": "^3.2", "phpstan/phpstan": "^2.1", - "contributte/psr11-container-interface": "^0.6.0", + "contributte/psr11-container-interface": "^0.7.0", "phpunit/phpunit": "^11.5", "squizlabs/php_codesniffer": "^3.13|^4.0", "symfony/dependency-injection": "^7.3|^8.0", diff --git a/src/Execution/Handler/MasterCache/MasterCacheReadKeysRequest.php b/src/Execution/Handler/MasterCache/MasterCacheReadKeysRequest.php index d97dd1b..e205e43 100644 --- a/src/Execution/Handler/MasterCache/MasterCacheReadKeysRequest.php +++ b/src/Execution/Handler/MasterCache/MasterCacheReadKeysRequest.php @@ -32,7 +32,7 @@ public function doRead(array &$storage): MasterCacheReadResponse * * @param array $storage data source to read from * @param array $keysSpec description of keys to fetch - * @return array fetched data + * @return array fetched data */ private function fetchKeys(array &$storage, array $keysSpec): array { From 66f9b06accee069035377b8a8495a1270dd7ba9f Mon Sep 17 00:00:00 2001 From: riki137 Date: Thu, 12 Feb 2026 09:55:31 +0100 Subject: [PATCH 12/12] phpstan fix --- .../Handler/MasterCache/MasterCacheReadKeysPromise.php | 2 +- src/Execution/Handler/MasterCache/MasterCacheReadResponse.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Execution/Handler/MasterCache/MasterCacheReadKeysPromise.php b/src/Execution/Handler/MasterCache/MasterCacheReadKeysPromise.php index 29ef6fe..c8a6ba0 100644 --- a/src/Execution/Handler/MasterCache/MasterCacheReadKeysPromise.php +++ b/src/Execution/Handler/MasterCache/MasterCacheReadKeysPromise.php @@ -17,7 +17,7 @@ public function __construct(private readonly ResponsePromise $promise) * Block until the read request completes and return the full set of * retrieved values keyed by cache identifier. * - * @return array + * @return array */ public function await(): array { diff --git a/src/Execution/Handler/MasterCache/MasterCacheReadResponse.php b/src/Execution/Handler/MasterCache/MasterCacheReadResponse.php index 0a68fb4..9024c1c 100644 --- a/src/Execution/Handler/MasterCache/MasterCacheReadResponse.php +++ b/src/Execution/Handler/MasterCache/MasterCacheReadResponse.php @@ -9,7 +9,7 @@ final readonly class MasterCacheReadResponse implements Message { /** - * @param array $data + * @param array $data */ public function __construct(public array $data) {