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
6 changes: 5 additions & 1 deletion resources/skills/governed-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions src/Make/Generators/OperationGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -283,6 +286,39 @@ 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<string>
*/
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;
}
}

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 = '/(?<![A-Za-z0-9_])' . preg_quote($class, '/') . '::class,[^\n]*?\\\\?((?:[A-Za-z_][A-Za-z0-9_]*\\\\)+[A-Za-z_][A-Za-z0-9_]*)'
. "::class \\. 'Repository'/";

return preg_match($entry, $pluginSource, $found) === 1 ? $found[1] : null;
}

/**
* Whether a plugin's source already lists the operation class, by its short or its qualified name.
*/
Expand Down
170 changes: 170 additions & 0 deletions src/Make/Generators/TestGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@
* The judge itself lands through the SAME gate (implement reaches `tests/Plugins/`), and the gate
* knows a judge never judges itself — otherwise this red would be unlandable.
*
* ── THE JUDGE OF AN OPERATION HOLDS WHAT THE OPERATION WORKS THROUGH (greenhouse evidence/1156) ────
*
* The house says the judge comes before the body. Three real residents out of three, with their operations
* scaffolded, spent their next fourteen to seventeen calls to the model finding out one thing the house knew: how
* a test gets the repository `run()` receives. One never found it and filled no body. So when the class judged
* is an operation of the plugin, the scaffold already builds that repository —in memory— and shows the call the
* house makes. It still fails on purpose: what it must DO is the one thing no scaffold knows.
*
* A name that already ends in `Test` IS the judge. Appending the suffix again invents a class the
* landing gate cannot find (`TareaServiceTest` → `TareaServiceTestTest`) and the guidance used to
* teach the resulting cycle: fill that phantom, then land a body that does not exist.
Expand All @@ -51,6 +59,18 @@ public function generate(GenerationContext $context): GenerationResult
$plugin = $context->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 = <<<PHP
<?php
Expand Down Expand Up @@ -86,6 +106,156 @@ public function testDeclareWhatItMustDo(): void
);
}

/**
* What the judge of an operation is scaffolded from, read from the operation's own source and from the entry
* that lists it in its plugin — or `null` when the target is not an operation of this plugin.
*
* Read, never loaded: this runs in a copy of the house whose classes nothing autoloads here.
*
* @return array{fqcn: string, input: list<string>, optional: list<string>, hands: list<string>, 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<string>, optional: list<string>, hands: list<string>, 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 = <<<PHP
/**
* What {$target}::run() works through, as the house hands it — in memory here: a test stores nothing.
*
* @var RepositoryInterface<{$operation['entity']}>
*/
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 <<<PHP
<?php

declare(strict_types=1);

namespace App\\Tests\\Plugins\\{$plugin};

{$uses}

/**
* The behavioral judge of {@see {$target}}.
*
* The landing gate runs this file whenever {$target} lands: red restores the original byte for
* byte, green is named in the landing's verdict. Declare here what the class must DO — not what
* it looks like; the gate already judges shape.
*
* It runs over the house as it is when {$target} lands. If it calls another operation of this plugin,
* that one's body has to be in the house first: a scaffold answers `ok: false`, and no judge goes green
* over that.
*/
final class {$judge} extends TestCase
{
{$holds} public function testDeclareWhatItMustDo(): void
{
// {$target} is called here the way the house calls it — built with its input, and its run()
// handed what it works through:
//
// \$answer = (new {$target}({$input}))->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.
*
Expand Down
58 changes: 57 additions & 1 deletion src/Operations/ImplementHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

namespace Milpa\DevTools\Operations;

use Milpa\DevTools\Make\Generators\OperationGenerator;
use Milpa\DevTools\Support\RootResolver;

/**
Expand Down Expand Up @@ -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' => [
Expand All @@ -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,
],
];
}
Expand Down Expand Up @@ -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<string>
*/
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('/(?<![A-Za-z0-9_])' . preg_quote($scaffold, '/') . '(?![A-Za-z0-9_])/', $judge) === 1
|| ($declares !== null && str_contains($judge, $declares))) {
$named[] = $scaffold;
}
}

return $named;
}

/**
* What a red judge is told when it names scaffolds: that they are, and the order that gets past them.
*
* @param list<string> $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/<plugin>/`, or `null` when none declares it. */
private function testFor(string $root, string $plugin, string $class): ?string
{
Expand Down
Loading
Loading