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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/tests export-ignore
10 changes: 5 additions & 5 deletions src/Operations/DevToolsOperations.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,11 @@ public function operations(): array
'description' => 'Target plugin directory: one identifier ^[A-Za-z_][A-Za-z0-9_]*$, no paths. Use an existing directory when adding artifacts. When scaffolding a NEW plugin, use exactly the name the task gives it: no suffix is added. Write scopes are granted per this name (plugins.<plugin>:write).',
'x-milpa-source' => ['tool' => 'artifact:list', 'key' => 'plugin'],
],
'name' => ['type' => 'string', 'description' => 'The class to create. For what=plugin, the same name as plugin. For what=page, the screen: lowercase letters and digits, e.g. blog'],
'fields' => ['type' => 'string', 'description' => 'Comma-separated `name:type` fields, named in English; prefix the name with `?` for nullable. E.g. «title:string, ?due_date:date, done:bool». Scalar types: string, text, int, bigint, bool, float, decimal, date, datetime, json. «enum:<Class>(case1,case2,…)» GENERATES the enum with those cases (e.g. «priority:enum:TaskPriority(low,medium,high)») — always declare the cases so no enum is left dangling. «belongsTo:<Entity>» creates a relation only for entity with --flavor=legacy; runtime resource degrades it to <entity>_id:int and names it in the postconditions; runtime entity and crud must receive the scalar id directly (e.g. «list_id:int»). Scalar modifiers: length («title:string:120») or decimal precision («price: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 /blog, no parameters. Refused where a screen is already mounted'],
'name' => ['type' => 'string', 'description' => 'The class to create. For what=plugin, the same name as plugin. For what=page, the screen: lowercase letters and digits, starting with a letter'],
'fields' => ['type' => 'string', 'description' => 'Comma-separated `name:type` fields, named in English; prefix the name with `?` for nullable. E.g. «<name>:string, ?<name>:date, <name>:bool». Scalar types: string, text, int, bigint, bool, float, decimal, date, datetime, json. «enum:<Class>(case1,case2,…)» GENERATES the enum with those cases (e.g. «<name>:enum:<Class>(<case>,<case>,…)») — always declare the cases so no enum is left dangling. «belongsTo:<Entity>» creates a relation only for entity with --flavor=legacy; runtime resource degrades it to <entity>_id:int and names it in the postconditions; runtime entity and crud must receive the scalar id directly (e.g. «<entity>_id:int»). Scalar modifiers: length («<name>:string:120») or decimal precision («<name>: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 /<path>, 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. Post. 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. <Entity>. 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'],
'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'],
Expand Down Expand Up @@ -468,7 +468,7 @@ public function operations(): array
inputSchema: [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string', 'description' => 'The class or enum to inspect: a bare name (e.g. «Tarea») searches the app plugins; a FQCN with backslashes (e.g. «Milpa\\Data\\RepositoryInterface») resolves through the app autoloader and reaches installed vendor code'],
'name' => ['type' => 'string', 'description' => 'The class or enum to inspect: a bare name (e.g. «<Name>») searches the app plugins; a FQCN with backslashes (e.g. «Milpa\\Data\\RepositoryInterface») resolves through the app autoloader and reaches installed vendor code'],
'plugin' => [
'type' => 'string',
'description' => 'Optional plugin directory to search; omit it to search all plugins',
Expand Down
17 changes: 15 additions & 2 deletions src/Operations/MakeHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,19 @@ public function handle(array $input): array
return $this->falla("unknown artifact «{$que}» — valid: " . implode(', ', $this->kinds()));
}

// A FORM IS NOT A VALUE (greenhouse decisions/0594 §5). The contract shows each argument by its form
// — <Entity>, /<path>, <name>:string, domain:verb — and an example gets copied letter for letter
// (evidence/1071, B11). A form that arrives as it was shown is answered by name, before anything is
// scaffolded: further down it would be refused too, for a reason that says something else.
foreach (['plugin', 'name', 'entity', 'route', 'path', 'fields', 'columns', 'public_when', 'operation'] as $argument) {
$given = \is_string($input[$argument] ?? null) ? $input[$argument] : '';
if (preg_match('/<[^<>\s]+>/', $given, $form) === 1 || $given === 'domain:verb') {
$shown = $form[0] ?? $given;

return $this->falla("«{$given}» is the form the contract shows for «{$argument}», not a value: write the name itself where «{$shown}» is");
}
}

$plugin = \is_string($input['plugin'] ?? null) ? $input['plugin'] : '';
$nombre = \is_string($input['name'] ?? null) ? $input['name'] : '';
$identificador = '/^[A-Za-z_][A-Za-z0-9_]*$/';
Expand Down Expand Up @@ -473,7 +486,7 @@ private function page(array $input, string $root, string $plugin, string $screen
}
$entity = \is_string($input['entity'] ?? null) ? trim($input['entity']) : '';
if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/D', $entity) !== 1) {
return $this->falla('a page needs «entity»: the entity whose public rows it lists, e.g. entity=Post');
return $this->falla('a page needs «entity»: the entity whose public rows it lists, e.g. entity=<Entity>');
}
$taken = $this->screenMountedAt($root, $route);
if ($taken !== null && $taken !== $screen) {
Expand Down Expand Up @@ -522,7 +535,7 @@ private function page(array $input, string $root, string $plugin, string $screen
return $this->falla("plugin {$plugin} has no entity «{$entity}»: name one it has, or pass fields=… and public_when=… to scaffold it");
}
if ($columns === []) {
return $this->falla("a page needs «columns»: the fields of {$entity} it shows, in order, e.g. columns=\"title, body\"");
return $this->falla("a page needs «columns»: the fields of {$entity} it shows, in order, e.g. columns=\"<field>, <field>\"");
}
if (!str_contains((string) file_get_contents($file), 'const PUBLIC_WHEN')) {
return $this->falla("{$entity} declares no PUBLIC_WHEN, so nothing of it is public and no page can list it: "
Expand Down
192 changes: 192 additions & 0 deletions tests/Operations/AnExampleInAContractShowsTheFormTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
<?php

/**
* This file is part of milpa/devtools — the generate-verify-inspect developer loop of the Milpa
* PHP framework.
*
* (c) Rodrigo Vicente - TeamX Agency — https://teamx.agency <hola@teamx.agency>
*
* @license Apache-2.0
*
* @link https://github.com/getmilpa/devtools
*/

declare(strict_types=1);

namespace Milpa\DevTools\Tests\Operations;

use Composer\InstalledVersions;
use Milpa\Command\Operation;
use Milpa\DevTools\Operations\DevToolsOperations;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
* AN EXAMPLE IS NOT THE EXAM (greenhouse decisions/0594 §5, question 7).
*
* A contract is read by every session, before it knows what it will be asked to build. When its
* example of an argument is a real name — an entity, a route, a list of fields — the contract has
* chosen a domain, and whoever is later asked for that very domain was handed part of the answer.
* So an example shows the FORM of the value: a placeholder where a name would go (`<Entity>`,
* `/<path>`, `<name>:string`), or a name this package can vouch for because it is its own.
*
* The rule is stated positively, and holds no list of names to avoid: such a list, written here,
* would be the very thing it guards against.
*/
final class AnExampleInAContractShowsTheFormTest extends TestCase
{
/** The words a form may carry around its placeholders: the field grammar `make` declares. */
private const GRAMMAR = ['string', 'text', 'int', 'bigint', 'bool', 'float', 'decimal', 'date', 'datetime', 'json', 'enum', 'belongsTo', 'id'];

/**
* Names this package shows as they are, each for a reason that is checked below: a form written
* as words, a fragment of the names its own search matches, and the namespace of a dependency.
*/
private const OWN = ['domain:verb', 'Repository', 'Milpa\\Data\\'];

public function testWhatFollowsAnExampleInAContractIsAForm(): void
{
$shown = 0;
foreach ((new DevToolsOperations())->operations() as $operation) {
foreach (self::contract($operation) as $where => $text) {
foreach (self::shown($text) as $value) {
++$shown;
self::assertTrue(
self::isAForm($value),
"{$where} shows «{$value}»: an example shows the form of the value — <Entity>, /<path>, <name>:string — or a name this package can vouch for",
);
}
}
}

self::assertGreaterThan(8, $shown, 'the control: the contracts do show examples');
}

/** The names shown as they are exist: the exception is a name the package answers for, not a habit. */
public function testTheNamesShownAsTheyAreAreTheirOwn(): void
{
self::assertTrue(interface_exists('Milpa\\Data\\RepositoryInterface'), 'the interface the contract names, in the namespace and with the fragment it shows');
self::assertTrue(InstalledVersions::isInstalled('milpa/data'), 'the package the contracts name');
}

/** @return iterable<string, array{string, bool}> */
public static function values(): iterable
{
yield 'a placeholder' => ['<Entity>', true];
yield 'a path of placeholders' => ['/<path>', true];
yield 'a field by its form' => ['<name>:string, ?<name>:date, <name>:bool', true];
yield 'an enum by its form' => ['<name>:enum:<Class>(<case>,<case>,…)', true];
yield 'a form written as words' => ['domain:verb', true];
yield 'a class that exists' => ['Milpa\\Data\\RepositoryInterface', true];
yield 'an installed package' => ['milpa/data', true];
yield 'an entity by a name' => ['Thing', false];
yield 'a route by a name' => ['/things', false];
yield 'a field by a name' => ['size:decimal', false];
yield 'a name beside a placeholder' => ['<name>:enum:Kind(<case>,…)', false];
yield 'a package that is not there' => ['acme/things', false];
}

/** The control of the rule itself: it tells a form from a name, in both directions. */
#[DataProvider('values')]
public function testTheRuleTellsAFormFromAName(string $value, bool $form): void
{
self::assertSame($form, self::isAForm($value));
}

public function testItFindsTheValueAnExampleShows(): void
{
self::assertSame(['Thing'], self::shown('the entity it lists, e.g. Thing. With fields it is scaffolded'));
self::assertSame(['/things'], self::shown('a literal path such as /things, no parameters'));
self::assertSame(['size:decimal'], self::shown('fields (e.g. «size:decimal») and more'));
self::assertSame(['things'], self::shown('lowercase letters and digits, e.g. things'));
self::assertSame([], self::shown('the shared key (e.g. a name both sides agree on)'), 'words that describe are not a value');
self::assertSame([], self::shown('nothing is shown here'));
}

/**
* Every text of an operation's contract: its description, and each argument's.
*
* @return array<string, string>
*/
private static function contract(Operation $operation): array
{
$texts = [$operation->name => $operation->description];
$walk = static function (array $schema, string $path) use (&$walk, &$texts, $operation): void {
foreach ((array) ($schema['properties'] ?? []) as $name => $property) {
if (!\is_array($property)) {
continue;
}
if (\is_string($property['description'] ?? null)) {
$texts["{$operation->name} · {$path}{$name}"] = $property['description'];
}
$walk($property, "{$path}{$name}.");
if (\is_array($property['items'] ?? null)) {
$walk($property['items'], "{$path}{$name}[].");
}
}
};
$walk($operation->inputSchema ?? [], '');

return $texts;
}

/**
* The values a text shows as examples: what follows "e.g.", "for example" or "such as", up to
* the end of its clause. A value sits between « », backticks or double quotes, or is a bare
* token shaped like code (a path, a Name, a name:type, a key=value) or standing alone. Words that
* describe — "a name both sides agree on" — are not a value.
*
* @return list<string>
*/
private static function shown(string $text): array
{
$values = [];
preg_match_all('/(?:\be\.g\.,?|\bfor example,?|\bsuch as)\s+(.*?)(?=(?<![A-Za-z0-9\/])[.;](?:\s|$)| — |\)(?:[\s.,;]|$)|$)/isu', $text, $clauses);

foreach ($clauses[1] as $clause) {
$clause = trim($clause);
if (preg_match_all('/«([^»]*)»|`([^`]*)`|"([^"]*)"/u', $clause, $quoted, \PREG_SET_ORDER) > 0) {
foreach ($quoted as $q) {
$values[] = implode('', \array_slice($q, 1));
}

continue;
}

$first = rtrim((string) (preg_split('/[\s,]+/', $clause)[0] ?? ''), '.;:)');
$alone = preg_match('/\s/', rtrim($clause, '.;:)')) !== 1;
if ($first !== '' && ($alone || preg_match('/^[\/<{\[]|^[A-Z]|[:=_\\\\]/', $first) === 1)) {
$values[] = $first;
}
}

return $values;
}

/**
* Whether a shown value is a form: it is made of placeholders and the grammar around them, or it
* is a name this package can vouch for — a class that exists, an installed package, one of its own.
*/
private static function isAForm(string $value): bool
{
if (\in_array($value, self::OWN, true) || class_exists($value) || interface_exists($value)) {
return true;
}
if (preg_match('~^[a-z0-9-]+/[a-z0-9-]+$~', $value) === 1) {
return InstalledVersions::isInstalled($value);
}

$rest = preg_replace('/<[^<>\s]+>|\{[^{}\s]+\}|…/u', '', $value) ?? $value;
if ($rest === $value) {
return false;
}

foreach (preg_split('/[^A-Za-z\\\\]+/', $rest, -1, \PREG_SPLIT_NO_EMPTY) ?: [] as $word) {
if (!\in_array($word, self::GRAMMAR, true)) {
return false;
}
}

return true;
}
}
47 changes: 47 additions & 0 deletions tests/Operations/MakeAsksWhatASurfaceIsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,53 @@ public function testWhatAPageCannotBeBuiltFromIsRefusedByNameAndWritesNothing():
}
}

/**
* AN EXAMPLE IS NOT THE EXAM (greenhouse decisions/0594 §5). A refusal that says what a page needs shows
* the FORM of the argument it asks for — a placeholder where the name goes — and names no entity or field
* of its own choosing.
*/
public function testWhatAPageNeedsIsShownByItsForm(): void
{
$this->make(['what' => 'entity', 'plugin' => 'Blog', 'name' => 'Note', 'fields' => 'text:string, body:text']);
$page = ['what' => 'page', 'plugin' => 'Blog', 'name' => 'blog', 'route' => '/blog'];

$noEntity = $this->make($page);
$noColumns = $this->make($page + ['entity' => 'Note']);

self::assertStringEndsWith('e.g. entity=<Entity>', (string) $noEntity['error']);
self::assertStringEndsWith('e.g. columns="<field>, <field>"', (string) $noColumns['error']);
}

/**
* A FORM IS NOT A VALUE. The contract shows an argument by its form, and an example is copied letter for
* letter (greenhouse evidence/1071, B11). Each form, sent back exactly as the contract shows it, is refused
* by name — which argument, and what to write there — and nothing is scaffolded. Two of them used to get
* through: fields named «<name>» died later as «class not found», and «domain:verb» became an operation.
*/
public function testAFormCopiedToTheLetterIsAnsweredByNameAndWritesNothing(): void
{
$this->make(['what' => 'entity', 'plugin' => 'Blog', 'name' => 'Note', 'fields' => 'text:string, shown:bool', 'public_when' => 'shown']);
$before = $this->tree();
$page = ['what' => 'page', 'plugin' => 'Blog', 'name' => 'notes', 'route' => '/notes', 'entity' => 'Note', 'columns' => 'text'];

foreach ([
'fields' => ['what' => 'entity', 'plugin' => 'Blog', 'name' => 'Other', 'fields' => '<name>:string, ?<name>:date, <name>:bool'],
'name' => ['what' => 'entity', 'plugin' => 'Blog', 'name' => '<Name>', 'fields' => 'text:string'],
'entity' => ['entity' => '<Entity>'] + $page,
'route' => ['route' => '/<path>'] + $page,
'columns' => ['columns' => '<field>, <field>'] + $page,
'operation' => ['what' => 'operation', 'plugin' => 'Blog', 'name' => 'DoIt', 'operation' => 'domain:verb'],
] as $argument => $input) {
$refused = $this->make($input);

self::assertFalse($refused['ok'], $argument);
self::assertStringContainsString("is the form the contract shows for «{$argument}», not a value", (string) $refused['error'], $argument);
self::assertSame($before, $this->tree(), "the form of «{$argument}» wrote something");
}

self::assertTrue($this->make($page)['ok'], 'the control: the same page, with names where the forms were, is scaffolded');
}

public function testAPageDoesNotOverwriteAnEntityThatIsAlreadyThere(): void
{
$this->make(['what' => 'entity', 'plugin' => 'Blog', 'name' => 'Post', 'fields' => 'title:string, body:text']);
Expand Down
Loading
Loading