diff --git a/src/Operation.php b/src/Operation.php index f804e82..cf18fcd 100644 --- a/src/Operation.php +++ b/src/Operation.php @@ -177,7 +177,40 @@ public function __construct( * @var list */ public array $citesReceiptsOf = [], + /** + * Whether this operation AMENDS the entity {@see $namedTarget} names — changes, in place, one that + * exists — so that a session's own record can say where that entity came from (greenhouse decisions/0596). + * + * {@see $createsNamedTarget} drew the intent contract's line by the verb: `implement` materialises and is + * not asked, `edit` selects what exists and is. The record drew it elsewhere. Of 34 times a house asked a + * person to confirm an `edit`, 28 were about a class that same session had brought into the house a moment + * before: nothing the person already had was being selected. Correcting what one has just materialised is + * still materialising — the model's interpretive domain; touching what was already there is selecting, and + * stays the human's. + * + * So the target of an operation that declares this is NAMED when the session's record says where it came + * from: the session brought it into the house and it is still what the session left, or the call repairs a + * proposal of that same session. Who decides that is not this class: it is the session's floor, reading + * facts, with no model in the circuit. What the record does not say keeps being asked. + * + * Defaults to `false` — fail-closed. The declaration speaks of a target, so it needs one; and an operation + * cannot both create its target and amend one that exists. Both are refused at declaration. + */ + public bool $amendsNamedTarget = false, ) { + if ($this->amendsNamedTarget && $this->namedTarget === null) { + throw new \InvalidArgumentException( + "Operation '{$this->name}' declares that it amends its named target and names none. The declaration " + . 'speaks of a target: declare namedTarget, or drop it.' + ); + } + if ($this->amendsNamedTarget && $this->createsNamedTarget) { + throw new \InvalidArgumentException( + "Operation '{$this->name}' declares that it creates its named target AND that it amends it. One that " + . 'creates its target is never asked about it; one that amends an existing target is asked unless the ' + . "session's record says where the target came from. Declare the one this operation does." + ); + } if ($this->citesReceiptsOf !== [] && $this->continues === null) { throw new \InvalidArgumentException( "Operation '{$this->name}' names receipts it may cite (" . implode(', ', $this->citesReceiptsOf) . ') ' diff --git a/tests/AnOperationSaysItAmendsItsNamedTargetTest.php b/tests/AnOperationSaysItAmendsItsNamedTargetTest.php new file mode 100644 index 0000000..d44a83b --- /dev/null +++ b/tests/AnOperationSaysItAmendsItsNamedTargetTest.php @@ -0,0 +1,92 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Tests; + +use Milpa\Command\Operation; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; + +/** + * An operation says it AMENDS the target it names — and says nothing it cannot mean (greenhouse decisions/0596). + * + * The intent contract's line was drawn by the verb: an operation that creates its named target is not asked «you did + * not name it», one that selects an existing target is. Of 34 times a house asked a person to confirm an `edit`, 28 + * were about a class that same session had brought into the house. So an operation can declare that it amends what + * its target names, and the session's floor reads from that session's own record where the target came from. + * + * The declaration is a statement about a target: it needs one, and it cannot sit beside the statement that the + * operation creates that same target. Both contradictions are refused where they are written, not reconciled by + * whoever reads them later. + */ +#[CoversClass(Operation::class)] +final class AnOperationSaysItAmendsItsNamedTargetTest extends TestCase +{ + public function testAnOperationDoesNotAmendUnlessItSaysSo(): void + { + $operation = new Operation('edit', 'Edits a class.', static fn (): array => [], namedTarget: 'class'); + + self::assertFalse($operation->amendsNamedTarget, 'fail-closed: silence keeps the question'); + } + + public function testAnOperationThatNamesNoTargetAndSaysNothingIsDeclaredAsBefore(): void + { + $operation = new Operation('list', 'Lists things.', static fn (): array => []); + + self::assertNull($operation->namedTarget, 'the control: most operations name no target, and say nothing about one'); + self::assertFalse($operation->amendsNamedTarget); + } + + public function testAnOperationThatSaysSoCarriesIt(): void + { + $operation = new Operation('edit', 'Edits a class.', static fn (): array => [], namedTarget: 'class', amendsNamedTarget: true); + + self::assertTrue($operation->amendsNamedTarget); + self::assertSame('class', $operation->namedTarget); + self::assertFalse($operation->createsNamedTarget, 'amending is not creating'); + } + + public function testItSurvivesACopyOfTheContract(): void + { + $operation = new Operation('edit', 'Edits a class.', static fn (): array => [], namedTarget: 'class', amendsNamedTarget: true); + + $copy = new Operation(...array_replace(get_object_vars($operation), ['description' => 'Edits a class, and says more.'])); + + self::assertTrue($copy->amendsNamedTarget, 'a host that rebuilds the contract keeps what it declared'); + } + + public function testAmendingNeedsATargetToSpeakOf(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Operation 'edit' declares that it amends its named target and names none"); + + new Operation('edit', 'Edits a class.', static fn (): array => [], amendsNamedTarget: true); + } + + public function testAnOperationCannotBothCreateItsTargetAndAmendOneThatExists(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Operation 'edit' declares that it creates its named target AND that it amends it"); + + new Operation('edit', 'Edits a class.', static fn (): array => [], namedTarget: 'class', createsNamedTarget: true, amendsNamedTarget: true); + } + + public function testCreatingAloneIsStillWhatItWas(): void + { + $operation = new Operation('implement', 'Fills a class.', static fn (): array => [], namedTarget: 'class', createsNamedTarget: true); + + self::assertTrue($operation->createsNamedTarget); + self::assertFalse($operation->amendsNamedTarget); + } +}