diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..18e14aa --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +/tests export-ignore diff --git a/src/Operations/DevToolsOperations.php b/src/Operations/DevToolsOperations.php index 33ea883..16329bc 100644 --- a/src/Operations/DevToolsOperations.php +++ b/src/Operations/DevToolsOperations.php @@ -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.: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:(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:» 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. «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. «: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. 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. . 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'], @@ -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. «») 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', diff --git a/src/Operations/MakeHandler.php b/src/Operations/MakeHandler.php index b9e5c0d..d5738ec 100644 --- a/src/Operations/MakeHandler.php +++ b/src/Operations/MakeHandler.php @@ -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 + // — , /, :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_]*$/'; @@ -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='); } $taken = $this->screenMountedAt($root, $route); if ($taken !== null && $taken !== $screen) { @@ -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=\", \""); } 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: " diff --git a/tests/Operations/AnExampleInAContractShowsTheFormTest.php b/tests/Operations/AnExampleInAContractShowsTheFormTest.php new file mode 100644 index 0000000..068da21 --- /dev/null +++ b/tests/Operations/AnExampleInAContractShowsTheFormTest.php @@ -0,0 +1,192 @@ + + * + * @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 (``, + * `/`, `: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 — , /, :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 */ + public static function values(): iterable + { + yield 'a placeholder' => ['', true]; + yield 'a path of placeholders' => ['/', true]; + yield 'a field by its form' => [':string, ?:date, :bool', true]; + yield 'an enum by its form' => [':enum:(,,…)', 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' => [':enum:Kind(,…)', 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 + */ + 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 + */ + private static function shown(string $text): array + { + $values = []; + preg_match_all('/(?:\be\.g\.,?|\bfor example,?|\bsuch as)\s+(.*?)(?=(? 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; + } +} diff --git a/tests/Operations/MakeAsksWhatASurfaceIsTest.php b/tests/Operations/MakeAsksWhatASurfaceIsTest.php index 0c46c47..b49a91e 100644 --- a/tests/Operations/MakeAsksWhatASurfaceIsTest.php +++ b/tests/Operations/MakeAsksWhatASurfaceIsTest.php @@ -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=', (string) $noEntity['error']); + self::assertStringEndsWith('e.g. columns=", "', (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 «» 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' => ':string, ?:date, :bool'], + 'name' => ['what' => 'entity', 'plugin' => 'Blog', 'name' => '', 'fields' => 'text:string'], + 'entity' => ['entity' => ''] + $page, + 'route' => ['route' => '/'] + $page, + 'columns' => ['columns' => ', '] + $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']); diff --git a/tests/Operations/TheModelReadsEnglishTest.php b/tests/Operations/TheModelReadsEnglishTest.php index da9e800..bcdf637 100644 --- a/tests/Operations/TheModelReadsEnglishTest.php +++ b/tests/Operations/TheModelReadsEnglishTest.php @@ -46,13 +46,16 @@ public function testEveryDescriptionAnOperationGivesTheModelIsEnglish(): void self::assertSame([], $spanish, 'the model reads Spanish here'); } - /** The example the resident copied is the one that has to be English. */ + /** + * The example the resident copied is the one that has to be English — and, since a resident copies an + * example letter for letter, it shows the form of a field and no field of its own (decisions/0594 §5). + */ public function testTheFieldsExampleIsEnglish(): void { $make = array_values(array_filter((new DevToolsOperations())->operations(), static fn ($o): bool => $o->name === 'make'))[0]; $fields = (string) ($make->inputSchema['properties']['fields']['description'] ?? ''); - self::assertStringContainsString('title:string, ?due_date:date, done:bool', $fields); + self::assertStringContainsString(':string, ?:date, :bool', $fields); self::assertStringNotContainsString('titulo', $fields); }