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