From 6fb7e3cb6f089c832f8f576ae6025a9b8edad137 Mon Sep 17 00:00:00 2001 From: Rodrigo Vicente Date: Wed, 7 Oct 2026 20:55:06 -0600 Subject: [PATCH 1/9] =?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/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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; From 7922dbb62594dc27446268bcfe8d1f3775534be1 Mon Sep 17 00:00:00 2001 From: Rodrigo Vicente Date: Thu, 8 Oct 2026 10:37:20 -0600 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20a=20landing=20refusal=20carries=20th?= =?UTF-8?q?e=20text=20the=20next=20call=20needs=20=E2=80=94=20the=20builde?= =?UTF-8?q?r's=20own=20lines,=20where=20an=20edit=20stops=20matching,=20th?= =?UTF-8?q?e=20lines=20an=20ambiguous=20text=20sits=20on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three refusals are about the text itself, and each said something was wrong without the words to put it right (greenhouse evidence/1162). In runs with a real resident a body refused for syntax was answered with one line number — beside the house's hint to repair the proposal with exact find and replace pairs. In one run the edit of that proposal did not find its text, the next edit did not either, and no body landed: 1,240 of the run's 1,414 seconds. In another the resident sent the whole file again each time: it landed, about three minutes later each time. - Syntax: the refusal shows the proposal's own lines where the parser stopped — that line, the three before it and the one after, and the line a bracket was opened on when the parser names one. The parser stops after the mistake, not on it. After the gutter each line is the proposal's, byte for byte: it is what a find is copied from. - An edit whose text is not there: where it stops matching — the line, how the text reads there, how the find reads there — and the text's own lines over that stretch, handed over as the find that matches, only when they sit in the text once. When only spacing parts the two, it says so. A later pair is told it was judged against the text the earlier ones left. - An edit whose text is there more than once: the lines it sits on. - A body the parser reads and the compile check refuses — a void function that returns a value, a method declared twice: the same lines around the line the check names. A resident met that door on a house that carried the rest of this: 300 characters, a line number, no text. Nothing is taken away: the first sentence of each, the whole text, and the finding the diagnostic carries are what they were. And an answer costs what the stretch it speaks of costs, not what the file does: two tests time it on a long file. --- src/Operations/EditPairs.php | 105 ++++- src/Operations/ImplementHandler.php | 8 +- src/Operations/SyntaxFinding.php | 66 +++ ...RefusalCarriesWhatTheNextCallNeedsTest.php | 410 ++++++++++++++++++ 4 files changed, 585 insertions(+), 4 deletions(-) create mode 100644 tests/Operations/ALandingRefusalCarriesWhatTheNextCallNeedsTest.php diff --git a/src/Operations/EditPairs.php b/src/Operations/EditPairs.php index db352ef..348d1f9 100644 --- a/src/Operations/EditPairs.php +++ b/src/Operations/EditPairs.php @@ -27,17 +27,118 @@ public static function apply(string $content, array $edits, string $sourceLabel return ['ok' => false, 'error' => 'edit #' . ($i + 1) . ' has an empty `find`']; } $times = substr_count($content, $find); + // WHAT THE NEXT CALL IS WRITTEN FROM (greenhouse evidence/1162). The text a pair is judged against is the + // one the pairs before it left, and is named as that. + $label = $i === 0 ? $sourceLabel : $sourceLabel . ', with edit' . ($i === 1 ? ' #1' : 's #1–#' . $i) . ' of this call applied,'; + $where = $i === 0 ? "the {$sourceLabel}" : 'the text the earlier edits of this call left'; if ($times === 0) { return ['ok' => false, 'error' => 'edit #' . ($i + 1) . " matches nothing — this `find` does not appear in the file:\n{$find}\n" - . "The {$sourceLabel} is:\n---\n{$content}\n---\nBuild your pairs against that text exactly."]; + . self::whereItStops($content, $find, $where) + . "The {$label} is:\n---\n{$content}\n---\nBuild your pairs against that text exactly."]; } if ($times > 1) { return ['ok' => false, 'error' => 'edit #' . ($i + 1) - . " is ambiguous: `find` appears {$times} times and must appear exactly once — widen it with surrounding lines"]; + . " is ambiguous: `find` appears {$times} times and must appear exactly once — widen it with surrounding lines" + . self::whereItSits($content, $find, $where)]; } $content = str_replace($find, $replace, $content); } return ['ok' => true, 'content' => $content, 'edits_applied' => count($edits)]; } + + /** How many places of an ambiguous text are shown. */ + private const PLACES = 8; + + /** How much of a `find` has to be in the text before saying where it stops: a letter or two is in every text. */ + private const ENOUGH = 6; + + /** + * Where a `find` that is not in the text parts ways with it, and the text's own lines there. + * + * The whole text was already handed back, and a builder was left to compare the two by eye. The longest start of + * the `find` that IS in the text says where they part: the line it reaches, how the text reads there, how the + * `find` reads there. And the text's own lines over that stretch are handed over as the `find` that matches — + * only when they do, and only once: a text that sits in several places would be the next refusal. + */ + private static function whereItStops(string $content, string $find, string $where): string + { + // The longest prefix of `find` the text holds: a shorter prefix of a prefix it holds is held too. + $low = 0; + $high = \strlen($find); + while ($low < $high) { + $middle = intdiv($low + $high + 1, 2); + str_contains($content, substr($find, 0, $middle)) ? $low = $middle : $high = $middle - 1; + } + $held = substr($find, 0, $low); + // Too little of it is there to say where it stops: a letter or two is in every text. + $first = trim(explode("\n", ltrim($find, "\n"))[0]); + if (\strlen(trim($held)) < min(\strlen($first), self::ENOUGH)) { + return "Nothing of its first line is in {$where}.\n"; + } + $at = (int) strpos($content, $held); + $places = substr_count($content, $held); + $lines = explode("\n", $content); + $from = substr_count($content, "\n", 0, $at); // the line the match starts on, from 0 + $stops = substr_count($content, "\n", 0, $at + $low); // the line it stops on + $findLines = explode("\n", $find); + $findStops = substr_count($held, "\n"); + $said = "It matches {$where} up to line " . ($stops + 1) . ' and stops there' + . ($places > 1 ? " (the first of {$places} places it matches that far)" : '') . '. There the file reads:' . "\n" + . $lines[$stops] . "\nand your `find` reads:\n" . $findLines[$findStops] . "\n"; + + // The text's own lines over that stretch — or, when only spacing parts the two, over the stretch that says + // the same words. + $squash = static fn (string $text): string => trim((string) preg_replace('/\s+/', ' ', $text)); + $wanted = $squash($find); + $own = null; + $onlySpacing = false; + for ($to = $from; $to < \count($lines); ++$to) { + $candidate = $squash(implode("\n", \array_slice($lines, $from, $to - $from + 1))); + if ($candidate === $wanted) { + $own = implode("\n", \array_slice($lines, $from, $to - $from + 1)); + $onlySpacing = true; + break; + } + // A stretch that already says another word never becomes the `find` by growing: stop there. It changes + // nothing of the answer — it is what keeps its cost that of the stretch, not of the file. + if (!str_starts_with($wanted, $candidate)) { + break; + } + } + $own ??= implode("\n", \array_slice($lines, $from, \count($findLines))); + // Those lines hold the start that is in the text only once, so they are in it only once too. + if ($places > 1) { + return $said; + } + + return $said . ($onlySpacing ? 'Only its spacing differs from the file. ' : '') + . "The file's own text over those lines — as a `find` it matches exactly once:\n{$own}\n"; + } + + /** The lines an ambiguous `find` sits on, so the next one can be widened with what tells them apart. */ + private static function whereItSits(string $content, string $find, string $where): string + { + $lines = explode("\n", $content); + $numbers = []; + $line = 1; + $counted = 0; + for ($at = strpos($content, $find); $at !== false; $at = strpos($content, $find, $at + \strlen($find))) { + $line += substr_count($content, "\n", $counted, $at - $counted); // counted from the place before it + $counted = $at; + $numbers[$line] = true; // a line it is on twice is one line + } + $numbers = array_keys($numbers); + $shown = \array_slice($numbers, 0, self::PLACES); + $more = \count($numbers) - \count($shown); + $width = \strlen((string) max($shown)); + $list = match (true) { + $more > 0 => 'lines ' . implode(', ', $shown) . " and {$more} more", + \count($shown) > 1 => 'lines ' . implode(', ', \array_slice($shown, 0, -1)) . ' and ' . end($shown), + default => "line {$shown[0]}, more than once,", + }; + $rows = array_map(static fn (int $n): string => ' ' . str_pad((string) $n, $width, ' ', STR_PAD_LEFT) . ' │ ' . $lines[$n - 1], $shown); + + return ". It is on {$list} of {$where}:\n" . implode("\n", $rows); + } } diff --git a/src/Operations/ImplementHandler.php b/src/Operations/ImplementHandler.php index d78c807..22b41f1 100644 --- a/src/Operations/ImplementHandler.php +++ b/src/Operations/ImplementHandler.php @@ -422,7 +422,8 @@ private function land(string $root, string $file, string $plugin, string $class, 'ok' => false, 'error' => 'refused: syntax error in the proposal for ' . substr($file, strlen($root) + 1) . ' on line ' . $finding['line'] . ': ' . $finding['message'] - . ($unchanged ? '; destination preserved, proposal never installed' : '; destination preservation could not be verified'), + . ($unchanged ? '; destination preserved, proposal never installed' : '; destination preservation could not be verified') + . SyntaxFinding::shown($judged, $finding), 'diagnostic' => [ 'schema' => 'milpa.authoring-diagnostic/v1', 'phase' => 'syntax', @@ -442,8 +443,11 @@ private function land(string $root, string $file, string $plugin, string $class, // The diagnostic travels whole — it is what the model corrects from. The temp path // inside it would only mislead, so it is renamed to the file it was meant for. $detail = str_replace($staged, $file, implode("\n", $lines)); + // …and so do the builder's own lines where the check names one (greenhouse evidence/1162). + $named = SyntaxFinding::lineNamedIn($detail); - return ['ok' => false, 'error' => "refused: syntax or compilation check failed (exit {$code}):\n{$detail}"]; + return ['ok' => false, 'error' => "refused: syntax or compilation check failed (exit {$code}):\n{$detail}" + . ($named === null ? '' : SyntaxFinding::shownAt($judged, $named))]; } } finally { @unlink($staged); diff --git a/src/Operations/SyntaxFinding.php b/src/Operations/SyntaxFinding.php index c611978..17c1aab 100644 --- a/src/Operations/SyntaxFinding.php +++ b/src/Operations/SyntaxFinding.php @@ -35,4 +35,70 @@ public static function inspect(string $content): ?array } return null; } + + /** How much of one line is shown: a generated line can be thousands of characters long. */ + private const SHOWN = 240; + + /** + * The builder's own lines where the parser stopped, to close the refusal with (greenhouse evidence/1162). + * + * A line number alone sends a builder back to a text it no longer has in front of it — and the house, beside + * the refusal, tells it to repair that text with exact find and replace pairs. The line the parser names is + * where it gave up, which is rarely the line of the mistake: a missing semicolon is reported two lines down. So + * the lines before it travel too, and so does the line a bracket was opened on when the parser names one. After + * the gutter each line is the proposal's, byte for byte: it is what a `find` is copied from. + * + * @param array{message: string, line: int} $finding + */ + public static function shown(string $content, array $finding): string + { + $opened = preg_match("/^Unclosed '.' on line ([1-9][0-9]*)/", $finding['message'], $named) === 1 ? (int) $named[1] : null; + + return self::lines($content, $finding['line'], $opened, "The parser stopped at line {$finding['line']}: what it could not read is on that line or just before it."); + } + + /** + * The same lines for the check that runs after the parser, to close its refusal with. + * + * A body the parser reads can still fail to compile — a method declared twice, a void function that returns a + * value — and that check answered with what it printed: a line number inside a sentence, and no text. The line + * it names is the line of the mistake itself. + */ + public static function shownAt(string $content, int $line): string + { + return self::lines($content, $line, null, "The check names line {$line}."); + } + + /** The line a compile check names in what it printed — it closes its sentence with `on line N` — or null. */ + public static function lineNamedIn(string $printed): ?int + { + return preg_match('/ on line ([1-9][0-9]*)$/m', $printed, $named) === 1 ? (int) $named[1] : null; + } + + private static function lines(string $content, int $stopped, ?int $opened, string $said): string + { + $lines = explode("\n", $content); + $width = \strlen((string) \count($lines)); + $row = static function (int $number, string $mark) use ($lines, $width): string { + $text = $lines[$number - 1]; + if (\strlen($text) > self::SHOWN) { + $text = substr($text, 0, self::SHOWN) . ' … ⟨the line goes on: ' . \strlen($text) . ' bytes⟩'; + } + + return ' ' . $mark . ' ' . str_pad((string) $number, $width, ' ', STR_PAD_LEFT) . ' │ ' . $text . "\n"; + }; + + $block = ''; + if ($opened !== null && $opened < $stopped - 3 && $opened <= \count($lines)) { + $said .= " The bracket it names was opened on line {$opened}."; + $block .= $row($opened, '◂') . " …\n"; + } elseif ($opened !== null) { + $said .= " The bracket it names was opened on line {$opened}."; + } + for ($number = max(1, $stopped - 3); $number <= min(\count($lines), $stopped + 1); ++$number) { + $block .= $row($number, $number === $stopped ? '▸' : ($number === $opened ? '◂' : ' ')); + } + + return "\n" . $said . " Your proposal reads there — after «│» every line is yours, byte for byte:\n" . rtrim($block, "\n"); + } } diff --git a/tests/Operations/ALandingRefusalCarriesWhatTheNextCallNeedsTest.php b/tests/Operations/ALandingRefusalCarriesWhatTheNextCallNeedsTest.php new file mode 100644 index 0000000..ce16a12 --- /dev/null +++ b/tests/Operations/ALandingRefusalCarriesWhatTheNextCallNeedsTest.php @@ -0,0 +1,410 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/devtools + */ + +declare(strict_types=1); + +namespace Milpa\DevTools\Tests\Operations; + +use Milpa\DevTools\Operations\EditPairs; +use Milpa\DevTools\Operations\ImplementHandler; +use Milpa\DevTools\Operations\SyntaxFinding; +use Milpa\DevTools\Support\RootResolver; +use PHPUnit\Framework\TestCase; + +/** + * A LANDING REFUSAL CARRIES THE TEXT THE NEXT CALL NEEDS (greenhouse evidence/1162). + * + * Three refusals are about the text itself, and each told a builder that something was wrong without the words to + * put it right. A body with a syntax error got one line — a line number, and it is the line where the parser gave + * up, which is rarely the line of the mistake — beside a hint to repair the proposal with exact find and replace + * pairs: exact pairs over a text the answer did not show. An edit whose `find` was not in the file got the whole + * file back and nothing about where the two part ways. An edit whose `find` was there several times got a count. + * + * So each says what the next call is written from: the builder's own lines where the parser stopped; the line where + * an edit stops matching, how the file reads there, and the file's own text over those lines; the lines an + * ambiguous text sits on. + */ +final class ALandingRefusalCarriesWhatTheNextCallNeedsTest extends TestCase +{ + private const FILE = <<<'PHP' + root = sys_get_temp_dir() . '/milpa-refusal-' . bin2hex(random_bytes(4)); + mkdir($this->root . '/src/Plugins/Demo/Services', 0o775, true); + file_put_contents($this->file(), "root)); + } + + // ── a body with a syntax error ─────────────────────────────────────────────────────────────── + + public function testASyntaxRefusalShowsTheBuildersOwnLinesWhereTheParserStopped(): void + { + $proposal = str_replace('$clean = trim($name);', '$clean = trim($name)', self::FILE); + + $r = $this->implement($proposal); + + self::assertFalse($r['ok']); + self::assertSame('syntax', $r['diagnostic']['phase']); + self::assertStringStartsWith('refused: syntax error in the proposal for src/Plugins/Demo/Services/GreeterService.php on line 15: ', $r['error']); + // The parser stopped two lines after the mistake: both are in what is shown, and the one it names is marked. + self::assertStringContainsString(" 13 │ \$clean = trim(\$name)\n", $r['error']); + self::assertStringContainsString(" ▸ 15 │ return 'hola ' . \$clean;\n", $r['error']); + self::assertStringContainsString('The parser stopped at line 15: what it could not read is on that line or just before it.', $r['error']); + } + + /** What is shown is copied into a `find`: after the gutter, every line is the proposal's, byte for byte. */ + public function testTheLinesShownAreTheProposalsByteForByte(): void + { + $proposal = str_replace(" \$clean = trim(\$name);\n", "\t\$clean = trim(\$name) \n", self::FILE); + + $r = $this->implement($proposal); + + $lines = explode("\n", $proposal); + self::assertSame(1, preg_match_all('/^ [ ▸]\s*(\d+) │ (.*)$/mu', $r['error'], $shown, PREG_SET_ORDER) > 0 ? 1 : 0, $r['error']); + self::assertGreaterThanOrEqual(4, \count($shown)); + foreach ($shown as [, $number, $text]) { + self::assertSame($lines[(int) $number - 1], $text, "line {$number}"); + } + } + + public function testAnUnclosedBracketShowsWhereItWasOpenedToo(): void + { + $proposal = str_replace(" return 'adiós ' . trim(\$name);\n }\n}\n", " return 'adiós ' . trim(\$name);\n}\n", self::FILE) . "\n\n\n\n\n\n// the end\n"; + + $r = $this->implement($proposal); + + self::assertFalse($r['ok']); + self::assertMatchesRegularExpression("/Unclosed '\\{' on line (\\d+)/", $r['error']); + self::assertStringContainsString('The bracket it names was opened on line 10.', $r['error']); + self::assertStringContainsString(" ◂ 10 │ {\n …\n", $r['error'], 'far from where the parser stopped, it is shown apart'); + self::assertMatchesRegularExpression('/^ ▸ 29 │ $/mu', $r['error']); + } + + /** Opened close by, the bracket's line is among the lines shown: it is marked there, and nothing is set apart. */ + public function testABracketOpenedCloseByIsMarkedAmongTheLinesShown(): void + { + $proposal = rtrim(self::FILE) . "\n\nfunction more(): void\n{\n return;\n"; + + $r = $this->implement($proposal); + + self::assertFalse($r['ok']); + self::assertStringContainsString('The bracket it names was opened on line 25.', $r['error']); + self::assertMatchesRegularExpression('/^ ◂ 25 │ \{$/mu', $r['error']); + self::assertMatchesRegularExpression('/^ ▸ 27 │ $/mu', $r['error']); + self::assertStringNotContainsString(" …\n", $r['error']); + } + + public function testASyntaxErrorOnTheLastLineShowsTheEndOfTheProposal(): void + { + $proposal = rtrim(self::FILE) . "\n\nfunction ("; + + $r = $this->implement($proposal); + + self::assertFalse($r['ok']); + self::assertStringEndsWith(" ▸ 24 │ function (", $r['error'], 'the last line of the proposal is the last line shown'); + self::assertStringContainsString(" 21 │ }\n", $r['error']); + } + + public function testAVeryLongLineIsCutAndSaysSo(): void + { + $proposal = str_replace('$clean = trim($name);', '$clean = trim($name) . \'' . str_repeat('x', 600) . '\'', self::FILE); + + $r = $this->implement($proposal); + + self::assertFalse($r['ok']); + self::assertStringContainsString(str_repeat('x', 100) . ' … ⟨the line goes on: 6', $r['error']); + self::assertLessThan(1600, \strlen($r['error'])); + } + + /** What nothing else reads stays as it was: the first sentence, and the finding the diagnostic carries. */ + public function testTheFindingItselfIsWhatItWas(): void + { + $proposal = str_replace('$clean = trim($name);', '$clean = trim($name)', self::FILE); + + $r = $this->implement($proposal); + + self::assertSame(SyntaxFinding::inspect($proposal), $r['diagnostic']['result']); + self::assertSame(['parser', 'message', 'line', 'fingerprint'], array_keys($r['diagnostic']['result'])); + self::assertStringContainsString('; destination preserved, proposal never installed', explode("\n", $r['error'])[0]); + } + + // ── a body the parser reads and the compile check refuses ──────────────────────────────────── + + /** + * The parser is not the only door. A body it reads can still fail to compile — a void function that returns a + * value, a method declared twice — and the check that says so has a sentence of its own. A resident met it on + * a house that carried the rest of this: 300 characters, a line number, no text. + */ + public function testABodyThatDoesNotCompileIsShownItsOwnLinesToo(): void + { + $proposal = str_replace('part(string $name): string', 'part(string $name): void', self::FILE); + + $r = $this->implement($proposal); + + self::assertFalse($r['ok']); + self::assertStringStartsWith("refused: syntax or compilation check failed (exit 255):\n", $r['error']); + self::assertStringContainsString('A void function must not return a value in ', $r['error']); + self::assertStringEndsWith( + "\nThe check names line 20. Your proposal reads there — after «│» every line is yours, byte for byte:\n" + . " 17 │ \n" + . " 18 │ public function part(string \$name): void\n" + . " 19 │ {\n" + . " ▸ 20 │ return 'adiós ' . trim(\$name);\n" + . ' 21 │ }', + $r['error'], + ); + } + + /** The line it names is the mistake's own, whatever the mistake: here the second declaration of a method. */ + public function testTheLineTheCompileCheckNamesIsTheOneMarked(): void + { + $r = $this->implement(str_replace('function part(', 'function greet(', self::FILE)); + + self::assertFalse($r['ok']); + self::assertStringContainsString('Cannot redeclare ', $r['error']); + self::assertStringContainsString('The check names line 18.', $r['error']); + self::assertMatchesRegularExpression('/^ ▸ 18 │ public function greet\(string \$name\): string$/mu', $r['error']); + } + + /** What the check printed is still there whole, under the name of the file it was meant for. */ + public function testWhatTheCompileCheckPrintedIsStillThere(): void + { + $r = $this->implement(str_replace('part(string $name): string', 'part(string $name): void', self::FILE)); + + self::assertStringContainsString(' in ' . $this->file() . ' on line 20', $r['error']); + self::assertStringContainsString('Errors parsing ' . $this->file(), $r['error']); + self::assertStringNotContainsString('milpa-implement-', $r['error']); + self::assertArrayNotHasKey('diagnostic', $r); + } + + /** The line is read where the check closes its sentence with it; a check that names none adds nothing. */ + public function testTheLineIsReadFromWhatTheCheckPrinted(): void + { + self::assertSame(41, SyntaxFinding::lineNamedIn("PHP Fatal error: A void function must not return a value in /app/src/A.php on line 41\nErrors parsing /app/src/A.php")); + // A folder can be named anything: the line is the one that closes the sentence, not the first that looks like one. + self::assertSame(7, SyntaxFinding::lineNamedIn("Fatal error: Cannot redeclare A::b() in /app/notes on line 3/A.php on line 7\nErrors parsing /app/notes on line 3/A.php")); + self::assertNull(SyntaxFinding::lineNamedIn('Errors parsing /app/src/A.php')); + self::assertNull(SyntaxFinding::lineNamedIn("sh: 1: php: not found\n")); + } + + // ── an edit whose text is not in the file ──────────────────────────────────────────────────── + + public function testAnEditWhoseTextIsNotThereIsToldWhereItStopsMatching(): void + { + $find = " public function greet(string \$name): string\n {\n return 'hola ' . \$name;"; + + $r = EditPairs::apply(self::FILE, [['find' => $find, 'replace' => 'x']]); + + self::assertFalse($r['ok']); + self::assertStringStartsWith("edit #1 matches nothing — this `find` does not appear in the file:\n{$find}\n", $r['error']); + self::assertStringContainsString("It matches the CURRENT file up to line 13 and stops there. There the file reads:\n \$clean = trim(\$name);\nand your `find` reads:\n return 'hola ' . \$name;\n", $r['error']); + } + + /** The file's own text over those lines is handed over as the `find` that matches — and it does. */ + public function testTheFilesOwnLinesAreGivenAndTheyMatch(): void + { + $find = " public function greet(string \$name): string\n {\n return 'hola ' . \$name;"; + + $r = EditPairs::apply(self::FILE, [['find' => $find, 'replace' => 'x']]); + + self::assertSame(1, preg_match('/as a `find` it matches exactly once:\n(.*?)\nThe CURRENT file is:/s', $r['error'], $given), $r['error']); + self::assertSame(" public function greet(string \$name): string\n {\n \$clean = trim(\$name);", $given[1]); + self::assertTrue(EditPairs::apply(self::FILE, [['find' => $given[1], 'replace' => 'x']])['ok']); + } + + /** The commonest miss: the words are right and the spacing is not — a blank line, an indentation. */ + public function testWhenOnlyItsSpacingDiffersItSaysSoAndGivesTheExactText(): void + { + $find = " \$clean = trim(\$name);\n return 'hola ' . \$clean;"; + + $r = EditPairs::apply(self::FILE, [['find' => $find, 'replace' => 'x']]); + + self::assertFalse($r['ok']); + self::assertStringContainsString('Only its spacing differs from the file', $r['error']); + self::assertSame(1, preg_match('/as a `find` it matches exactly once:\n(.*?)\nThe CURRENT file is:/s', $r['error'], $given), $r['error']); + self::assertSame(" \$clean = trim(\$name);\n\n return 'hola ' . \$clean;", $given[1]); + } + + public function testWhenNothingOfItIsThereItSaysSo(): void + { + $r = EditPairs::apply(self::FILE, [['find' => " private function shout(): string\n {", 'replace' => 'x']]); + + self::assertFalse($r['ok']); + self::assertStringContainsString('Nothing of its first line is in the CURRENT file.', $r['error']); + self::assertStringNotContainsString('stops there', $r['error']); + self::assertStringNotContainsString('matches exactly once', $r['error']); + } + + /** A text over several places is not handed back as a `find`: it would be the next refusal. */ + public function testLinesThatAreInTheFileMoreThanOnceAreNotOfferedAsAFind(): void + { + $r = EditPairs::apply(self::FILE, [['find' => " {\n zzz();", 'replace' => 'x']]); + + self::assertFalse($r['ok']); + self::assertStringContainsString('It matches the CURRENT file up to line 13 and stops there (the first of 2 places it matches that far).', $r['error']); + self::assertStringNotContainsString('matches exactly once', $r['error']); + } + + /** A second pair is judged against the text the first one left — and the answer says that is the text it shows. */ + public function testALaterPairIsShownTheTextTheEarlierOnesLeft(): void + { + $r = EditPairs::apply(self::FILE, [['find' => "'hola '", 'replace' => "'buenas '"], ['find' => "return 'hola ' . \$clean;", 'replace' => 'x']]); + + self::assertFalse($r['ok']); + self::assertStringStartsWith('edit #2 matches nothing', $r['error']); + self::assertStringContainsString("The CURRENT file, with edit #1 of this call applied, is:\n---\n", $r['error']); + self::assertStringContainsString("return 'buenas ' . \$clean;", $r['error']); + self::assertStringContainsString('It matches the text the earlier edits of this call left up to line 15 and stops there', $r['error']); + self::assertStringContainsString("There the file reads:\n return 'buenas ' . \$clean;\nand your `find` reads:\nreturn 'hola ' . \$clean;\n", $r['error']); + } + + public function testAThirdPairSaysWhichOnesCameBeforeIt(): void + { + $r = EditPairs::apply(self::FILE, [['find' => "'hola '", 'replace' => "'buenas '"], ['find' => "'adiós '", 'replace' => "'chao '"], ['find' => 'absent', 'replace' => 'x']]); + + self::assertStringStartsWith('edit #3 matches nothing', $r['error']); + self::assertStringContainsString("The CURRENT file, with edits #1–#2 of this call applied, is:\n---\n", $r['error']); + self::assertStringContainsString('Nothing of its first line is in the text the earlier edits of this call left.', $r['error']); + } + + /** + * What the answer costs is the cost of the stretch it speaks of, not of the file: the search for the stretch + * that says the same words ends at the first line that says another. Without that it reads the rest of the + * file once per line — measured, 22 seconds on 8,000 lines. + */ + public function testALongFileIsAnsweredAsSoonAsAShortOne(): void + { + $lines = [' " public function first(): int\n {\n return 2;", 'replace' => 'x']]); + $seconds = (hrtime(true) - $started) / 1e9; + + self::assertStringContainsString("as a `find` it matches exactly once:\n public function first(): int\n {\n return 1;\n", $r['error']); + self::assertLessThan(2.0, $seconds, 'a refused edit on a long file took ' . round($seconds, 1) . ' s to answer'); + } + + /** The same for a text that is on every line of a long file: each place is counted from the one before it. */ + public function testATextOnEveryLineOfALongFileIsCountedOnce(): void + { + $lines = []; + for ($i = 0; $i < 80000; ++$i) { + $lines[] = " private int \$field{$i} = {$i}; // a line of ordinary length, as a class has them"; + } + + $started = hrtime(true); + $r = EditPairs::apply(implode("\n", $lines) . "\n", [['find' => ';', 'replace' => 'x']]); + $seconds = (hrtime(true) - $started) / 1e9; + + self::assertStringContainsString('It is on lines 1, 2, 3, 4, 5, 6, 7, 8 and 79992 more of the CURRENT file:', $r['error']); + self::assertLessThan(2.0, $seconds, 'an ambiguous edit on a long file took ' . round($seconds, 1) . ' s to answer'); + } + + /** Nothing is taken away: the whole text still travels, under the name it was given. */ + public function testTheWholeTextStillTravels(): void + { + $r = EditPairs::apply(self::FILE, [['find' => 'absent', 'replace' => 'x']], 'RECORDED proposal'); + + self::assertStringContainsString("The RECORDED proposal is:\n---\n" . self::FILE . "\n---\nBuild your pairs against that text exactly.", $r['error']); + self::assertStringContainsString('Nothing of its first line is in the RECORDED proposal.', $r['error']); + } + + // ── an edit whose text is there more than once ─────────────────────────────────────────────── + + public function testAnAmbiguousEditIsToldTheLinesItsTextSitsOn(): void + { + $r = EditPairs::apply(self::FILE, [['find' => 'trim($name)', 'replace' => 'x']]); + + self::assertFalse($r['ok']); + self::assertStringStartsWith('edit #1 is ambiguous: `find` appears 2 times and must appear exactly once — widen it with surrounding lines', $r['error']); + self::assertStringContainsString("It is on lines 13 and 20 of the CURRENT file:\n 13 │ \$clean = trim(\$name);\n 20 │ return 'adiós ' . trim(\$name);", $r['error']); + } + + public function testALineItSitsOnTwiceIsOneLine(): void + { + $r = EditPairs::apply(self::FILE, [['find' => 'string', 'replace' => 'x']]); + + self::assertStringContainsString('appears 4 times', $r['error']); + self::assertStringContainsString("It is on lines 11 and 18 of the CURRENT file:\n 11 │ public function greet(string \$name): string\n 18 │ public function part(string \$name): string", $r['error']); + + $one = EditPairs::apply("a x x b\n", [['find' => 'x', 'replace' => 'y']]); + self::assertStringContainsString("It is on line 1, more than once, of the CURRENT file:\n 1 │ a x x b", $one['error']); + } + + public function testManyPlacesAreCounted(): void + { + $r = EditPairs::apply(str_repeat("same\n", 40), [['find' => 'same', 'replace' => 'x']]); + + self::assertStringContainsString('appears 40 times', $r['error']); + self::assertStringContainsString('It is on lines 1, 2, 3, 4, 5, 6, 7, 8 and 32 more of the CURRENT file:', $r['error']); + self::assertSame(8, preg_match_all('/^\s+\d+ │ same$/mu', $r['error'])); + } + + /** Through the door a builder uses, the same answer arrives. */ + public function testTheEditDoorAnswersWithIt(): void + { + file_put_contents($this->file(), self::FILE); + + $r = (new ImplementHandler(new RootResolver($this->root)))->edit(['plugin' => 'Demo', 'class' => 'GreeterService', + 'edits' => [['find' => " return 'hola ' . \$name;", 'replace' => 'x']]]); + + self::assertFalse($r['ok']); + self::assertStringContainsString("It matches the CURRENT file up to line 15 and stops there. There the file reads:\n return 'hola ' . \$clean;\n", $r['error']); + self::assertSame(self::FILE, (string) file_get_contents($this->file()), 'and the file is as it was'); + } + + private function file(): string + { + return $this->root . '/src/Plugins/Demo/Services/GreeterService.php'; + } + + /** @return array */ + private function implement(string $content): array + { + return (new ImplementHandler(new RootResolver($this->root)))->handle(['plugin' => 'Demo', 'class' => 'GreeterService', 'content' => $content]); + } +} From 6151dfe55878d332089b0067364e163e6a42aa6b Mon Sep 17 00:00:00 2001 From: Rodrigo Vicente Date: Thu, 8 Oct 2026 10:41:10 -0600 Subject: [PATCH 9/9] test: a body that does not compile is judged by what every engine version says of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP 8.3 prints «A void function must not return a value» and 8.4 «A void method must not return a value». The test quoted the first; it asks now only for what both say. The refusal itself was right on both: the check's own words travel whole, whatever they are. --- .../ALandingRefusalCarriesWhatTheNextCallNeedsTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Operations/ALandingRefusalCarriesWhatTheNextCallNeedsTest.php b/tests/Operations/ALandingRefusalCarriesWhatTheNextCallNeedsTest.php index ce16a12..01a812b 100644 --- a/tests/Operations/ALandingRefusalCarriesWhatTheNextCallNeedsTest.php +++ b/tests/Operations/ALandingRefusalCarriesWhatTheNextCallNeedsTest.php @@ -183,7 +183,9 @@ public function testABodyThatDoesNotCompileIsShownItsOwnLinesToo(): void self::assertFalse($r['ok']); self::assertStringStartsWith("refused: syntax or compilation check failed (exit 255):\n", $r['error']); - self::assertStringContainsString('A void function must not return a value in ', $r['error']); + // What the check printed, in the engine's own words — which change with its version («A void function…», + // «A void method…»): only what both say is asked for. + self::assertStringContainsString(' must not return a value in ', $r['error']); self::assertStringEndsWith( "\nThe check names line 20. Your proposal reads there — after «│» every line is yours, byte for byte:\n" . " 17 │ \n"