packages/deriver/tests/Unit/Deriver/Evaluator/Call/ExtensionsTest.php

1<?php
2
3declare (strict_types=1);
4
5namespace Tests\Unit\Deriver\Evaluator\Call;
6
7use Deriver\Deriver\DeriverConfig;
8use Deriver\Deriver\Evaluator\Call\Extensions;
9use Deriver\Deriver\Evaluator\Call\Registrations;
10use Deriver\Deriver\Evaluator\Domain\AbstractDomain;
11use Deriver\Deriver\Evaluator\Domain\DomainFact;
12use Deriver\Deriver\Evaluator\Domain\DomainLaws;
13use Deriver\Deriver\Evaluator\Domain\DomainOperations;
14use Deriver\Deriver\Evaluator\Intrinsic\IntrinsicDescriptor;
15use Deriver\Deriver\Evaluator\Intrinsic\PureIntrinsic;
16use Deriver\Deriver\Evaluator\Projection;
17use Deriver\Deriver\Evaluator\TargetProfile;
18use Deriver\Deriver\Evaluator\Term;
19use Deriver\Deriver\Evaluator\TermKind;
20use Deriver\Exception\InvalidInputException;
21use Override;
22use PHPUnit\Framework\Attributes\CoversClass;
23use PHPUnit\Framework\Attributes\DataProvider;
24use PHPUnit\Framework\Attributes\Small;
25use PHPUnit\Framework\Attributes\UsesClass;
26use PHPUnit\Framework\TestCase;
27
28/**
29 * @covers \Deriver\Deriver\Evaluator\Call\Extensions
30 */
31#[CoversClass(Extensions::class)]
32#[UsesClass(DomainLaws::class)]
33#[UsesClass(DomainFact::class)]
34#[UsesClass(DomainOperations::class)]
35#[UsesClass(IntrinsicDescriptor::class)]
36#[UsesClass(DeriverConfig::class)]
37#[UsesClass(TargetProfile::class)]
38#[UsesClass(Term::class)]
39#[Small]
40final class ExtensionsTest extends TestCase
41{
42    public function testRegistersVersionedOperationsAndDomains(): void
43    {
44        $extensions = new Extensions(new Registrations(intrinsics: [new class ('compose') implements PureIntrinsic {
45            /**
46             * @param string $operation compose, override, or uncertain-tags
47             */
48            public function __construct(public readonly string $operation)
49            {
50            }
51            /**
52             * @return IntrinsicDescriptor Explicit input dependencies
53             */
54            #[Override]
55            public function descriptor(): IntrinsicDescriptor
56            {
57                return new IntrinsicDescriptor('example.' . $this->operation, '1', 'policy.' . $this->operation, 2, [0, 1]);
58            }
59            /**
60             * @param list<Term> $arguments Policy operands
61             * @param TargetProfile $target Declared semantics
62             * @return Term Structured policy with independent fields
63             */
64            #[Override]
65            public function evaluate(array $arguments, TargetProfile $target): Term
66            {
67                [$left, $right] = $arguments;
68                if ($left->kind !== TermKind::Array || $right->kind !== TermKind::Array) {
69                    return new Term(TermKind::Intrinsic, 'policy.' . $this->operation, $arguments, ['type' => 'array']);
70                }
71                if ($this->operation === 'override') {
72                    return Term::array(array_replace($left->operands, $right->operands));
73                }
74                $ttl = $left->operands['ttl'] ?? Term::opaque('UNKNOWN_TTL');
75                $other = $right->operands['ttl'] ?? $ttl;
76                $ttl = is_int($ttl->literal) && is_int($other->literal) ? Term::constant(min($ttl->literal, $other->literal)) : new Term(TermKind::Intrinsic, 'policy.minimum', [$ttl, $other]);
77                $tags = $this->operation === 'uncertain-tags' ? Term::opaque('UNKNOWN_TAG_TRANSFORM') : Term::array(array_values([...$left->operands['tags']->operands ?? [], ...$right->operands['tags']->operands ?? []]));
78                return Term::array(['ttl' => $ttl, 'tags' => $tags]);
79            }
80        }], domains: [new class () implements AbstractDomain {
81            /**
82             * @return string Domain identity
83             */
84            #[Override]
85            public function id(): string
86            {
87                return 'example.policy';
88            }
89            /**
90             * @return string Semantic version
91             */
92            #[Override]
93            public function version(): string
94            {
95                return '1';
96            }
97            /**
98             * @return DomainFact Empty set
99             */
100            #[Override]
101            public function bottom(): DomainFact
102            {
103                return new DomainFact($this->id(), new Term(TermKind::Bottom));
104            }
105            /**
106             * @return DomainFact All policy records
107             */
108            #[Override]
109            public function top(): DomainFact
110            {
111                return new DomainFact($this->id(), new Term(TermKind::Top));
112            }
113            /**
114             * @param DomainFact $a Contained fact
115             * @param DomainFact $b Containing fact
116             * @return bool Flat-lattice inclusion
117             */
118            #[Override]
119            public function lessOrEqual(DomainFact $a, DomainFact $b): bool
120            {
121                return $a->representation->kind === TermKind::Bottom || $b->representation->kind === TermKind::Top || $a->representation->isConcrete() && $b->representation->isConcrete() && $a->representation->native() === $b->representation->native();
122            }
123            /**
124             * @param DomainFact $a First possibility
125             * @param DomainFact $b Second possibility
126             * @return DomainFact Inclusive alternative
127             */
128            #[Override]
129            public function join(DomainFact $a, DomainFact $b): DomainFact
130            {
131                if ($this->lessOrEqual($a, $b)) {
132                    return $b;
133                }
134                return $this->lessOrEqual($b, $a) ? $a : $this->top();
135            }
136            /**
137             * @param DomainFact $previous Previous approximation
138             * @param DomainFact $next New approximation
139             * @return DomainFact Finite widening
140             */
141            #[Override]
142            public function widen(DomainFact $previous, DomainFact $next): DomainFact
143            {
144                return $this->join($previous, $next);
145            }
146            /**
147             * @param DomainFact $value Policy record
148             * @param Projection $projection Selected fields
149             * @return DomainFact Projected fact
150             */
151            #[Override]
152            public function project(DomainFact $value, Projection $projection): DomainFact
153            {
154                $term = $value->representation;
155                foreach ($projection->path as $key) {
156                    $term = $term->operands[$key] ?? new Term(TermKind::Top);
157                }
158                return new DomainFact($this->id(), $term);
159            }
160        }]));
161        self::assertArrayHasKey('policy.compose', $extensions->intrinsics);
162        self::assertArrayHasKey('example.policy', $extensions->domains);
163        self::assertSame('1', $extensions->manifest['domain:example.policy']);
164    }
165    public function testRejectsDuplicateOperations(): void
166    {
167        $this->expectException(InvalidInputException::class);
168        new Extensions(new Registrations(intrinsics: [new class ('compose') implements PureIntrinsic {
169            /**
170             * @param string $operation compose, override, or uncertain-tags
171             */
172            public function __construct(public readonly string $operation)
173            {
174            }
175            /**
176             * @return IntrinsicDescriptor Explicit input dependencies
177             */
178            #[Override]
179            public function descriptor(): IntrinsicDescriptor
180            {
181                return new IntrinsicDescriptor('example.' . $this->operation, '1', 'policy.' . $this->operation, 2, [0, 1]);
182            }
183            /**
184             * @param list<Term> $arguments Policy operands
185             * @param TargetProfile $target Declared semantics
186             * @return Term Structured policy with independent fields
187             */
188            #[Override]
189            public function evaluate(array $arguments, TargetProfile $target): Term
190            {
191                [$left, $right] = $arguments;
192                if ($left->kind !== TermKind::Array || $right->kind !== TermKind::Array) {
193                    return new Term(TermKind::Intrinsic, 'policy.' . $this->operation, $arguments, ['type' => 'array']);
194                }
195                if ($this->operation === 'override') {
196                    return Term::array(array_replace($left->operands, $right->operands));
197                }
198                $ttl = $left->operands['ttl'] ?? Term::opaque('UNKNOWN_TTL');
199                $other = $right->operands['ttl'] ?? $ttl;
200                $ttl = is_int($ttl->literal) && is_int($other->literal) ? Term::constant(min($ttl->literal, $other->literal)) : new Term(TermKind::Intrinsic, 'policy.minimum', [$ttl, $other]);
201                $tags = $this->operation === 'uncertain-tags' ? Term::opaque('UNKNOWN_TAG_TRANSFORM') : Term::array(array_values([...$left->operands['tags']->operands ?? [], ...$right->operands['tags']->operands ?? []]));
202                return Term::array(['ttl' => $ttl, 'tags' => $tags]);
203            }
204        }, new class ('compose') implements PureIntrinsic {
205            /**
206             * @param string $operation compose, override, or uncertain-tags
207             */
208            public function __construct(public readonly string $operation)
209            {
210            }
211            /**
212             * @return IntrinsicDescriptor Explicit input dependencies
213             */
214            #[Override]
215            public function descriptor(): IntrinsicDescriptor
216            {
217                return new IntrinsicDescriptor('example.' . $this->operation, '1', 'policy.' . $this->operation, 2, [0, 1]);
218            }
219            /**
220             * @param list<Term> $arguments Policy operands
221             * @param TargetProfile $target Declared semantics
222             * @return Term Structured policy with independent fields
223             */
224            #[Override]
225            public function evaluate(array $arguments, TargetProfile $target): Term
226            {
227                [$left, $right] = $arguments;
228                if ($left->kind !== TermKind::Array || $right->kind !== TermKind::Array) {
229                    return new Term(TermKind::Intrinsic, 'policy.' . $this->operation, $arguments, ['type' => 'array']);
230                }
231                if ($this->operation === 'override') {
232                    return Term::array(array_replace($left->operands, $right->operands));
233                }
234                $ttl = $left->operands['ttl'] ?? Term::opaque('UNKNOWN_TTL');
235                $other = $right->operands['ttl'] ?? $ttl;
236                $ttl = is_int($ttl->literal) && is_int($other->literal) ? Term::constant(min($ttl->literal, $other->literal)) : new Term(TermKind::Intrinsic, 'policy.minimum', [$ttl, $other]);
237                $tags = $this->operation === 'uncertain-tags' ? Term::opaque('UNKNOWN_TAG_TRANSFORM') : Term::array(array_values([...$left->operands['tags']->operands ?? [], ...$right->operands['tags']->operands ?? []]));
238                return Term::array(['ttl' => $ttl, 'tags' => $tags]);
239            }
240        }]));
241    }
242    #[DataProvider('providerIntrinsicIdentityPairs')]
243    public function testRegistersDistinctIntrinsicContractsWithoutDelimiterCollisions(IntrinsicDescriptor $firstDescriptor, IntrinsicDescriptor $secondDescriptor): void
244    {
245        $first = self::createStub(PureIntrinsic::class);
246        $first->method('descriptor')->willReturn($firstDescriptor);
247        $second = self::createStub(PureIntrinsic::class);
248        $second->method('descriptor')->willReturn($secondDescriptor);
249        $a = new Extensions(new Registrations(intrinsics: [$first]));
250        $b = new Extensions(new Registrations(intrinsics: [$second]));
251        self::assertNotSame($a->manifest, $b->manifest);
252    }
253    /**
254     * @return iterable<string,array{IntrinsicDescriptor,IntrinsicDescriptor}>
255     */
256    public static function providerIntrinsicIdentityPairs(): iterable
257    {
258        $base = new IntrinsicDescriptor('example', '1', 'apply', 2, [0]);
259        yield 'separator' => [new IntrinsicDescriptor('example', '1:example', 'apply', 2, [0]), new IntrinsicDescriptor('example', '1', 'example:apply', 2, [0])];
260        yield 'id' => [$base, new IntrinsicDescriptor('other', '1', 'apply', 2, [0])];
261        yield 'version' => [$base, new IntrinsicDescriptor('example', '2', 'apply', 2, [0])];
262        yield 'operation' => [$base, new IntrinsicDescriptor('example', '1', 'other', 2, [0])];
263        yield 'arity' => [$base, new IntrinsicDescriptor('example', '1', 'apply', 3, [0])];
264        yield 'dependencies' => [$base, new IntrinsicDescriptor('example', '1', 'apply', 2, [1])];
265        yield 'all dependencies' => [$base, new IntrinsicDescriptor('example', '1', 'apply', 2, [0, 1])];
266    }
267    #[DataProvider('providerInvalidIntrinsic')]
268    public function testRejectsInvalidIntrinsicRegistrationBeforeAdmission(IntrinsicDescriptor $descriptor, string $message): void
269    {
270        $intrinsic = self::createStub(PureIntrinsic::class);
271        $intrinsic->method('descriptor')->willReturn($descriptor);
272        $this->expectException(InvalidInputException::class);
273        $this->expectExceptionMessage($message);
274        new Extensions(new Registrations(intrinsics: [$intrinsic]));
275    }
276    /**
277     * @return iterable<string,array{IntrinsicDescriptor,string}>
278     */
279    public static function providerInvalidIntrinsic(): iterable
280    {
281        yield 'empty id' => [new IntrinsicDescriptor('', '1', 'apply', 1, [0]), 'EVALUATOR_CONFLICT'];
282        yield 'empty version' => [new IntrinsicDescriptor('example', '', 'apply', 1, [0]), 'EVALUATOR_CONFLICT'];
283        yield 'empty operation' => [new IntrinsicDescriptor('example', '1', '', 1, [0]), 'EVALUATOR_CONFLICT'];
284        yield 'negative arity' => [new IntrinsicDescriptor('example', '1', 'apply', -1, []), 'EVALUATOR_CONFLICT'];
285        yield 'negative dependency' => [new IntrinsicDescriptor('example', '1', 'apply', 1, [-1]), 'EVALUATOR_CONTRACT_VIOLATION'];
286        yield 'dependency at arity' => [new IntrinsicDescriptor('example', '1', 'apply', 1, [1]), 'EVALUATOR_CONTRACT_VIOLATION'];
287    }
288    public function testRejectsARepeatedIdEvenWhenOperationsDiffer(): void
289    {
290        $first = self::createStub(PureIntrinsic::class);
291        $first->method('descriptor')->willReturn(new IntrinsicDescriptor('example', '1', 'first', 0, []));
292        $second = self::createStub(PureIntrinsic::class);
293        $second->method('descriptor')->willReturn(new IntrinsicDescriptor('example', '1', 'second', 0, []));
294        $this->expectException(InvalidInputException::class);
295        $this->expectExceptionMessage('repeated intrinsic operation or ID');
296        new Extensions(new Registrations(intrinsics: [$first, $second]));
297    }
298    public function testRegistersZeroArityAndTheLastValidDependency(): void
299    {
300        $first = self::createStub(PureIntrinsic::class);
301        $first->method('descriptor')->willReturn(new IntrinsicDescriptor('a', '1', 'first', 0, []));
302        $second = self::createStub(PureIntrinsic::class);
303        $second->method('descriptor')->willReturn(new IntrinsicDescriptor('z', '1', 'second', 2, [1]));
304        $extensions = new Extensions(new Registrations(intrinsics: [$second, $first]));
305        self::assertSame(['intrinsic:a', 'intrinsic:z'], array_keys($extensions->manifest));
306        self::assertSame(['second' => $second, 'first' => $first], $extensions->intrinsics);
307        self::assertSame([], $extensions->domains);
308    }
309    public function testRejectsARepeatedDomainIdentity(): void
310    {
311        $this->expectException(InvalidInputException::class);
312        $this->expectExceptionMessage('invalid or repeated domain identity');
313        new Extensions(new Registrations(domains: [new class () implements AbstractDomain {
314            /**
315             * @return string Domain identity
316             */
317            #[Override]
318            public function id(): string
319            {
320                return 'example.policy';
321            }
322            /**
323             * @return string Semantic version
324             */
325            #[Override]
326            public function version(): string
327            {
328                return '1';
329            }
330            /**
331             * @return DomainFact Empty set
332             */
333            #[Override]
334            public function bottom(): DomainFact
335            {
336                return new DomainFact($this->id(), new Term(TermKind::Bottom));
337            }
338            /**
339             * @return DomainFact All policy records
340             */
341            #[Override]
342            public function top(): DomainFact
343            {
344                return new DomainFact($this->id(), new Term(TermKind::Top));
345            }
346            /**
347             * @param DomainFact $a Contained fact
348             * @param DomainFact $b Containing fact
349             * @return bool Flat-lattice inclusion
350             */
351            #[Override]
352            public function lessOrEqual(DomainFact $a, DomainFact $b): bool
353            {
354                return $a->representation->kind === TermKind::Bottom || $b->representation->kind === TermKind::Top || $a->representation->isConcrete() && $b->representation->isConcrete() && $a->representation->native() === $b->representation->native();
355            }
356            /**
357             * @param DomainFact $a First possibility
358             * @param DomainFact $b Second possibility
359             * @return DomainFact Inclusive alternative
360             */
361            #[Override]
362            public function join(DomainFact $a, DomainFact $b): DomainFact
363            {
364                if ($this->lessOrEqual($a, $b)) {
365                    return $b;
366                }
367                return $this->lessOrEqual($b, $a) ? $a : $this->top();
368            }
369            /**
370             * @param DomainFact $previous Previous approximation
371             * @param DomainFact $next New approximation
372             * @return DomainFact Finite widening
373             */
374            #[Override]
375            public function widen(DomainFact $previous, DomainFact $next): DomainFact
376            {
377                return $this->join($previous, $next);
378            }
379            /**
380             * @param DomainFact $value Policy record
381             * @param Projection $projection Selected fields
382             * @return DomainFact Projected fact
383             */
384            #[Override]
385            public function project(DomainFact $value, Projection $projection): DomainFact
386            {
387                $term = $value->representation;
388                foreach ($projection->path as $key) {
389                    $term = $term->operands[$key] ?? new Term(TermKind::Top);
390                }
391                return new DomainFact($this->id(), $term);
392            }
393        }, new class () implements AbstractDomain {
394            /**
395             * @return string Domain identity
396             */
397            #[Override]
398            public function id(): string
399            {
400                return 'example.policy';
401            }
402            /**
403             * @return string Semantic version
404             */
405            #[Override]
406            public function version(): string
407            {
408                return '1';
409            }
410            /**
411             * @return DomainFact Empty set
412             */
413            #[Override]
414            public function bottom(): DomainFact
415            {
416                return new DomainFact($this->id(), new Term(TermKind::Bottom));
417            }
418            /**
419             * @return DomainFact All policy records
420             */
421            #[Override]
422            public function top(): DomainFact
423            {
424                return new DomainFact($this->id(), new Term(TermKind::Top));
425            }
426            /**
427             * @param DomainFact $a Contained fact
428             * @param DomainFact $b Containing fact
429             * @return bool Flat-lattice inclusion
430             */
431            #[Override]
432            public function lessOrEqual(DomainFact $a, DomainFact $b): bool
433            {
434                return $a->representation->kind === TermKind::Bottom || $b->representation->kind === TermKind::Top || $a->representation->isConcrete() && $b->representation->isConcrete() && $a->representation->native() === $b->representation->native();
435            }
436            /**
437             * @param DomainFact $a First possibility
438             * @param DomainFact $b Second possibility
439             * @return DomainFact Inclusive alternative
440             */
441            #[Override]
442            public function join(DomainFact $a, DomainFact $b): DomainFact
443            {
444                if ($this->lessOrEqual($a, $b)) {
445                    return $b;
446                }
447                return $this->lessOrEqual($b, $a) ? $a : $this->top();
448            }
449            /**
450             * @param DomainFact $previous Previous approximation
451             * @param DomainFact $next New approximation
452             * @return DomainFact Finite widening
453             */
454            #[Override]
455            public function widen(DomainFact $previous, DomainFact $next): DomainFact
456            {
457                return $this->join($previous, $next);
458            }
459            /**
460             * @param DomainFact $value Policy record
461             * @param Projection $projection Selected fields
462             * @return DomainFact Projected fact
463             */
464            #[Override]
465            public function project(DomainFact $value, Projection $projection): DomainFact
466            {
467                $term = $value->representation;
468                foreach ($projection->path as $key) {
469                    $term = $term->operands[$key] ?? new Term(TermKind::Top);
470                }
471                return new DomainFact($this->id(), $term);
472            }
473        }]));
474    }
475}
476