Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions resources/construction-probe.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,20 +90,55 @@
$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, '\\')) {
$routes[] = implode('|', array_map(static fn (\Milpa\Http\HttpMethod $m): string => $m->value, $route->methods)) . ' ' . $route->path;
}
}
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.
Expand Down Expand Up @@ -131,5 +166,5 @@
'built' => false,
'error' => (new \ReflectionClass($e))->getShortName() . ': ' . implode(' ← ', $messages),
'unresolvable' => $unresolvable,
]);
] + $asked);
}
8 changes: 5 additions & 3 deletions resources/skills/governed-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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=<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
Expand Down
10 changes: 8 additions & 2 deletions src/Make/Generators/EntityGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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];
}
Expand Down Expand Up @@ -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];
}
Expand Down
50 changes: 46 additions & 4 deletions src/Make/Generators/OperationGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@
* - THE AUTHORITY IT SPENDS — `#[Needs(scopes: ['<domain>: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
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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=<Entity>. ',
default => '',
},
$wiring,
$context->plugin,
$context->name,
Expand Down Expand Up @@ -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<string>
*/
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<string>
*/
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
{
Expand Down
94 changes: 90 additions & 4 deletions src/Operations/ConstructionProbe.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand All @@ -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<string>, built?: bool, error?: string, unresolvable?: list<array{parameter: string, type: string}>}
* `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<string>, built?: bool, error?: string, unresolvable?: list<array{parameter: string, type: string}>, operation?: array{name: string, handed: bool, unhanded: list<array{type: string, error: string}>}}
*/
public function probe(string $root, string $class): array
{
Expand All @@ -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 = [];
Expand All @@ -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<mixed> $answer
*
* @return array{}|array{operation: array{name: string, handed: bool, unhanded: list<array{type: string, error: string}>}}
*/
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<array{type: string, error: string}> $unhanded
*/
public static function unhanded(string $pluginDir, string $plugin, string $class, string $fqcn, string $name, array $unhanded): string
{
$asked = implode('; ', array_map(static fn (array $u): string => $u['type'] . ', and the house answered: ' . rtrim($u['error'], '. '), $unhanded));
$text = "refused: the house cannot hand «{$class}» what its run() works through — «{$name}» asked for {$asked}.\n"
. "What run() takes is found by the entry that lists this operation in operations() of its plugin, and by nothing else.\n";
$again = "Then send this same implement plugin={$plugin} class={$class} again.";

$types = array_column($unhanded, 'type');
$others = array_values(array_filter($types, static fn (string $type): bool => $type !== self::REPOSITORY));
$steps = [];
if (\in_array(self::REPOSITORY, $types, true)) {
$entities = \Milpa\DevTools\Make\Generators\OperationGenerator::entitiesIn($pluginDir);
$text .= 'A repository has no class to be found by: it is reached by its entity, under the id `make entity` registered it with. '
. ($entities === [] ? "This plugin has no entity yet.\n" : "This plugin's entities: " . implode(', ', $entities) . ".\n");
$byClass = \Milpa\DevTools\Make\Generators\OperationGenerator::entry($fqcn);
$source = (string) @file_get_contents($pluginDir . '/' . $plugin . '.php');
$namespace = substr($fqcn, 0, (int) strrpos($fqcn, '\\Operations\\'));
$entity = \count($entities) === 1 ? $entities[0] : '<Entity>';
$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=<Entity> 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, <how it is built>); — 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));
}

/**
Expand Down
Loading
Loading