From 6fb7e3cb6f089c832f8f576ae6025a9b8edad137 Mon Sep 17 00:00:00 2001 From: Rodrigo Vicente Date: Wed, 7 Oct 2026 20:55:06 -0600 Subject: [PATCH 1/7] =?UTF-8?q?fix:=20what=20an=20operation's=20run()=20wo?= =?UTF-8?q?rks=20through,=20the=20house=20hands=20it=20=E2=80=94=20a=20plu?= =?UTF-8?q?gin's=20only=20entity=20by=20default,=20and=20judged=20when=20t?= =?UTF-8?q?he=20code=20lands=20(greenhouse=20evidence/1154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/construction-probe.php | 41 ++- resources/skills/governed-authoring/SKILL.md | 8 +- src/Make/Generators/OperationGenerator.php | 50 ++- src/Operations/ConstructionProbe.php | 94 +++++- src/Operations/DevToolsOperations.php | 2 +- src/Operations/ImplementHandler.php | 34 ++ tests/Make/OperationGeneratorTest.php | 76 +++++ ...HandsAnOperationWhatItWorksThroughTest.php | 314 ++++++++++++++++++ 8 files changed, 604 insertions(+), 15 deletions(-) create mode 100644 tests/Operations/ImplementHandsAnOperationWhatItWorksThroughTest.php 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/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..0eed847 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'] . ' — the house answered: ' . $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..4cdcf77 100644 --- a/src/Operations/ImplementHandler.php +++ b/src/Operations/ImplementHandler.php @@ -535,6 +535,40 @@ 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)'; + } + $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..64bd900 100644 --- a/tests/Make/OperationGeneratorTest.php +++ b/tests/Make/OperationGeneratorTest.php @@ -206,6 +206,82 @@ 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); + } + + /** 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..b94d672 --- /dev/null +++ b/tests/Operations/ImplementHandsAnOperationWhatItWorksThroughTest.php @@ -0,0 +1,314 @@ + + * + * @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::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 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]); + } +} From bd98e41feaa122291db59f610a3d04b3f7910fdb Mon Sep 17 00:00:00 2001 From: Rodrigo Vicente Date: Wed, 7 Oct 2026 20:56:45 -0600 Subject: [PATCH 2/7] fix: an operation the booted house does not offer lands saying what went unjudged --- src/Operations/ImplementHandler.php | 5 +++++ ...entHandsAnOperationWhatItWorksThroughTest.php | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/Operations/ImplementHandler.php b/src/Operations/ImplementHandler.php index 4cdcf77..c1e97ae 100644 --- a/src/Operations/ImplementHandler.php +++ b/src/Operations/ImplementHandler.php @@ -567,6 +567,11 @@ private function land(string $root, string $file, string $plugin, string $class, } 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'; diff --git a/tests/Operations/ImplementHandsAnOperationWhatItWorksThroughTest.php b/tests/Operations/ImplementHandsAnOperationWhatItWorksThroughTest.php index b94d672..462f3b0 100644 --- a/tests/Operations/ImplementHandsAnOperationWhatItWorksThroughTest.php +++ b/tests/Operations/ImplementHandsAnOperationWhatItWorksThroughTest.php @@ -176,6 +176,22 @@ public function testARunThatAsksForNothingLandsAndIsSaidHanded(): void 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 { From 2cb94015e4ddefc0566f7844f4831aab3f0e7b9f Mon Sep 17 00:00:00 2001 From: Rodrigo Vicente Date: Wed, 7 Oct 2026 21:00:59 -0600 Subject: [PATCH 3/7] fix: the refusal quotes what the house answered and ends its sentence once --- src/Operations/ConstructionProbe.php | 2 +- .../ImplementHandsAnOperationWhatItWorksThroughTest.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Operations/ConstructionProbe.php b/src/Operations/ConstructionProbe.php index 0eed847..cadcefd 100644 --- a/src/Operations/ConstructionProbe.php +++ b/src/Operations/ConstructionProbe.php @@ -152,7 +152,7 @@ private static function operationIn(array $answer): array */ 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'] . ' — the house answered: ' . $u['error'], $unhanded)); + $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."; diff --git a/tests/Operations/ImplementHandsAnOperationWhatItWorksThroughTest.php b/tests/Operations/ImplementHandsAnOperationWhatItWorksThroughTest.php index 462f3b0..873407b 100644 --- a/tests/Operations/ImplementHandsAnOperationWhatItWorksThroughTest.php +++ b/tests/Operations/ImplementHandsAnOperationWhatItWorksThroughTest.php @@ -109,6 +109,7 @@ public function testARunThatAsksForARepositoryItsEntryCannotHandIsRefusedWithThe 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. From 9fd2ad964c1df67350d07d9f211549a1f799c6dc Mon Sep 17 00:00:00 2001 From: Rodrigo Vicente Date: Wed, 7 Oct 2026 21:03:43 -0600 Subject: [PATCH 4/7] fix: the entity's scaffold says how an operation reaches its repository --- src/Make/Generators/EntityGenerator.php | 10 ++++++++-- tests/Make/OperationGeneratorTest.php | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) 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/tests/Make/OperationGeneratorTest.php b/tests/Make/OperationGeneratorTest.php index 64bd900..d996bbe 100644 --- a/tests/Make/OperationGeneratorTest.php +++ b/tests/Make/OperationGeneratorTest.php @@ -230,6 +230,21 @@ public function testWithNoEntityNamedThePluginsOnlyEntityIsTheOneRunReceives(): 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 { From e40acab95336cc68b410ad14cb9cfc051d47dee2 Mon Sep 17 00:00:00 2001 From: Rodrigo Vicente Date: Wed, 7 Oct 2026 21:35:24 -0600 Subject: [PATCH 5/7] fix: the judge of an operation is scaffolded holding what the operation works through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three real residents out of three, with their operations scaffolded, spent their next fourteen to seventeen calls to the model finding out how a test gets the repository run() receives; one never found it and filled no body (greenhouse evidence/1156). The judge's scaffold was a bare TestCase. When the class judged is an operation of the plugin, its judge now builds the repository its plugin hands run() — in memory — and shows the call the house makes, with a value for each required input. It still fails on purpose. What is not an operation, or does not exist yet, gets the judge it always got, byte for byte. The generator reads the operation's source and its plugin's entry; it loads nothing. --- resources/skills/governed-authoring/SKILL.md | 6 +- src/Make/Generators/OperationGenerator.php | 37 +++ src/Make/Generators/TestGenerator.php | 170 ++++++++++ ...AnOperationHoldsWhatItWorksThroughTest.php | 298 ++++++++++++++++++ 4 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 tests/Make/Generators/TheJudgeOfAnOperationHoldsWhatItWorksThroughTest.php diff --git a/resources/skills/governed-authoring/SKILL.md b/resources/skills/governed-authoring/SKILL.md index c65f07b..1b11418 100644 --- a/resources/skills/governed-authoring/SKILL.md +++ b/resources/skills/governed-authoring/SKILL.md @@ -80,7 +80,11 @@ unjudged when no test declares what the class must do*. A body with no test is s 3. **Declare the evidence FIRST — `make what=test`.** Before you write a line of the body, scaffold the test that says what the code must *do* — the - observable criterion, the falsifier. Promote it, then write it. + observable criterion, the falsifier. Promote it, then write it. **Scaffold an operation's judge once + the operation's own scaffold is in the house**: the judge then already holds what `run()` works + through — its entity's repository, in memory — and shows the call the house makes, so you write it + from what it gives you. A judge runs over the house as it is: if it calls other operations of the + plugin, land their bodies first — a scaffold answers `ok: false`, and no judge goes green over that. 4. **Scaffold the structure — `make`, then promote.** `make what=plugin|page|controller|entity|crud|resource|service|operation|tool|test` writes real, diff --git a/src/Make/Generators/OperationGenerator.php b/src/Make/Generators/OperationGenerator.php index 7a5c459..781ee2a 100644 --- a/src/Make/Generators/OperationGenerator.php +++ b/src/Make/Generators/OperationGenerator.php @@ -70,6 +70,9 @@ final class OperationGenerator implements GeneratorInterface /** The inputs a scaffold declares, and the PHP type each one is. */ private const INPUTS = ['string' => 'string', 'text' => 'string', 'int' => 'int', 'bigint' => 'int', 'bool' => 'bool', 'float' => 'float', 'decimal' => 'float']; + /** How the sentence an unfilled scaffold answers with ends, after its class name: what «unfilled» is read by. */ + public const UNFILLED = '::run() is a scaffold — fill it with implement'; + private const COMMAND_PROVIDER = 'Milpa\\Command\\CommandProvider'; private StubLocator $stubs; @@ -283,6 +286,40 @@ private static function repositoryParameter(string $entity): string return lcfirst($entity) . 's'; } + /** + * The operations of a plugin whose body is still the scaffold's, by class name. + * + * Read from the sentence the scaffold itself answers with: the house wrote it, so the house can tell an unfilled + * operation from a filled one without calling either (greenhouse evidence/1156). + * + * @return list + */ + public static function unfilledIn(string $pluginDir): array + { + $unfilled = []; + foreach (glob($pluginDir . '/Operations/*.php') ?: [] as $file) { + $class = basename($file, '.php'); + if (str_contains((string) file_get_contents($file), $class . self::UNFILLED)) { + $unfilled[] = $class; + } + } + sort($unfilled); + + return $unfilled; + } + + /** + * The entity whose repository a plugin hands the operation's `run()`, read back from the entry {@see entry()} + * wrote — or `null` when the entry finds everything by its class. + */ + public static function entityHandedTo(string $pluginSource, string $class): ?string + { + $entry = '/(?plugin; [$judge, $target] = $this->judgeAndTarget($context->name); $path = "tests/Plugins/{$plugin}/{$judge}.php"; + $operation = $this->operation($context, $target); + if ($operation !== null) { + return new GenerationResult( + files: [new PlannedFile($path, $this->judgeOfAnOperation($plugin, $judge, $target, $operation))], + guidance: "This judge (class {$judge}) will run when {$target} lands. " + . ($operation['entity'] === null ? 'It shows' : "It already holds what {$target}::run() works through — the repository of " + . "{$operation['entity']}, in memory — and shows") + . " the call the house makes: fill it with `implement` declaring what {$target} must do. If it calls " + . "another operation of this plugin, land that one's body first: a judge runs over the house as it " + . 'is, and a scaffold answers ok: false.', + ); + } $contents = <<, optional: list, hands: list, entity: ?string, entityFqcn: ?string, repository: ?string}|null + */ + private function operation(GenerationContext $context, string $target): ?array + { + $dir = $context->root . '/src/Plugins/' . $context->plugin; + $file = $dir . '/Operations/' . $target . '.php'; + $source = is_file($file) ? (string) file_get_contents($file) : ''; + if (!str_contains($source, '#[Operation(') || preg_match('/^namespace ([^;]+);/m', $source, $namespace) !== 1) { + return null; + } + + $parameter = '/(\??[A-Za-z_\\\\][A-Za-z0-9_\\\\|]*)\s+\$(\w+)(\s*=)?/'; + $input = []; + $optional = []; + if (preg_match('/function __construct\((.*?)\)\s*\{/s', $source, $constructor) === 1) { + preg_match_all($parameter, $constructor[1], $fields, PREG_SET_ORDER); + foreach ($fields as $field) { + if (($field[3] ?? '') !== '') { + $optional[] = $field[2]; + continue; + } + $input[] = $field[2] . ': ' . self::sample($field[1]); + } + } + + $pluginFile = $dir . '/' . $context->plugin . '.php'; + $entityFqcn = is_file($pluginFile) ? OperationGenerator::entityHandedTo((string) file_get_contents($pluginFile), $target) : null; + $hands = []; + $repository = null; + if (preg_match('/function run\((.*?)\)\s*:/s', $source, $run) === 1) { + preg_match_all($parameter, $run[1], $takes, PREG_SET_ORDER); + foreach ($takes as $taken) { + if ($entityFqcn !== null && $repository === null && str_ends_with($taken[1], 'RepositoryInterface')) { + $repository = $taken[2]; + $hands[] = '$this->' . $taken[2]; + continue; + } + $hands[] = "/* {$taken[1]} \${$taken[2]} */"; + } + } + // The judge holds a repository only when run() takes one AND its plugin says of which entity. + if ($repository === null) { + $entityFqcn = null; + } + + return [ + 'fqcn' => $namespace[1] . '\\' . $target, + 'input' => $input, + 'optional' => $optional, + 'hands' => $hands, + 'entity' => $entityFqcn === null ? null : substr((string) strrchr('\\' . $entityFqcn, '\\'), 1), + 'entityFqcn' => $entityFqcn, + 'repository' => $repository, + ]; + } + + /** A value of an input's type that the call can be made with — the judge's author replaces it. */ + private static function sample(string $type): string + { + return match (ltrim($type, '?')) { + 'string' => "'…'", + 'int' => '1', + 'float' => '1.0', + 'bool' => 'true', + 'array' => '[]', + default => 'null /* ' . ltrim($type, '?') . ' */', + }; + } + + /** + * The judge of an operation: it holds what `run()` works through and shows the call the house makes. + * + * @param array{fqcn: string, input: list, optional: list, hands: list, entity: ?string, entityFqcn: ?string, repository: ?string} $operation + */ + private function judgeOfAnOperation(string $plugin, string $judge, string $target, array $operation): string + { + $uses = [$operation['fqcn'], 'PHPUnit\\Framework\\TestCase']; + $holds = ''; + $after = '$answer'; + if ($operation['repository'] !== null && $operation['entityFqcn'] !== null) { + array_push($uses, $operation['entityFqcn'], 'Milpa\\Data\\InMemoryRepository', 'Milpa\\Data\\RepositoryInterface'); + $after = "\$answer, and on what \$this->{$operation['repository']} holds afterwards"; + $holds = << + */ + private RepositoryInterface \${$operation['repository']}; + + protected function setUp(): void + { + \$this->{$operation['repository']} = new InMemoryRepository({$operation['entity']}::class); + } + + +PHP; + } + sort($uses); + $uses = implode("\n", array_map(static fn (string $class): string => "use {$class};", $uses)); + $input = implode(', ', $operation['input']); + $hands = implode(', ', $operation['hands']); + $optional = $operation['optional'] === [] ? '' : " // Left out of that call because it is optional: " . implode(', ', $operation['optional']) . ".\n"; + + return <<run({$hands}); + // +{$optional} // Assert on {$after}. + // + // Replace this with real behavior. It fails ON PURPOSE: a judge that judges nothing must + // not green-light anything — until this is real, no body of {$target} lands past the gate. + self::fail('this judge does not judge anything yet — declare what {$target} must DO'); + } +} + +PHP; + } + /** * Splits `$name` into the judge class and the production class it judges. * diff --git a/tests/Make/Generators/TheJudgeOfAnOperationHoldsWhatItWorksThroughTest.php b/tests/Make/Generators/TheJudgeOfAnOperationHoldsWhatItWorksThroughTest.php new file mode 100644 index 0000000..eddb563 --- /dev/null +++ b/tests/Make/Generators/TheJudgeOfAnOperationHoldsWhatItWorksThroughTest.php @@ -0,0 +1,298 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/devtools + */ + +declare(strict_types=1); + +namespace Milpa\DevTools\Tests\Make\Generators; + +use Milpa\DevTools\Make\GenerationContext; +use Milpa\DevTools\Make\GenerationResult; +use Milpa\DevTools\Make\Generators\EntityGenerator; +use Milpa\DevTools\Make\Generators\OperationGenerator; +use Milpa\DevTools\Make\Generators\PluginGenerator; +use Milpa\DevTools\Make\Generators\TestGenerator; +use PHPUnit\Framework\TestCase; + +/** + * WHAT THE HOUSE TELLS A BUILDER TO WRITE FIRST, IT LEAVES READY TO BE WRITTEN (greenhouse evidence/1156). + * + * The house says the judge comes before the body. Three real residents out of three, having scaffolded their + * operations, spent their next fourteen to seventeen calls to the model finding out one thing the house knew all + * along: how a test gets the repository an operation's `run()` receives. One of them never found it, and filled no + * body at all. The judge's scaffold was a bare `TestCase` that fails on purpose and holds nothing. + * + * So the judge of an operation is scaffolded holding what that operation works through, and showing the call the + * house makes. Everything here is scaffolded by the real generators, and the judge is RUN by a real phpunit: a + * scaffold that only looked right would not pass. + */ +final class TheJudgeOfAnOperationHoldsWhatItWorksThroughTest extends TestCase +{ + private static int $n = 0; + + private string $root; + private string $plugin; + + protected function setUp(): void + { + $this->root = sys_get_temp_dir() . '/milpa-devtools-judge-' . uniqid(); + mkdir($this->root, 0o775, true); + file_put_contents( + $this->root . '/composer.json', + (string) json_encode(['autoload' => ['psr-4' => ['App\\' => 'src/']]], JSON_PRETTY_PRINT), + ); + $this->plugin = 'Bodega' . (++self::$n) . 'x' . getmypid(); + } + + protected function tearDown(): void + { + exec('rm -rf ' . escapeshellarg($this->root)); + } + + public function testTheJudgeOfAnOperationBuildsTheRepositoryItsRunReceives(): void + { + $this->house(); + + $judge = $this->judge('GuardarCaja')->files[0]->contents; + + self::assertStringContainsString("use App\\Plugins\\{$this->plugin}\\Entities\\Caja;", $judge); + self::assertStringContainsString('use Milpa\\Data\\InMemoryRepository;', $judge); + self::assertStringContainsString('private RepositoryInterface $cajas;', $judge, 'named as run() names it'); + self::assertStringContainsString('$this->cajas = new InMemoryRepository(Caja::class);', $judge); + } + + public function testItShowsTheCallTheHouseMakes(): void + { + $this->house(); + + $judge = $this->judge('GuardarCaja')->files[0]->contents; + + self::assertStringContainsString("use App\\Plugins\\{$this->plugin}\\Operations\\GuardarCaja;", $judge); + self::assertStringContainsString("\$answer = (new GuardarCaja(etiqueta: '…'))->run(\$this->cajas);", $judge); + } + + /** The red is still TDD's starting position — and it is the ONLY thing wrong with the scaffold. */ + public function testTheScaffoldStillFailsOnPurposeAndForNoOtherReason(): void + { + $this->house(); + + [$exit, $said] = $this->run_($this->judge('GuardarCaja')->files[0]->contents); + + self::assertNotSame(0, $exit, $said); + self::assertStringContainsString('this judge does not judge anything yet — declare what GuardarCaja must DO', $said); + self::assertMatchesRegularExpression('/Tests: 1, Assertions: \d+, Failures: 1\./', $said, 'one failure, and no error: what it holds is built'); + } + + /** The call in the comment is not prose: taken out of its comment, the scaffolded operation answers it. */ + public function testTheCallItShowsIsOneTheScaffoldedOperationAnswers(): void + { + $this->house('etiqueta:string,cantidad:int,activa:bool,precio:float,?nota:string'); + $judge = $this->judge('GuardarCaja')->files[0]->contents; + self::assertStringContainsString("(new GuardarCaja(etiqueta: '…', cantidad: 1, activa: true, precio: 1.0))->run(\$this->cajas);", $judge); + self::assertStringContainsString('optional: nota', $judge); + + [$exit, $said] = $this->run_($this->withItsCallMade($judge)); + + self::assertSame(0, $exit, $said); + self::assertStringContainsString('OK (1 test, 2 assertions)', $said); + } + + /** An operation the plugin hands no repository gets none here either: the judge holds what run() takes, no more. */ + public function testAnOperationHandedNoRepositoryIsShownItsCallAndNothingElse(): void + { + $this->house(entities: []); + + $judge = $this->judge('GuardarCaja')->files[0]->contents; + + self::assertStringNotContainsString('InMemoryRepository', $judge); + self::assertStringNotContainsString('setUp', $judge); + self::assertStringContainsString("\$answer = (new GuardarCaja(etiqueta: '…'))->run();", $judge); + [$exit, $said] = $this->run_($this->withItsCallMade($judge)); + self::assertSame(0, $exit, $said); + } + + /** With several entities and none named, the scaffold handed run() nothing — and neither does its judge. */ + public function testWithSeveralEntitiesAndNoneNamedTheJudgeGuessesNoRepository(): void + { + $this->house(entities: ['Caja', 'Estante']); + + $judge = $this->judge('GuardarCaja')->files[0]->contents; + + self::assertStringNotContainsString('InMemoryRepository', $judge); + self::assertStringContainsString('->run();', $judge); + } + + public function testTheNamedEntityIsTheOneTheJudgeHolds(): void + { + $this->house(entities: ['Caja', 'Estante'], operation: ['entity' => 'Estante']); + + $judge = $this->judge('GuardarCaja')->files[0]->contents; + + self::assertStringContainsString('$this->estantes = new InMemoryRepository(Estante::class);', $judge); + self::assertStringNotContainsString('Caja::class', $judge); + } + + public function testTheGuidanceSaysWhatTheJudgeHoldsAndWhatItRunsOver(): void + { + $this->house(); + + $guidance = (string) $this->judge('GuardarCaja')->guidance; + + self::assertStringContainsString('This judge (class GuardarCajaTest) will run when GuardarCaja lands.', $guidance); + self::assertStringContainsString('It already holds what GuardarCaja::run() works through — the repository of Caja, in memory', $guidance); + self::assertStringContainsString('shows the call the house makes', $guidance); + self::assertStringContainsString('If it calls another operation of this plugin, land that one\'s body first', $guidance); + } + + /** A judge named with its suffix is the same judge. */ + public function testANameThatEndsInTestIsTheSameJudge(): void + { + $this->house(); + + self::assertSame( + $this->judge('GuardarCaja')->files[0]->contents, + $this->judge('GuardarCajaTest')->files[0]->contents, + ); + } + + /** Nothing changes for what is not an operation: its judge is the bare one, byte for byte. */ + public function testTheJudgeOfSomethingThatIsNotAnOperationIsWhatItWas(): void + { + $this->house(); + mkdir("{$this->root}/src/Plugins/{$this->plugin}/Services", 0o775, true); + file_put_contents( + "{$this->root}/src/Plugins/{$this->plugin}/Services/Etiquetas.php", + "plugin}\\Services;\n\nfinal class Etiquetas\n{\n public function run(): array\n {\n return [];\n }\n}\n", + ); + + $result = $this->judge('Etiquetas'); + + self::assertSame(self::bare($this->plugin, 'Etiquetas'), $result->files[0]->contents); + self::assertSame('This judge (class EtiquetasTest) will run when Etiquetas lands. Fill it with `implement` declaring what Etiquetas must do.', $result->guidance); + } + + /** Nor for a judge scaffolded before the class it judges exists. */ + public function testTheJudgeOfAClassThatDoesNotExistYetIsWhatItWas(): void + { + $this->house(); + + $result = $this->judge('ListarCajas'); + + self::assertSame(self::bare($this->plugin, 'ListarCajas'), $result->files[0]->contents); + self::assertStringContainsString('No ListarCajas exists yet', (string) $result->guidance); + } + + /** + * A plugin with its entities and one operation over them, scaffolded by the real generators in the order a + * resident scaffolds them. + * + * @param list $entities + * @param array $operation + */ + private function house(string $fields = 'etiqueta:string', array $entities = ['Caja'], array $operation = []): void + { + $this->write((new PluginGenerator())->generate(new GenerationContext($this->plugin, $this->plugin, ['flavor' => 'runtime'], $this->root))); + foreach ($entities as $entity) { + $this->write((new EntityGenerator())->generate(new GenerationContext($this->plugin, $entity, ['flavor' => 'runtime', 'fields' => 'etiqueta:string'], $this->root))); + } + $this->write((new OperationGenerator())->generate(new GenerationContext( + $this->plugin, + 'GuardarCaja', + ['flavor' => 'runtime', 'operation' => 'bodega:guardar', 'fields' => $fields] + $operation, + $this->root, + ))); + } + + private function judge(string $name): GenerationResult + { + return (new TestGenerator())->generate(new GenerationContext($this->plugin, $name, [], $this->root)); + } + + private function write(GenerationResult $result): void + { + foreach ($result->files as $file) { + $path = str_starts_with($file->path, '/') ? $file->path : $this->root . '/' . $file->path; + @mkdir(\dirname($path), 0o775, true); + file_put_contents($path, $file->contents); + } + } + + /** The scaffold with the call it shows taken out of its comment, and the scaffold's own answer asserted. */ + private function withItsCallMade(string $judge): string + { + self::assertSame(1, preg_match('/^\s*\/\/\s+(\$answer = .+;)$/m', $judge, $call), 'the scaffold shows a call'); + + return (string) preg_replace( + '/^\s*self::fail\(.*$/m', + " {$call[1]}\n self::assertFalse(\$answer['ok']);\n self::assertStringContainsString('is a scaffold', \$answer['error']);", + $judge, + ); + } + + /** + * Runs a judge with a real phpunit, over the plugin as it is on disk. + * + * @return array{0: int, 1: string} + */ + private function run_(string $judge): array + { + $file = "{$this->root}/tests/Plugins/{$this->plugin}/GuardarCajaTest.php"; + @mkdir(\dirname($file), 0o775, true); + file_put_contents($file, $judge); + $requires = "root}/src/Plugins/{$this->plugin}/{$kind}/*.php") ?: [] as $source) { + $requires .= 'require ' . var_export($source, true) . ";\n"; + } + } + file_put_contents($this->root . '/bootstrap.php', $requires); + exec( + escapeshellarg(\dirname(__DIR__, 3) . '/vendor/bin/phpunit') . ' --no-configuration --bootstrap ' + . escapeshellarg($this->root . '/bootstrap.php') . ' ' . escapeshellarg($file) . ' 2>&1', + $lines, + $exit, + ); + + return [$exit, implode("\n", $lines)]; + } + + /** The judge's scaffold as it has always been. */ + private static function bare(string $plugin, string $target): string + { + return << Date: Wed, 7 Oct 2026 21:35:25 -0600 Subject: [PATCH 6/7] fix: a red judge names the scaffolds it runs over, and the order past them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A resident wrote one judge for its plugin's whole cycle and named it after one operation. That operation's body was refused three times as «judged red by its own test» and the run ended with it still a scaffold: the judge called the plugin's other operations, which in that copy of the house were scaffolds (greenhouse evidence/1156). The refusal showed ten lines of phpunit. The house wrote those scaffolds and reads that they are unfilled from the sentence they answer with. A behavior refusal now names the unfilled operations its judge names — by class or by the operation they declare — and gives the order: fill and promote those, then send the same implement again. A judge red over no scaffold is told nothing new. --- src/Operations/ImplementHandler.php | 58 +++- ...edJudgeNamesTheScaffoldsItRunsOverTest.php | 278 ++++++++++++++++++ 2 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 tests/Operations/ARedJudgeNamesTheScaffoldsItRunsOverTest.php diff --git a/src/Operations/ImplementHandler.php b/src/Operations/ImplementHandler.php index c1e97ae..d78c807 100644 --- a/src/Operations/ImplementHandler.php +++ b/src/Operations/ImplementHandler.php @@ -14,6 +14,7 @@ namespace Milpa\DevTools\Operations; +use Milpa\DevTools\Make\Generators\OperationGenerator; use Milpa\DevTools\Support\RootResolver; /** @@ -595,10 +596,16 @@ private function land(string $root, string $file, string $plugin, string $class, static fn (string $l): bool => trim($l) !== '', )), -10)); + // A RED JUDGE IS NOT ALWAYS ABOUT THE BODY (greenhouse evidence/1156): a judge runs over the house + // as it is, and a scaffold the judge calls answers `ok: false` whatever this body does. The + // house wrote those scaffolds and can read that they are unfilled, so the refusal names them. + $scaffolds = self::scaffoldsNamedBy($root, $plugin, $class, (string) file_get_contents($testFile)); + return [ 'ok' => false, 'error' => 'refused: the class\'s own test judges this behavior red — ' - . basename($testFile, '.php') . " said:\n{$tail}", + . basename($testFile, '.php') . " said:\n{$tail}" + . self::overScaffolds($plugin, $class, basename($testFile, '.php'), $scaffolds), // This is a judgment of the transient proposal, never a positive verification. // Missing counts, timeouts and changed subjects cannot earn diagnostic credit. 'diagnostic' => [ @@ -613,6 +620,7 @@ private function land(string $root, string $file, string $plugin, string $class, 'stable_subject' => $stable, 'rolled_back' => $restored, 'result' => ['exit' => $verdictCode, ...PhpUnitSummary::counts(implode("\n", $verdictLines))], + 'scaffolds' => $scaffolds, ], ]; } @@ -687,6 +695,54 @@ private function stageAtomically(string $staging, string $content, string $live) return hash_file('sha256', $staging) === hash('sha256', $content); } + /** + * The other operations of the plugin that are still scaffolds and that the judge names — by their class, or by + * the operation they declare. The class being landed is the proposal, never its own obstacle. + * + * @return list + */ + private static function scaffoldsNamedBy(string $root, string $plugin, string $class, string $judge): array + { + $dir = $root . '/src/Plugins/' . $plugin; + $named = []; + foreach (OperationGenerator::unfilledIn($dir) as $scaffold) { + if ($scaffold === $class) { + continue; + } + $declares = preg_match( + "/#\\[Operation\\(name: '([^']+)'/", + (string) file_get_contents($dir . '/Operations/' . $scaffold . '.php'), + $name, + ) === 1 ? $name[1] : null; + if (preg_match('/(? $scaffolds + */ + private static function overScaffolds(string $plugin, string $class, string $judge, array $scaffolds): string + { + if ($scaffolds === []) { + return ''; + } + $one = \count($scaffolds) === 1; + + return "\n{$judge} names " . implode(', ', $scaffolds) . ', and in this copy of the house ' + . ($one ? 'it is still a scaffold: its run() answers' : 'they are still scaffolds: their run() answers') + . " «not implemented», and no judge goes green over that. Whatever else this body needs, in this order:\n" + . "1. implement plugin={$plugin} class={$scaffolds[0]}, and promote it" + . ($one ? '.' : '; then the same for ' . implode(', ', \array_slice($scaffolds, 1)) . '.') . "\n" + . "2. Then send this same implement plugin={$plugin} class={$class} again."; + } + /** The class's behavioral test under `tests/Plugins//`, or `null` when none declares it. */ private function testFor(string $root, string $plugin, string $class): ?string { diff --git a/tests/Operations/ARedJudgeNamesTheScaffoldsItRunsOverTest.php b/tests/Operations/ARedJudgeNamesTheScaffoldsItRunsOverTest.php new file mode 100644 index 0000000..a030e59 --- /dev/null +++ b/tests/Operations/ARedJudgeNamesTheScaffoldsItRunsOverTest.php @@ -0,0 +1,278 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/devtools + */ + +declare(strict_types=1); + +namespace Milpa\DevTools\Tests\Operations; + +use Milpa\DevTools\Make\GenerationContext; +use Milpa\DevTools\Make\GenerationResult; +use Milpa\DevTools\Make\Generators\EntityGenerator; +use Milpa\DevTools\Make\Generators\OperationGenerator; +use Milpa\DevTools\Make\Generators\PluginGenerator; +use Milpa\DevTools\Operations\ImplementHandler; +use Milpa\DevTools\Support\RootResolver; +use PHPUnit\Framework\TestCase; + +/** + * A REFUSAL NAMES ITS CAUSE WHEN THE HOUSE KNOWS IT (greenhouse evidence/1156). + * + * A real resident wrote one judge for the whole cycle of its plugin — register, lend, take back — and named it + * after one operation. That operation's body was refused three times in a row as «judged red by its own test», + * and the run ended with it still a scaffold. The body was never the reason: the judge called the plugin's other + * operations, which in that copy of the house were still scaffolds, and a scaffold answers `ok: false`. The house + * wrote those scaffolds and could read that they were unfilled; the refusal showed ten lines of phpunit. + * + * Everything here is scaffolded by the real generators and judged by a real phpunit. + */ +final class ARedJudgeNamesTheScaffoldsItRunsOverTest extends TestCase +{ + private static int $n = 0; + + private string $root; + private string $plugin; + + protected function setUp(): void + { + $this->root = sys_get_temp_dir() . '/milpa-devtools-scaffolds-' . uniqid(); + mkdir($this->root, 0o775, true); + file_put_contents( + $this->root . '/composer.json', + (string) json_encode(['autoload' => ['psr-4' => ['App\\' => 'src/']]], JSON_PRETTY_PRINT), + ); + $this->plugin = 'Bodega' . (++self::$n) . 'y' . getmypid(); + $this->write((new PluginGenerator())->generate($this->context($this->plugin))); + $this->write((new EntityGenerator())->generate($this->context('Caja', ['fields' => 'etiqueta:string']))); + $this->scaffold('GuardarCaja', ['operation' => 'bodega:guardar', 'fields' => 'etiqueta:string']); + $this->scaffold('ListarCajas', ['operation' => 'bodega:listar', 'reads' => true]); + $this->judge("\$guardada = (new GuardarCaja(etiqueta: 'Tornillos'))->run(\$this->cajas);\n self::assertTrue(\$guardada['ok']);"); + } + + protected function tearDown(): void + { + exec('rm -rf ' . escapeshellarg($this->root)); + } + + public function testARedJudgeNamesTheScaffoldItRunsOverAndTheOrderThatGetsPastIt(): void + { + $before = (string) file_get_contents($this->operation('ListarCajas')); + + $r = $this->handler()->handle(['plugin' => $this->plugin, 'class' => 'ListarCajas', 'content' => $this->listar()]); + + self::assertFalse($r['ok']); + self::assertSame('behavior', $r['diagnostic']['phase']); + self::assertSame(['GuardarCaja'], $r['diagnostic']['scaffolds']); + self::assertStringContainsString('ListarCajasTest names GuardarCaja, and in this copy of the house it is still a scaffold', $r['error']); + self::assertStringContainsString('its run() answers «not implemented»', $r['error']); + self::assertStringContainsString("1. implement plugin={$this->plugin} class=GuardarCaja, and promote it.", $r['error']); + self::assertStringContainsString("2. Then send this same implement plugin={$this->plugin} class=ListarCajas again.", $r['error']); + self::assertSame($before, (string) file_get_contents($this->operation('ListarCajas')), 'and the scaffold is back, byte for byte'); + } + + /** What phpunit said still travels: the note is added to it, it does not replace it. */ + public function testWhatTheJudgeSaidStillTravels(): void + { + $r = $this->handler()->handle(['plugin' => $this->plugin, 'class' => 'ListarCajas', 'content' => $this->listar()]); + + self::assertStringContainsString("refused: the class's own test judges this behavior red — ListarCajasTest said:\n", $r['error']); + self::assertStringContainsString('Failed asserting that false is true.', $r['error']); + self::assertGreaterThan(strpos($r['error'], 'Failed asserting'), strpos($r['error'], 'ListarCajasTest names GuardarCaja'), 'the cause closes the refusal'); + } + + public function testFollowedInThatOrderTheSameBodyLands(): void + { + file_put_contents($this->operation('GuardarCaja'), $this->guardar()); + + $r = $this->handler()->handle(['plugin' => $this->plugin, 'class' => 'ListarCajas', 'content' => $this->listar()]); + + self::assertTrue($r['ok'], json_encode($r, JSON_UNESCAPED_UNICODE) ?: ''); + self::assertStringContainsString('behavior (ListarCajasTest green)', $r['verified']); + } + + /** A judge red over no scaffold is red for what the body does: nothing is said of scaffolds. */ + public function testARedJudgeOverNoScaffoldSaysNothingOfScaffolds(): void + { + file_put_contents($this->operation('GuardarCaja'), $this->guardar()); + + $r = $this->handler()->handle(['plugin' => $this->plugin, 'class' => 'ListarCajas', + 'content' => str_replace('$cajas->all()', '[]', $this->listar())]); + + self::assertFalse($r['ok']); + self::assertSame('behavior', $r['diagnostic']['phase']); + self::assertSame([], $r['diagnostic']['scaffolds']); + self::assertStringNotContainsString('ListarCajasTest names', $r['error']); + self::assertStringNotContainsString('in this order', $r['error']); + self::assertStringEndsNotWith("\n", $r['error']); + } + + /** A scaffold the judge does not name is nobody's obstacle here. */ + public function testAScaffoldTheJudgeDoesNotNameIsNotBlamed(): void + { + $this->scaffold('BorrarCaja', ['operation' => 'bodega:borrar', 'fields' => 'id:int']); + + $r = $this->handler()->handle(['plugin' => $this->plugin, 'class' => 'ListarCajas', 'content' => $this->listar()]); + + self::assertSame(['GuardarCaja'], $r['diagnostic']['scaffolds']); + self::assertStringNotContainsString('BorrarCaja', $r['error']); + } + + /** With several, each is named, and the order fills them one at a time. */ + public function testSeveralScaffoldsAreNamedEachWithItsCall(): void + { + $this->scaffold('BorrarCaja', ['operation' => 'bodega:borrar', 'fields' => 'id:int']); + $this->judge("\$guardada = (new GuardarCaja(etiqueta: 'Tornillos'))->run(\$this->cajas);\n self::assertTrue(\$guardada['ok']);\n" + . " self::assertTrue((new \\App\\Plugins\\{$this->plugin}\\Operations\\BorrarCaja(id: 99))->run(\$this->cajas)['ok']);"); + + $r = $this->handler()->handle(['plugin' => $this->plugin, 'class' => 'ListarCajas', 'content' => $this->listar()]); + + self::assertSame(['BorrarCaja', 'GuardarCaja'], $r['diagnostic']['scaffolds']); + self::assertStringContainsString('ListarCajasTest names BorrarCaja, GuardarCaja, and in this copy of the house they are still scaffolds', $r['error']); + self::assertStringContainsString("1. implement plugin={$this->plugin} class=BorrarCaja, and promote it; then the same for GuardarCaja.", $r['error']); + } + + /** A judge reaches an operation by the name it declares too. */ + public function testAScaffoldNamedByTheOperationItDeclaresIsNamedToo(): void + { + $this->judge("self::assertSame([], \$this->cajas->all(), 'nothing until bodega:guardar has run');\n self::fail('bodega:guardar is not callable here yet');"); + + $r = $this->handler()->handle(['plugin' => $this->plugin, 'class' => 'ListarCajas', 'content' => $this->listar()]); + + self::assertSame(['GuardarCaja'], $r['diagnostic']['scaffolds']); + } + + /** The class being landed is the proposal: it is never its own obstacle, whatever its text still says. */ + public function testTheClassBeingLandedIsNeverNamedAsItsOwnObstacle(): void + { + file_put_contents($this->operation('GuardarCaja'), $this->guardar()); + + $r = $this->handler()->handle(['plugin' => $this->plugin, 'class' => 'ListarCajas', 'content' => str_replace( + "return ['ok' => true, 'cajas' => \\count(\$cajas->all())];", + "return ['ok' => false, 'error' => 'not implemented: ListarCajas::run() is a scaffold — fill it with implement'];", + $this->listar(), + )]); + + self::assertFalse($r['ok']); + self::assertSame([], $r['diagnostic']['scaffolds']); + } + + /** The house reads which operations are unfilled from the sentence its own scaffold answers with. */ + public function testTheScaffoldStillCarriesTheSentenceTheHouseReadsItBy(): void + { + $dir = "{$this->root}/src/Plugins/{$this->plugin}"; + + self::assertSame(['GuardarCaja', 'ListarCajas'], OperationGenerator::unfilledIn($dir)); + self::assertStringContainsString('GuardarCaja' . OperationGenerator::UNFILLED, (string) file_get_contents($this->operation('GuardarCaja'))); + + file_put_contents($this->operation('GuardarCaja'), $this->guardar()); + + self::assertSame(['ListarCajas'], OperationGenerator::unfilledIn($dir)); + self::assertSame([], OperationGenerator::unfilledIn($dir . '/nowhere')); + } + + /** @param array $options */ + private function context(string $name, array $options = []): GenerationContext + { + return new GenerationContext($this->plugin, $name, ['flavor' => 'runtime'] + $options, $this->root); + } + + /** @param array $options */ + private function scaffold(string $name, array $options): void + { + $this->write((new OperationGenerator())->generate($this->context($name, $options))); + } + + private function write(GenerationResult $result): void + { + foreach ($result->files as $file) { + $path = str_starts_with($file->path, '/') ? $file->path : $this->root . '/' . $file->path; + @mkdir(\dirname($path), 0o775, true); + file_put_contents($path, $file->contents); + } + } + + private function operation(string $class): string + { + return "{$this->root}/src/Plugins/{$this->plugin}/Operations/{$class}.php"; + } + + /** The judge of ListarCajas: it stores a row first — through another operation — and then lists. */ + private function judge(string $arrange): void + { + @mkdir("{$this->root}/tests/Plugins/{$this->plugin}", 0o775, true); + file_put_contents("{$this->root}/tests/Plugins/{$this->plugin}/ListarCajasTest.php", <<plugin}; + +use App\\Plugins\\{$this->plugin}\\Entities\\Caja; +use App\\Plugins\\{$this->plugin}\\Operations\\GuardarCaja; +use App\\Plugins\\{$this->plugin}\\Operations\\ListarCajas; +use Milpa\\Data\\InMemoryRepository; +use Milpa\\Data\\RepositoryInterface; +use PHPUnit\\Framework\\TestCase; + +final class ListarCajasTest extends TestCase +{ + private RepositoryInterface \$cajas; + + protected function setUp(): void + { + \$this->cajas = new InMemoryRepository(Caja::class); + } + + public function testItCountsWhatWasStored(): void + { + {$arrange} + self::assertSame(1, (new ListarCajas())->run(\$this->cajas)['cajas']); + } +} + +PHP); + } + + /** A handler whose judge is a real phpunit over the plugin as it is on disk when the judge runs. */ + private function handler(): ImplementHandler + { + $requires = "root}/src/Plugins/{$this->plugin}/", true) + . " . \$kind . '/*.php') ?: [] as \$source) { require \$source; } }\n"; + file_put_contents($this->root . '/bootstrap.php', $requires); + $runner = $this->root . '/judge.sh'; + file_put_contents($runner, "#!/bin/sh\n" . escapeshellarg(\dirname(__DIR__, 2) . '/vendor/bin/phpunit') + . ' --no-configuration --bootstrap ' . escapeshellarg($this->root . '/bootstrap.php') . " \"\$1\" 2>&1\n"); + chmod($runner, 0o755); + + return new ImplementHandler(new RootResolver($this->root), behaviorRunner: $runner); + } + + /** The body of GuardarCaja, on its scaffold. */ + private function guardar(): string + { + return str_replace( + "return ['ok' => false, 'error' => 'not implemented: GuardarCaja::run() is a scaffold — fill it with implement'];", + "\$cajas->save(new \\App\\Plugins\\{$this->plugin}\\Entities\\Caja(null, \$this->etiqueta));\n\n return ['ok' => true];", + (string) file_get_contents($this->operation('GuardarCaja')), + ); + } + + /** The body of ListarCajas, on its scaffold: a right one. */ + private function listar(): string + { + return str_replace( + "return ['ok' => false, 'error' => 'not implemented: ListarCajas::run() is a scaffold — fill it with implement'];", + "return ['ok' => true, 'cajas' => \\count(\$cajas->all())];", + (string) file_get_contents($this->operation('ListarCajas')), + ); + } +} From 2b38f35714ba3f1e478e276abb109f0fd5ff01c4 Mon Sep 17 00:00:00 2001 From: Rodrigo Vicente Date: Wed, 7 Oct 2026 21:40:56 -0600 Subject: [PATCH 7/7] test: a second repository, a class beside the operations, and names that only contain a scaffold's Four tests written after the code, for hand mutants that survived or that I expected to: the judge holds one repository and shows a second one missing; a class under Operations/ that is not an operation gets the bare judge; a name that starts or ends like a scaffold's is another name; a filled body that quotes another class's scaffold sentence is still filled. glob() already sorts. --- src/Make/Generators/OperationGenerator.php | 1 - ...AnOperationHoldsWhatItWorksThroughTest.php | 64 ++++++++++++++++++- ...edJudgeNamesTheScaffoldsItRunsOverTest.php | 22 ++++++- 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/Make/Generators/OperationGenerator.php b/src/Make/Generators/OperationGenerator.php index 781ee2a..11e24f3 100644 --- a/src/Make/Generators/OperationGenerator.php +++ b/src/Make/Generators/OperationGenerator.php @@ -303,7 +303,6 @@ public static function unfilledIn(string $pluginDir): array $unfilled[] = $class; } } - sort($unfilled); return $unfilled; } diff --git a/tests/Make/Generators/TheJudgeOfAnOperationHoldsWhatItWorksThroughTest.php b/tests/Make/Generators/TheJudgeOfAnOperationHoldsWhatItWorksThroughTest.php index eddb563..81d2be9 100644 --- a/tests/Make/Generators/TheJudgeOfAnOperationHoldsWhatItWorksThroughTest.php +++ b/tests/Make/Generators/TheJudgeOfAnOperationHoldsWhatItWorksThroughTest.php @@ -130,9 +130,21 @@ public function testWithSeveralEntitiesAndNoneNamedTheJudgeGuessesNoRepository() self::assertStringContainsString('->run();', $judge); } + /** Read from the entry of THIS operation: another operation of the plugin, over another entity, is listed first. */ public function testTheNamedEntityIsTheOneTheJudgeHolds(): void { - $this->house(entities: ['Caja', 'Estante'], operation: ['entity' => 'Estante']); + $this->write((new PluginGenerator())->generate(new GenerationContext($this->plugin, $this->plugin, ['flavor' => 'runtime'], $this->root))); + foreach (['Caja', 'Estante'] as $entity) { + $this->write((new EntityGenerator())->generate(new GenerationContext($this->plugin, $entity, ['flavor' => 'runtime', 'fields' => 'etiqueta:string'], $this->root))); + } + foreach (['ContarCajas' => 'Caja', 'GuardarCaja' => 'Estante'] as $operation => $entity) { + $this->write((new OperationGenerator())->generate(new GenerationContext( + $this->plugin, + $operation, + ['flavor' => 'runtime', 'operation' => 'bodega:' . strtolower($operation), 'fields' => 'etiqueta:string', 'entity' => $entity], + $this->root, + ))); + } $judge = $this->judge('GuardarCaja')->files[0]->contents; @@ -140,6 +152,56 @@ public function testTheNamedEntityIsTheOneTheJudgeHolds(): void self::assertStringNotContainsString('Caja::class', $judge); } + /** + * The state a resident left by hand: `run()` written against a repository while the entry that lists the + * operation hands none. The judge holds what the house hands, so it holds nothing — and shows what is missing. + */ + public function testARepositoryTheEntryDoesNotHandIsNotHeldByTheJudge(): void + { + $this->house(entities: []); + $this->write((new EntityGenerator())->generate(new GenerationContext($this->plugin, 'Caja', ['flavor' => 'runtime', 'fields' => 'etiqueta:string'], $this->root))); + $file = "{$this->root}/src/Plugins/{$this->plugin}/Operations/GuardarCaja.php"; + file_put_contents($file, str_replace('public function run(): array', 'public function run(\\Milpa\\Data\\RepositoryInterface $cajas): array', (string) file_get_contents($file), $replaced)); + self::assertSame(1, $replaced); + + $judge = $this->judge('GuardarCaja')->files[0]->contents; + + self::assertStringNotContainsString('InMemoryRepository', $judge); + self::assertStringNotContainsString('$this->cajas', $judge); + self::assertStringContainsString("->run(/* \\Milpa\\Data\\RepositoryInterface \$cajas */);", $judge); + self::assertStringContainsString('It shows the call the house makes', (string) $this->judge('GuardarCaja')->guidance); + } + + /** + * An entry hands ONE repository: `run()` is handed by type. A second one in the signature is not held as if it + * were the same — the judge shows it is missing, as the house would find it missing. + */ + public function testASecondRepositoryInTheSignatureIsShownMissingAndNotHeld(): void + { + $this->house(); + $file = "{$this->root}/src/Plugins/{$this->plugin}/Operations/GuardarCaja.php"; + file_put_contents($file, str_replace('RepositoryInterface $cajas): array', 'RepositoryInterface $cajas, RepositoryInterface $estantes): array', (string) file_get_contents($file), $replaced)); + self::assertSame(1, $replaced); + + $judge = $this->judge('GuardarCaja')->files[0]->contents; + + self::assertStringContainsString("->run(\$this->cajas, /* RepositoryInterface \$estantes */);", $judge); + self::assertStringContainsString('private RepositoryInterface $cajas;', $judge); + self::assertStringNotContainsString('$this->estantes', $judge); + } + + /** A class that lives beside the operations and is not one gets the bare judge. */ + public function testAClassBesideTheOperationsThatIsNotOneGetsTheBareJudge(): void + { + $this->house(); + file_put_contents( + "{$this->root}/src/Plugins/{$this->plugin}/Operations/Etiquetador.php", + "plugin}\\Operations;\n\nfinal class Etiquetador\n{\n public function run(\\Milpa\\Data\\RepositoryInterface \$cajas): array\n {\n return [];\n }\n}\n", + ); + + self::assertSame(self::bare($this->plugin, 'Etiquetador'), $this->judge('Etiquetador')->files[0]->contents); + } + public function testTheGuidanceSaysWhatTheJudgeHoldsAndWhatItRunsOver(): void { $this->house(); diff --git a/tests/Operations/ARedJudgeNamesTheScaffoldsItRunsOverTest.php b/tests/Operations/ARedJudgeNamesTheScaffoldsItRunsOverTest.php index a030e59..7440ec3 100644 --- a/tests/Operations/ARedJudgeNamesTheScaffoldsItRunsOverTest.php +++ b/tests/Operations/ARedJudgeNamesTheScaffoldsItRunsOverTest.php @@ -149,6 +149,17 @@ public function testAScaffoldNamedByTheOperationItDeclaresIsNamedToo(): void self::assertSame(['GuardarCaja'], $r['diagnostic']['scaffolds']); } + /** A name that only contains a scaffold's name is another name. */ + public function testANameThatOnlyContainsAScaffoldsNameIsNotIt(): void + { + $this->judge("self::fail('MiGuardarCaja, GuardarCajaGrande y GuardarCajas no son de este plugin');"); + + $r = $this->handler()->handle(['plugin' => $this->plugin, 'class' => 'ListarCajas', 'content' => $this->listar()]); + + self::assertFalse($r['ok']); + self::assertSame([], $r['diagnostic']['scaffolds']); + } + /** The class being landed is the proposal: it is never its own obstacle, whatever its text still says. */ public function testTheClassBeingLandedIsNeverNamedAsItsOwnObstacle(): void { @@ -172,7 +183,12 @@ public function testTheScaffoldStillCarriesTheSentenceTheHouseReadsItBy(): void self::assertSame(['GuardarCaja', 'ListarCajas'], OperationGenerator::unfilledIn($dir)); self::assertStringContainsString('GuardarCaja' . OperationGenerator::UNFILLED, (string) file_get_contents($this->operation('GuardarCaja'))); - file_put_contents($this->operation('GuardarCaja'), $this->guardar()); + // Filled — and a filled body that quotes ANOTHER class's sentence is still filled. + file_put_contents($this->operation('GuardarCaja'), str_replace( + "return ['ok' => true];", + "// ListarCajas::run() is a scaffold — fill it with implement\n return ['ok' => true];", + $this->guardar(), + )); self::assertSame(['ListarCajas'], OperationGenerator::unfilledIn($dir)); self::assertSame([], OperationGenerator::unfilledIn($dir . '/nowhere')); @@ -207,6 +223,7 @@ private function operation(string $class): string /** The judge of ListarCajas: it stores a row first — through another operation — and then lists. */ private function judge(string $arrange): void { + $uses = preg_match('/(?plugin}\\Operations\\GuardarCaja;\n" : ''; @mkdir("{$this->root}/tests/Plugins/{$this->plugin}", 0o775, true); file_put_contents("{$this->root}/tests/Plugins/{$this->plugin}/ListarCajasTest.php", <<plugin}; use App\\Plugins\\{$this->plugin}\\Entities\\Caja; -use App\\Plugins\\{$this->plugin}\\Operations\\GuardarCaja; -use App\\Plugins\\{$this->plugin}\\Operations\\ListarCajas; +{$uses}use App\\Plugins\\{$this->plugin}\\Operations\\ListarCajas; use Milpa\\Data\\InMemoryRepository; use Milpa\\Data\\RepositoryInterface; use PHPUnit\\Framework\\TestCase;