diff --git a/resources/construction-probe.php b/resources/construction-probe.php index 53193b5..50194df 100644 --- a/resources/construction-probe.php +++ b/resources/construction-probe.php @@ -90,6 +90,41 @@ $say(['booted' => true, 'served' => $served]); } +// WHAT AN OPERATION'S run() WORKS THROUGH IS HANDED BY THE ENTRY THAT LISTS IT (greenhouse evidence/1154). The house +// resolves each collaborator of `run()` with the resolver `DeclaredOperation::from()` was given in the plugin's +// `operations()` — at the call, and nowhere before it: an operation written against something that entry cannot +// hand landed green and failed at its first call. So the same act is done here, without running `run()`: the +// resolver is asked for each type it would be asked for. It is closed over by the handler of the operation, and +// that is where it is read. +$operation = null; +foreach ($kernel->commands() as $command) { + $handler = $command->handler ?? null; + if (!$handler instanceof \Closure) { + continue; + } + $closed = (new \ReflectionFunction($handler))->getStaticVariables(); + if (!\is_string($closed['class'] ?? null) || ltrim($closed['class'], '\\') !== ltrim($class, '\\') || !\is_array($closed['collaborators'] ?? null)) { + continue; + } + $resolve = $closed['resolve'] ?? null; + $unhanded = []; + foreach ($closed['collaborators'] as $type) { + if (!\is_string($type)) { + continue; + } + try { + if (!$resolve instanceof \Closure || !\is_object($resolve($type))) { + $unhanded[] = ['type' => $type, 'error' => 'the entry that lists it gave nothing back']; + } + } catch (\Throwable $e) { + $unhanded[] = ['type' => $type, 'error' => $relative($e->getMessage())]; + } + } + $operation = ['name' => (string) $command->name, 'handed' => $unhanded === [], 'unhanded' => $unhanded]; + break; +} +$asked = $operation === null ? [] : ['operation' => $operation]; + $routes = []; foreach ($kernel->router()->routes() as $route) { if ($route->handler !== null && ltrim($route->handler->controller, '\\') === ltrim($class, '\\')) { @@ -97,13 +132,13 @@ } } if ($routes === []) { - $say(['booted' => true, 'routes' => []]); + $say(['booted' => true, 'routes' => []] + $asked); } $container = $kernel->container(); try { $built = $container->get($class); - $say(['booted' => true, 'routes' => $routes, 'built' => \is_object($built)]); + $say(['booted' => true, 'routes' => $routes, 'built' => \is_object($built)] + $asked); } catch (\Throwable $e) { // What the container could not fill, named from the constructor: a parameter whose class-typed hint // nothing registered and PHP cannot `new`, with no default and no null to fall back on. @@ -131,5 +166,5 @@ 'built' => false, 'error' => (new \ReflectionClass($e))->getShortName() . ': ' . implode(' ← ', $messages), 'unresolvable' => $unresolvable, - ]); + ] + $asked); } diff --git a/resources/skills/governed-authoring/SKILL.md b/resources/skills/governed-authoring/SKILL.md index ffe8455..c65f07b 100644 --- a/resources/skills/governed-authoring/SKILL.md +++ b/resources/skills/governed-authoring/SKILL.md @@ -58,9 +58,11 @@ unjudged when no test declares what the class must do*. A body with no test is s **What a request asks the house to be able to DO is a verb, and each verb is an operation**, one `make` each. A controller serves a route; it does not give an agent anything to call. An operation - over stored rows names their entity (`entity=`) and its `run()` receives that entity's - repository; its `operation=domain:verb` names it and fixes the scope it spends (`domain:write`, or - `domain:read` with `reads=true`); `fields` are its input. + over stored rows receives their entity's repository in its `run()` — that is the ONLY way it reaches + them: a plugin's only entity is handed without being named; with several, name it (`entity=`). + Write `run()` against what the scaffold gave it: `implement` refuses a `run()` that asks for something + its plugin cannot hand it, and says the edit that fixes it. Its `operation=domain:verb` names it and + fixes the scope it spends (`domain:write`, or `domain:read` with `reads=true`); `fields` are its input. **Having decided WHAT, do not hand-type what a governed op builds whole.** Hand-author (`implement`, below) ONLY the criterion no op can know: the **business rule**, the validation, the **domain diff --git a/src/Make/Generators/EntityGenerator.php b/src/Make/Generators/EntityGenerator.php index effb01e..a5f94d0 100644 --- a/src/Make/Generators/EntityGenerator.php +++ b/src/Make/Generators/EntityGenerator.php @@ -56,6 +56,12 @@ */ final class EntityGenerator implements GeneratorInterface { + /** + * How an operation reaches the repository: said beside the container's id, because `run()` has no container + * to ask and a real resident wrote one against the interface (greenhouse evidence/1154). + */ + private const REACHED_BY_AN_OPERATION = ' An operation of this plugin does not ask the container for it: its run() receives it — make what=operation hands it.'; + private StubLocator $stubs; /** @@ -347,7 +353,7 @@ private function wireRepository( if ($this->markers->hasMarker($existing, Markers::SERVICES)) { $merged = $this->markers->insertBefore($existing, Markers::SERVICES, $snippet, $context->flag('force')); $guidance = "Auto-wired into the existing plugin at {$pluginPath} (// {" . Markers::SERVICES - . "} marker found). Resolve the repository later via \$container->get({$repositoryId})."; + . "} marker found). Resolve the repository later via \$container->get({$repositoryId})." . self::REACHED_BY_AN_OPERATION; return ['file' => new PlannedFile($pluginPath, $merged, merge: true), 'guidance' => $guidance]; } @@ -378,7 +384,7 @@ private function wireRepository( . "{$snippet}\n\n" . "The backend is one config line: set storage.driver in config/app.php to file, sqlite, " . "mysql or memory (with its path/dsn); with no storage block the default above persists " - . "to var/{$table}.json. Resolve the repository later via \$container->get({$repositoryId})."; + . "to var/{$table}.json. Resolve the repository later via \$container->get({$repositoryId})." . self::REACHED_BY_AN_OPERATION; return ['file' => null, 'guidance' => $guidance]; } diff --git a/src/Make/Generators/OperationGenerator.php b/src/Make/Generators/OperationGenerator.php index 5d06efb..7a5c459 100644 --- a/src/Make/Generators/OperationGenerator.php +++ b/src/Make/Generators/OperationGenerator.php @@ -45,8 +45,11 @@ * - THE AUTHORITY IT SPENDS — `#[Needs(scopes: [':write'])]`, or `:read`. The domain is what stands before * the separator of its name, so nobody invents a word of authority. It is what a person grants to admit it; * - ITS INPUT — the `--fields`, as the constructor; - * - WHERE ITS STATE LIVES — with `--entity`, `run()` receives the repository of that entity of its plugin, the one - * `make entity` registered. Nothing is invented: it is the same store, reached by the same key; + * - WHERE ITS STATE LIVES — `run()` receives the repository of an entity of its plugin, the one `make entity` + * registered: the one `--entity` names, or the plugin's ONLY entity when none is named. Nothing is invented: it + * is the same store, reached by the same key. With several entities and none named, none is guessed — and the + * result says so, because a `run()` written against a repository nobody hands it lands and fails at its first + * call (greenhouse evidence/1154); * - what else `run()` WORKS THROUGH — the `--needs`, resolved by whoever registers it. * * AND IT IS REGISTERED, never left as prose to paste: `operations()` of the plugin lists it through @@ -109,7 +112,11 @@ public function generate(GenerationContext $context): GenerationResult $path = $context->root . '/' . $appDir . '/Plugins/' . $context->plugin . '/Operations/' . $context->name . '.php'; $reads = $context->flag('reads'); - $entity = self::entityOf($context, $appDir); + $named = self::entityOf($context, $appDir); + // WHERE ITS STATE LIVES IS NOT SOMETHING TO REMEMBER TO SAY (greenhouse evidence/1154): a plugin's only + // entity is the one its operations work over. Which of several is a decision, and nobody made it. + $entities = $named === null ? self::entitiesOf($context, $appDir) : []; + $entity = $named ?? (\count($entities) === 1 ? $entities[0] : null); $entityFqcn = $entity === null ? null : $appNamespace . '\\Plugins\\' . $context->plugin . '\\Entities\\' . $entity; $name = self::operationName($context); $scope = self::domainOf($name) . ($reads ? ':read' : ':write'); @@ -154,7 +161,13 @@ public function generate(GenerationContext $context): GenerationResult $name, self::toolName($name), $scope, - $entity === null ? '' : "Its run() receives the repository of {$entity}. ", + match (true) { + $named !== null => "Its run() receives the repository of {$entity}. ", + $entity !== null => "Its run() receives the repository of {$entity} — the only entity of this plugin. ", + $entities !== [] => 'Its run() receives NO repository: this plugin has several entities (' . implode(', ', $entities) + . ') and none was named — if it stores or reads rows, scaffold it with entity=. ', + default => '', + }, $wiring, $context->plugin, $context->name, @@ -235,6 +248,35 @@ private static function entityOf(GenerationContext $context, string $appDir): ?s return $entity; } + /** + * The entities a plugin has, by name and in order: the classes under its `Entities/` that are entities. + * + * @return list + */ + private static function entitiesOf(GenerationContext $context, string $appDir): array + { + return self::entitiesIn($context->root . '/' . $appDir . '/Plugins/' . $context->plugin); + } + + /** + * The entities under a plugin's source directory, by name and in order. + * + * @return list + */ + public static function entitiesIn(string $pluginDir): array + { + $found = []; + foreach (glob(rtrim($pluginDir, '/') . '/Entities/*.php') ?: [] as $file) { + // An entity says it is one; an enum or a value object that lives beside them is not counted. + if (str_contains((string) file_get_contents($file), 'EntityInterface')) { + $found[] = basename($file, '.php'); + } + } + sort($found); + + return $found; + } + /** How `run()` names the repository it receives: `Widget` → `widgets`. */ private static function repositoryParameter(string $entity): string { diff --git a/src/Operations/ConstructionProbe.php b/src/Operations/ConstructionProbe.php index 04693fe..cadcefd 100644 --- a/src/Operations/ConstructionProbe.php +++ b/src/Operations/ConstructionProbe.php @@ -38,6 +38,9 @@ final class ConstructionProbe /** The child that boots the house and asks its container; one JSON object on its STDOUT. */ public const SCRIPT = __DIR__ . '/../../resources/construction-probe.php'; + /** What an operation over stored rows takes: registered by nobody, handed by the entry that names its entity. */ + private const REPOSITORY = 'Milpa\\Data\\RepositoryInterface'; + /** The container contracts a controller should not ask for (greenhouse decisions/0541, point 3). */ private const CONTAINERS = ['Milpa\\Interfaces\\Di\\DIContainerInterface', 'Psr\\Container\\ContainerInterface']; @@ -56,7 +59,11 @@ public function __construct(private readonly ?string $php = null) * and, when some does, `built` says whether the container gave it back, with `error` and * `unresolvable` (parameter → type) when it did not. * - * @return array{unjudged?: string, routes?: list, built?: bool, error?: string, unresolvable?: list} + * `operation` is there when the class is an operation the booted house offers (greenhouse evidence/1154): its + * name, whether the entry that lists it handed `run()` everything it works through, and — when it did not — + * each type it could not hand with what the house answered. + * + * @return array{unjudged?: string, routes?: list, built?: bool, error?: string, unresolvable?: list, operation?: array{name: string, handed: bool, unhanded: list}} */ public function probe(string $root, string $class): array { @@ -76,12 +83,13 @@ public function probe(string $root, string $class): array return ['unjudged' => 'the house did not boot: ' . (\is_string($answer['reason'] ?? null) ? $answer['reason'] : 'no reason given')]; } + $asked = self::operationIn($answer); $routes = array_values(array_filter((array) ($answer['routes'] ?? []), 'is_string')); if ($routes === []) { - return ['routes' => []]; + return ['routes' => []] + $asked; } if (($answer['built'] ?? false) === true) { - return ['routes' => $routes, 'built' => true]; + return ['routes' => $routes, 'built' => true] + $asked; } $unresolvable = []; @@ -96,7 +104,85 @@ public function probe(string $root, string $class): array 'built' => false, 'error' => \is_string($answer['error'] ?? null) ? $answer['error'] : 'the container gave nothing back', 'unresolvable' => $unresolvable, - ]; + ] + $asked; + } + + /** + * What the child said of the operation this class is, read strictly — or nothing: it is none. + * + * @param array $answer + * + * @return array{}|array{operation: array{name: string, handed: bool, unhanded: list}} + */ + private static function operationIn(array $answer): array + { + $said = $answer['operation'] ?? null; + if (!\is_array($said) || !\is_string($said['name'] ?? null) || !\is_bool($said['handed'] ?? null)) { + return []; + } + $unhanded = []; + foreach ((array) ($said['unhanded'] ?? []) as $entry) { + if (\is_array($entry) && \is_string($entry['type'] ?? null)) { + $unhanded[] = ['type' => $entry['type'], 'error' => \is_string($entry['error'] ?? null) ? $entry['error'] : '']; + } + } + + return ['operation' => ['name' => $said['name'], 'handed' => $said['handed'], 'unhanded' => $unhanded]]; + } + + /** + * The refusal a model corrects from when the house cannot hand an operation what its `run()` works through: + * what it asked for, what the house answered, why, and THE WAY with the order in which it lands. + * + * ── A REPOSITORY IS REACHED BY ITS ENTITY (greenhouse evidence/1154) ────────────────────────────────────────── + * + * `Milpa\Data\RepositoryInterface` is registered by nobody, and should not be: there is one repository per + * entity, under the id `make entity` gave it. What hands it to `run()` is the entry that lists the operation in + * its plugin — written with the entity when the operation was scaffolded with one, and by class when it was + * not. A real resident scaffolded with none, wrote `run()` against the repository, and nothing said a word + * until the first call. The fix is one exact edit of that entry, and it is written out here: with one entity + * in the plugin it names it; with several it leaves the name to whoever knows which. + * + * Anything else nobody registered is the defect a controller's constructor has, and takes the same fix: its + * type registered in the plugin's `boot()`. + * + * @param string $pluginDir the plugin's source directory + * @param string $fqcn the operation's class + * @param list $unhanded + */ + public static function unhanded(string $pluginDir, string $plugin, string $class, string $fqcn, string $name, array $unhanded): string + { + $asked = implode('; ', array_map(static fn (array $u): string => $u['type'] . ', and the house answered: ' . rtrim($u['error'], '. '), $unhanded)); + $text = "refused: the house cannot hand «{$class}» what its run() works through — «{$name}» asked for {$asked}.\n" + . "What run() takes is found by the entry that lists this operation in operations() of its plugin, and by nothing else.\n"; + $again = "Then send this same implement plugin={$plugin} class={$class} again."; + + $types = array_column($unhanded, 'type'); + $others = array_values(array_filter($types, static fn (string $type): bool => $type !== self::REPOSITORY)); + $steps = []; + if (\in_array(self::REPOSITORY, $types, true)) { + $entities = \Milpa\DevTools\Make\Generators\OperationGenerator::entitiesIn($pluginDir); + $text .= 'A repository has no class to be found by: it is reached by its entity, under the id `make entity` registered it with. ' + . ($entities === [] ? "This plugin has no entity yet.\n" : "This plugin's entities: " . implode(', ', $entities) . ".\n"); + $byClass = \Milpa\DevTools\Make\Generators\OperationGenerator::entry($fqcn); + $source = (string) @file_get_contents($pluginDir . '/' . $plugin . '.php'); + $namespace = substr($fqcn, 0, (int) strrpos($fqcn, '\\Operations\\')); + $entity = \count($entities) === 1 ? $entities[0] : ''; + $byEntity = \Milpa\DevTools\Make\Generators\OperationGenerator::entry($fqcn, $namespace . '\\Entities\\' . $entity); + if ($entities === []) { + $steps[] = "Scaffold the entity it stores — make what=entity plugin={$plugin} name= fields=… — and promote it."; + } + $steps[] = str_contains($source, $byClass) + ? "Make its entry say which entity — edit plugin={$plugin} class={$plugin} with one {find, replace} pair, and promote it:\n find: {$byClass}\n replace: {$byEntity}" + : "Make the entry that lists it in operations() of {$plugin} resolve " . self::REPOSITORY . " to its entity's repository — the resolver `make` writes is:\n {$byEntity}"; + } + foreach ($others as $type) { + $steps[] = "Register «{$type}» in boot() of {$plugin} — \$this->container->registerService(\\" . ltrim($type, '\\') + . '::class, ); — or take a concrete class the container can build; and promote it.'; + } + $steps[] = $again; + + return $text . "In this order:\n" . implode("\n", array_map(static fn (int $n, string $step): string => ($n + 1) . '. ' . $step, array_keys($steps), $steps)); } /** diff --git a/src/Operations/DevToolsOperations.php b/src/Operations/DevToolsOperations.php index b6fcc1c..4d624e2 100644 --- a/src/Operations/DevToolsOperations.php +++ b/src/Operations/DevToolsOperations.php @@ -129,7 +129,7 @@ public function operations(): array 'fields' => ['type' => 'string', 'description' => 'Comma-separated `name:type` fields, named in English; prefix the name with `?` for nullable. E.g. «:string, ?:date, :bool». Scalar types: string, text, int, bigint, bool, float, decimal, date, datetime, json. «enum:(case1,case2,…)» GENERATES the enum with those cases (e.g. «:enum:(,,…)») — always declare the cases so no enum is left dangling. «belongsTo:» creates a relation only for entity with --flavor=legacy; runtime resource degrades it to _id:int and names it in the postconditions; runtime entity and crud must receive the scalar id directly (e.g. «_id:int»). Scalar modifiers: length («:string:120») or decimal precision («:decimal:10,2»). There is NO «default» and NO «:nullable» — nullability is the `?`'], 'route' => ['type' => 'string', 'description' => 'Base route, for page, controller and crud: a literal path such as /, no parameters. Refused where a screen is already mounted'], 'returns' => ['type' => 'string', 'enum' => ['page', 'data'], 'description' => 'Required for controller: what its GET route returns. page: a page a visitor reads — no controller is written, the answer is what=page; data: an API'], - 'entity' => ['type' => 'string', 'description' => 'For page: the entity whose public rows it lists, e.g. . With fields it is scaffolded; without, it must exist and declare PUBLIC_WHEN. For operation: the entity of this plugin whose repository its run() receives'], + 'entity' => ['type' => 'string', 'description' => 'For page: the entity whose public rows it lists, e.g. . With fields it is scaffolded; without, it must exist and declare PUBLIC_WHEN. For operation: the entity of this plugin whose repository its run() receives — where its state lives. Left out, a plugin\'s only entity is the one; with several, name it'], 'columns' => ['type' => 'string', 'description' => 'For page: comma-separated fields it shows, in order — the first is the title, the second the body. Default with fields: every field that is not a bool'], 'methods' => ['type' => 'string', 'description' => 'Comma-separated methods, for controller'], 'table' => ['type' => 'string', 'description' => 'Table name, for entity, crud and resource'], diff --git a/src/Operations/ImplementHandler.php b/src/Operations/ImplementHandler.php index 14ecc5e..c1e97ae 100644 --- a/src/Operations/ImplementHandler.php +++ b/src/Operations/ImplementHandler.php @@ -535,6 +535,45 @@ private function land(string $root, string $file, string $plugin, string $class, ]; } + // ── Collaborators: what an operation's run() works through is handed the way a call hands it ── + // + // Measured on a real resident's run (greenhouse evidence/1154): an operation scaffolded with no entity + // and written against `RepositoryInterface` passed every judge above and answered «is not registered in + // the container» at its first call. The house resolves what `run()` takes through the entry that lists + // the operation in its plugin; that act, in a booted copy and without running `run()`, is the judge. + $asked = $built['operation'] ?? null; + if ($asked !== null && !$asked['handed']) { + $stable = hash_file('sha256', $file) === hash('sha256', $content); + $restored = $this->publishAtomically($file, $previous) + && hash_file('sha256', $file) === hash('sha256', $previous); + $marker = '/Plugins/' . $plugin . '/'; + $pluginDir = substr($file, 0, (int) strpos($file, $marker)) . rtrim($marker, '/'); + + return [ + 'ok' => false, + 'error' => ConstructionProbe::unhanded($pluginDir, $plugin, $class, $expected . '\\' . $class, $asked['name'], $asked['unhanded']), + 'diagnostic' => [ + 'schema' => 'milpa.authoring-diagnostic/v1', + 'phase' => 'collaborators', + 'subject' => substr($file, \strlen($root) + 1), + 'submitted_sha256' => $submitted, + 'judged_sha256' => hash('sha256', $content), + 'restored_sha256' => hash('sha256', $previous), + 'stable_subject' => $stable, + 'rolled_back' => $restored, + 'result' => ['operation' => $asked['name'], 'unhanded' => $asked['unhanded']], + ], + ]; + } + if ($asked !== null) { + $constructionNote .= ', collaborators (' . $asked['name'] . ': run() is handed what it works through)'; + } elseif (!isset($built['unjudged']) && str_contains($content, '#[Operation(')) { + // A silent gap reads as covered: an operation the booted house does not offer — its plugin is not + // registered yet, or does not list it — cannot be asked, and that is said. + $constructionNote .= '; what run() works through unjudged — no operation the booted house offers is this class' + . ' (its plugin is not registered, or does not list it)'; + } + $verdictNote = '; behavior unjudged — no test declares what this class must do'; $testFile = $this->testFor($root, $plugin, $class); if ($testFile !== null) { diff --git a/tests/Make/OperationGeneratorTest.php b/tests/Make/OperationGeneratorTest.php index 91c8142..d996bbe 100644 --- a/tests/Make/OperationGeneratorTest.php +++ b/tests/Make/OperationGeneratorTest.php @@ -206,6 +206,97 @@ public function testAnOperationOverAnEntityReceivesTheRepositoryOfThatEntity(): self::assertStringContainsString('the repository of Herramienta', (string) $result->guidance); } + /** + * WHERE ITS STATE LIVES IS NOT SOMETHING TO REMEMBER TO SAY (greenhouse evidence/1154). A real resident + * scaffolded four operations of a plugin with one entity, named the entity in none, and wrote `run()` against + * the repository: the house landed them green and the first call answered «RepositoryInterface is not + * registered in the container». A plugin's only entity is the one its operations work over — the house says + * the same of where their state lives (decisions/0588) — so the scaffold hands it without being asked. + */ + public function testWithNoEntityNamedThePluginsOnlyEntityIsTheOneRunReceives(): void + { + $this->write((new EntityGenerator())->generate(new GenerationContext($this->plugin, 'Herramienta', ['flavor' => 'runtime', 'fields' => 'nombre:string'], $this->root))); + + $result = $this->make('RegistrarHerramienta', ['operation' => 'herramientas:registrar', 'fields' => 'nombre:string']); + + $operation = $this->fileNamed($result->files, 'RegistrarHerramienta.php'); + self::assertStringContainsString('public function run(RepositoryInterface $herramientas): array', $operation->contents); + self::assertStringContainsString('@param RepositoryInterface $herramientas', $operation->contents); + self::assertStringContainsString( + "\\App\\Plugins\\{$this->plugin}\\Entities\\Herramienta::class . 'Repository'", + $this->fileNamed($result->files, $this->plugin . '.php')->contents, + 'and the entry that lists it hands that repository', + ); + self::assertStringContainsString('Its run() receives the repository of Herramienta — the only entity of this plugin.', (string) $result->guidance); + } + + /** + * The entity's own scaffold said how to reach its repository with the container's id — and `run()` has no + * container to ask. It says the way an operation has, too. + */ + public function testTheEntitysScaffoldSaysHowAnOperationReachesItsRepository(): void + { + $plugin = (new \Milpa\DevTools\Make\Generators\PluginGenerator())->generate(new GenerationContext($this->plugin, $this->plugin, ['flavor' => 'runtime'], $this->root)); + $this->write($plugin); + + $result = (new EntityGenerator())->generate(new GenerationContext($this->plugin, 'Herramienta', ['flavor' => 'runtime', 'fields' => 'nombre:string'], $this->root)); + + self::assertStringContainsString('Resolve the repository later via', (string) $result->guidance); + self::assertStringContainsString('An operation of this plugin does not ask the container for it: its run() receives it', (string) $result->guidance); + } + + /** A read works over the same store: it receives it too. */ + public function testAReadOfAPluginWithOneEntityReceivesItToo(): void + { + $this->write((new EntityGenerator())->generate(new GenerationContext($this->plugin, 'Herramienta', ['flavor' => 'runtime', 'fields' => 'nombre:string'], $this->root))); + + $result = $this->make('HerramientasDisponibles', ['operation' => 'herramientas:disponibles', 'reads' => '1']); + + self::assertStringContainsString('public function run(RepositoryInterface $herramientas): array', $this->fileNamed($result->files, 'HerramientasDisponibles.php')->contents); + } + + /** What lives beside the entities and is not one — a value object, an enum — does not make them several. */ + public function testAClassBesideTheEntitiesThatIsNotOneIsNotCounted(): void + { + $this->write((new EntityGenerator())->generate(new GenerationContext($this->plugin, 'Herramienta', ['flavor' => 'runtime', 'fields' => 'nombre:string'], $this->root))); + file_put_contents( + "{$this->root}/src/Plugins/{$this->plugin}/Entities/Estado.php", + "plugin}\\Entities;\n\nenum Estado: string\n{\n case Disponible = 'disponible';\n}\n", + ); + + $result = $this->make('RegistrarHerramienta', ['operation' => 'herramientas:registrar']); + + self::assertStringContainsString('public function run(RepositoryInterface $herramientas): array', $this->fileNamed($result->files, 'RegistrarHerramienta.php')->contents); + self::assertStringContainsString('the only entity of this plugin', (string) $result->guidance); + } + + /** Which of several is a decision nobody made: none is guessed, and the scaffold says there is one to make. */ + public function testWithSeveralEntitiesNoneIsGuessedAndTheGuidanceNamesThem(): void + { + foreach (['Herramienta', 'Prestamo'] as $entity) { + $this->write((new EntityGenerator())->generate(new GenerationContext($this->plugin, $entity, ['flavor' => 'runtime', 'fields' => 'nombre:string'], $this->root))); + } + + $result = $this->make('RegistrarHerramienta', ['operation' => 'herramientas:registrar', 'fields' => 'nombre:string']); + + $operation = $this->fileNamed($result->files, 'RegistrarHerramienta.php')->contents; + self::assertStringContainsString('public function run(): array', $operation); + self::assertStringNotContainsString('RepositoryInterface', $operation); + self::assertStringContainsString( + 'Its run() receives NO repository: this plugin has several entities (Herramienta, Prestamo) and none was named — if it stores or reads rows, scaffold it with entity=.', + (string) $result->guidance, + ); + } + + /** A plugin with no entity has no store to hand: nothing is wired, as before, and nothing is said of one. */ + public function testWithNoEntityInThePluginNothingIsWired(): void + { + $result = $this->make('PrestarHerramienta', ['operation' => 'herramientas:prestar']); + + self::assertStringContainsString('public function run(): array', $this->fileNamed($result->files, 'PrestarHerramienta.php')->contents); + self::assertStringNotContainsString('repository', (string) $result->guidance); + } + public function testThePluginResolvesThatRepositoryAndEverythingElseByItsClass(): void { $this->write((new EntityGenerator())->generate(new GenerationContext($this->plugin, 'Herramienta', ['flavor' => 'runtime', 'fields' => 'nombre:string'], $this->root))); diff --git a/tests/Operations/ImplementHandsAnOperationWhatItWorksThroughTest.php b/tests/Operations/ImplementHandsAnOperationWhatItWorksThroughTest.php new file mode 100644 index 0000000..873407b --- /dev/null +++ b/tests/Operations/ImplementHandsAnOperationWhatItWorksThroughTest.php @@ -0,0 +1,331 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/devtools + */ + +declare(strict_types=1); + +namespace Milpa\DevTools\Tests\Operations; + +use Milpa\Command\Declaration\DeclaredOperation; +use Milpa\DevTools\Operations\ConstructionProbe; +use Milpa\DevTools\Operations\ImplementHandler; +use Milpa\DevTools\Support\RootResolver; +use PHPUnit\Framework\TestCase; + +/** + * What an operation's `run()` works through, the house hands it — judged when the code lands, not at its first call + * (greenhouse evidence/1154; the rule of decisions/0541, for what the catalogue offers). + * + * A real resident scaffolded an operation naming no entity and wrote its `run()` against + * `Milpa\Data\RepositoryInterface`. Every judge passed — syntax, namespace, static conformance — and the first call + * answered «Service "Milpa\Data\RepositoryInterface" is not registered in the container». The house resolves what + * `run()` takes through the entry that lists the operation in its plugin, and that entry resolved by class; a + * repository has no class to be found by. The house's own act, in a booted copy, is the judge here too. + */ +final class ImplementHandsAnOperationWhatItWorksThroughTest extends TestCase +{ + private const BY_CLASS = 'fn (string $type): object => $this->container->get($type)'; + private const BY_ENTITY = "fn (string \$type): object => \$type === \\Milpa\\Data\\RepositoryInterface::class ? \$this->container->get(\\App\\Plugins\\Bodega\\Entities\\Caja::class . 'Repository') : \$this->container->get(\$type)"; + + private string $root; + + protected function setUp(): void + { + $this->root = sys_get_temp_dir() . '/milpa-handed-' . bin2hex(random_bytes(4)); + foreach (['vendor', 'config', 'src/Plugins/Bodega/Entities', 'src/Plugins/Bodega/Operations', 'src/Plugins/Bodega/Services'] as $dir) { + mkdir($this->root . '/' . $dir, 0o775, true); + } + $package = \dirname(__DIR__, 2); + file_put_contents($this->root . '/vendor/autoload.php', "addPsr4('App\\\\', __DIR__ . '/../src/', true);\n\nreturn \$loader;\n"); + file_put_contents($this->root . '/config/app.php', " ['debug' => false]];\n"); + file_put_contents($this->root . '/config/boot.php', " new \\Milpa\\Container\\DIContainer(), " + . "'plugins' => [\\App\\Plugins\\Bodega\\Bodega::class]];\n"); + file_put_contents($this->root . '/src/Plugins/Bodega/Entities/Caja.php', <<<'PHP' + id; + } + + public function toArray(): array + { + return ['id' => $this->id, 'etiqueta' => $this->etiqueta]; + } + + public static function fromArray(array $row): static + { + return new self($row['id'] ?? null, $row['etiqueta']); + } + } + PHP); + file_put_contents($this->root . '/src/Plugins/Bodega/Services/Etiquetas.php', "plugin(self::BY_CLASS); + file_put_contents($this->operationFile(), $this->operation('', '', "return ['ok' => false, 'error' => 'not implemented'];")); + } + + protected function tearDown(): void + { + exec('rm -rf ' . escapeshellarg($this->root)); + } + + /** + * THE RUN OF EVIDENCE/1154: scaffolded with no entity, written against the repository. It is refused before it + * lands, saying what `run()` asked for, what the house answered, why, and the edit that makes its entry hand it + * — with the order in which the two land. The scaffold survives byte for byte. + */ + public function testARunThatAsksForARepositoryItsEntryCannotHandIsRefusedWithTheWay(): void + { + $before = (string) file_get_contents($this->operationFile()); + + $r = $this->implement($this->saves()); + + self::assertFalse($r['ok'], 'an operation the house cannot run landed'); + $error = (string) $r['error']; + self::assertStringContainsString('the house cannot hand «GuardarCaja» what its run() works through', $error); + self::assertStringContainsString('«bodega:guardar»', $error); + self::assertStringContainsString('Milpa\\Data\\RepositoryInterface', $error); + self::assertStringContainsString('is not registered in the container', $error, 'what the house itself answered'); + self::assertStringNotContainsString('..', $error, 'the house\'s sentence ends once'); + self::assertStringContainsString('A repository has no class to be found by: it is reached by its entity', $error); + self::assertStringContainsString('This plugin\'s entities: Caja', $error); + // The way, exact and in order: the entry first, then this body again. + self::assertStringContainsString('edit plugin=Bodega class=Bodega', $error); + $entry = '\\Milpa\\Command\\Declaration\\DeclaredOperation::from(\\App\\Plugins\\Bodega\\Operations\\GuardarCaja::class, '; + self::assertStringContainsString('find: ' . $entry . self::BY_CLASS . '),', $error, 'the whole entry: in a plugin with four operations the resolver alone is in four places'); + self::assertStringContainsString('replace: ' . $entry . self::BY_ENTITY . '),', $error); + self::assertMatchesRegularExpression('/1\..*edit plugin=Bodega.*2\..*implement plugin=Bodega class=GuardarCaja/s', $error); + self::assertSame($before, (string) file_get_contents($this->operationFile()), 'the refused body stayed on disk'); + self::assertSame('collaborators', $r['diagnostic']['phase'] ?? null); + self::assertTrue($r['diagnostic']['rolled_back'] ?? false); + self::assertSame('bodega:guardar', $r['diagnostic']['result']['operation'] ?? null); + self::assertSame('Milpa\\Data\\RepositoryInterface', $r['diagnostic']['result']['unhanded'][0]['type'] ?? null); + } + + public function testTheSameBodyLandsOnceItsEntryNamesTheEntityAndItSavesAndReads(): void + { + $this->plugin(self::BY_ENTITY); + + $r = $this->implement($this->saves()); + + self::assertTrue($r['ok'], (string) ($r['error'] ?? '')); + self::assertStringContainsString('run() is handed what it works through', (string) $r['verified']); + // And it is not only resolvable: called the way the house calls it, it stores a row and reads it back. + exec(\PHP_BINARY . ' -r ' . escapeshellarg( + 'require ' . var_export($this->root . '/vendor/autoload.php', true) . '; $c = new \Milpa\Container\DIContainer();' + . ' $p = new \App\Plugins\Bodega\Bodega($c); $p->boot(); $op = $p->operations()[0];' + . ' ($op->handler)(["etiqueta" => "Tornillos"]); echo json_encode(($op->handler)(["etiqueta" => "Clavos"]));', + ) . ' 2>&1', $out, $exit); + self::assertSame(0, $exit, implode("\n", $out)); + self::assertSame(['ok' => true, 'cajas' => 2], json_decode(implode("\n", $out), true)); + } + + /** With several entities the house names them and guesses none: the edit is written for the one that is said. */ + public function testWithSeveralEntitiesItNamesThemAndWritesNoEditForAGuess(): void + { + copy($this->root . '/src/Plugins/Bodega/Entities/Caja.php', $this->root . '/src/Plugins/Bodega/Entities/Estante.php'); + file_put_contents($this->root . '/src/Plugins/Bodega/Entities/Estante.php', str_replace('class Caja', 'class Estante', (string) file_get_contents($this->root . '/src/Plugins/Bodega/Entities/Estante.php'))); + + $error = (string) $this->implement($this->saves())['error']; + + self::assertStringContainsString('This plugin\'s entities: Caja, Estante', $error); + self::assertStringContainsString('::class', $error, 'the edit leaves the entity to be named'); + self::assertStringNotContainsString('Entities\\Caja::class . \'Repository\'', $error); + } + + /** Anything else nobody registered is the same defect, and its fix is the one a controller's has. */ + public function testAServiceNobodyRegisteredIsRefusedNamingItsType(): void + { + $r = $this->implement($this->operation("use App\\Plugins\\Bodega\\Services\\Etiquetas;\n", 'Etiquetas $etiquetas', "return ['ok' => true, 'etiqueta' => \$etiquetas->limpia(\$this->etiqueta)];")); + + self::assertFalse($r['ok']); + $error = (string) $r['error']; + self::assertStringContainsString('App\\Plugins\\Bodega\\Services\\Etiquetas', $error); + self::assertStringContainsString('registerService(\\App\\Plugins\\Bodega\\Services\\Etiquetas::class', $error); + self::assertStringContainsString('boot()', $error); + self::assertStringNotContainsString('reached by its entity', $error); + } + + public function testARunThatAsksForNothingLandsAndIsSaidHanded(): void + { + $r = $this->implement($this->operation('', '', "return ['ok' => true];")); + + self::assertTrue($r['ok'], (string) ($r['error'] ?? '')); + self::assertStringContainsString('run() is handed what it works through', (string) $r['verified']); + } + + /** + * A SILENT GAP READS AS COVERED. An operation whose plugin the house does not boot yet — scaffolded, and not + * registered — is in no catalogue, so nothing can be asked for it: it lands, and the result says that what its + * run() works through went unjudged, and why. + */ + public function testAnOperationTheBootedHouseDoesNotOfferLandsAndSaysItWentUnjudged(): void + { + file_put_contents($this->root . '/config/boot.php', " new \\Milpa\\Container\\DIContainer(), 'plugins' => []];\n"); + + $r = $this->implement($this->saves()); + + self::assertTrue($r['ok'], (string) ($r['error'] ?? '')); + self::assertStringContainsString('what run() works through unjudged — no operation the booted house offers is this class', (string) $r['verified']); + self::assertStringNotContainsString('is handed what it works through', (string) $r['verified']); + } + + /** A class no operation of the house is: nothing is asked of it here, and nothing is said. */ + public function testAClassThatIsNoOperationIsNotAsked(): void + { + file_put_contents($this->root . '/src/Plugins/Bodega/Services/Limpia.php', "root)))->handle(['plugin' => 'Bodega', 'class' => 'Limpia', 'content' => "operationFile(), $this->saves()); + + $asked = (new ConstructionProbe())->probe($this->root, 'App\\Plugins\\Bodega\\Operations\\GuardarCaja'); + + self::assertSame('bodega:guardar', $asked['operation']['name'] ?? null); + self::assertFalse($asked['operation']['handed'] ?? null); + self::assertSame('Milpa\\Data\\RepositoryInterface', $asked['operation']['unhanded'][0]['type'] ?? null); + self::assertStringContainsString('is not registered in the container', (string) ($asked['operation']['unhanded'][0]['error'] ?? '')); + self::assertSame([], $asked['routes'] ?? null, 'no route names it: construction is not asked'); + } + + /** + * WHAT THE PROBE READS OF `milpa/command`, PINNED. The resolver an entry hands lives only inside the handler + * `DeclaredOperation::from()` returns; the probe reaches it there. If that package changes how it closes over + * it, this goes red here — and not as a judge that silently stopped judging. + */ + public function testTheHandlerOfADeclaredOperationStillCarriesWhatTheProbeReads(): void + { + file_put_contents($this->operationFile(), $this->saves()); + require_once $this->root . '/vendor/autoload.php'; + $class = 'App\\Plugins\\Bodega\\Operations\\GuardarCaja'; + $resolve = static fn (string $type): object => new \stdClass(); + + $closed = (new \ReflectionFunction(DeclaredOperation::from($class, $resolve)->handler))->getStaticVariables(); + + self::assertSame($class, $closed['class'] ?? null); + self::assertSame(['Milpa\\Data\\RepositoryInterface'], $closed['collaborators'] ?? null); + self::assertSame($resolve, $closed['resolve'] ?? null); + } + + private function operationFile(): string + { + return $this->root . '/src/Plugins/Bodega/Operations/GuardarCaja.php'; + } + + /** The body a resident wrote: it saves a row through the repository and counts what is there. */ + private function saves(): string + { + return $this->operation( + "use App\\Plugins\\Bodega\\Entities\\Caja;\nuse Milpa\\Data\\RepositoryInterface;\n", + 'RepositoryInterface $repository', + "\$repository->save(new Caja(null, \$this->etiqueta));\n\n return ['ok' => true, 'cajas' => \\count(\$repository->all())];", + ); + } + + /** `bodega:guardar`, with `$uses` imported, `run()` taking `$parameters` and doing `$body`. */ + private function operation(string $uses, string $parameters, string $body): string + { + return " */\n public function run({$parameters}): array\n {\n {$body}\n }\n}\n"; + } + + /** The Bodega plugin: it registers the repository of Caja, and lists the operation with `$resolver`. */ + private function plugin(string $resolver): void + { + file_put_contents($this->root . '/src/Plugins/Bodega/Bodega.php', <<container->registerService( + \\App\\Plugins\\Bodega\\Entities\\Caja::class . 'Repository', + new \\Milpa\\Data\\InMemoryRepository(\\App\\Plugins\\Bodega\\Entities\\Caja::class), + ); + } + + public function install(): void + { + } + + public function uninstall(): void + { + } + + public function enable(): void + { + } + + public function disable(): void + { + } + + /** @return list<\\Milpa\\Command\\Operation> */ + public function operations(): array + { + return [ + \\Milpa\\Command\\Declaration\\DeclaredOperation::from(\\App\\Plugins\\Bodega\\Operations\\GuardarCaja::class, {$resolver}), + // {coa:operations} + ]; + } + } + PHP); + } + + /** @return array */ + private function implement(string $content): array + { + return (new ImplementHandler(new RootResolver($this->root)))->handle(['plugin' => 'Bodega', 'class' => 'GuardarCaja', 'content' => $content]); + } +}