From f39576071f455b1c18f150a412d1375a1558c452 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 19 Jul 2026 21:27:46 -0400 Subject: [PATCH 1/3] feat(taxonomy): export Siren's full typed taxonomy; wire PHPStan lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Siren owns its domain vocabulary (global-taxonomy-and-sdk-doctrine); this SDK is the typed carrier of it so integrations never hand-roll magic strings. - Add EventSlug (built-in ingestion slugs) and status constant classes: ConversionStatus, TransactionStatus, ObligationStatus, PayoutStatus, FulfillmentStatus, OpportunityStatus, ApiKeyStatus, WebhookSubscriptionStatus. - WebhookEventType: add CREDIT_ISSUED, CREDIT_REDEEMED, CURRENCY_CREATED, CURRENCY_DELETED — dispatched by the service but missing from the SDK and openapi.yaml. - Pin the canonical vocabularies in tests/TaxonomyTest.php (mirrors the service's dispatchers and REST validations). - Wire phpstan (level 6) + phpnomad/phpstan-rules ^2.1 stack-elevator.neon as 'composer lint', declared in navigator.yaml ci.lint and enforced by a CI lint job; fix the 5 phpdoc-type findings it surfaced. Charter 761 (SDKs speak the taxonomy). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138jQKyzwyDfEbbAeMmYNnL --- .github/workflows/ci.yml | 21 ++++ CHANGELOG.md | 13 +++ README.md | 7 ++ composer.json | 5 +- navigator.yaml | 1 + openapi.yaml | 4 + phpstan.neon.dist | 17 +++ src/ApiKeyStatus.php | 18 ++++ src/ApiResponse.php | 2 + src/ConversionStatus.php | 24 +++++ src/EventSlug.php | 23 +++++ src/Exception/RateLimitException.php | 3 + src/Exception/SirenException.php | 9 +- src/FulfillmentStatus.php | 21 ++++ src/ListResult.php | 1 + src/ObligationStatus.php | 25 +++++ src/OpportunityStatus.php | 23 +++++ src/PayoutStatus.php | 21 ++++ src/TransactionStatus.php | 20 ++++ src/WebhookEventType.php | 4 + src/WebhookSubscriptionStatus.php | 19 ++++ tests/TaxonomyTest.php | 149 +++++++++++++++++++++++++++ tests/WebhooksTest.php | 3 +- 23 files changed, 430 insertions(+), 3 deletions(-) create mode 100644 phpstan.neon.dist create mode 100644 src/ApiKeyStatus.php create mode 100644 src/ConversionStatus.php create mode 100644 src/EventSlug.php create mode 100644 src/FulfillmentStatus.php create mode 100644 src/ObligationStatus.php create mode 100644 src/OpportunityStatus.php create mode 100644 src/PayoutStatus.php create mode 100644 src/TransactionStatus.php create mode 100644 src/WebhookSubscriptionStatus.php create mode 100644 tests/TaxonomyTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 517b1f6..5210ffb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,3 +48,24 @@ jobs: - name: Run test suite run: composer test + + lint: + name: Lint (PHPStan) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: json + coverage: none + tools: composer:v2 + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run PHPStan + run: composer lint diff --git a/CHANGELOG.md b/CHANGELOG.md index 12cbe4d..52a0c1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Typed taxonomy exports so integrations use SDK constants instead of magic + strings: `EventSlug` (built-in ingestion slugs) and the status vocabularies + `ConversionStatus`, `TransactionStatus`, `ObligationStatus`, `PayoutStatus`, + `FulfillmentStatus`, `OpportunityStatus`, `ApiKeyStatus`, and + `WebhookSubscriptionStatus`. +- `WebhookEventType`: added the missing `CREDIT_ISSUED`, `CREDIT_REDEEMED`, + `CURRENCY_CREATED`, and `CURRENCY_DELETED` events (also added to + `openapi.yaml`), matching the full set Siren dispatches. +- PHPStan static analysis (level 6) with the PHPNomad stack-elevator ruleset, + wired as `composer lint` and a CI lint job. + ## [0.1.0] - 2026-07-11 ### Added diff --git a/README.md b/README.md index 09c3471..12db904 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,13 @@ $secret = $subscription['signingSecret']; - **Reconciliation readers** — paginated iterators over conversions, transactions, obligations, and payouts. - **Automatic retries** — network errors, 429s, and 5xx are retried with exponential backoff on idempotent operations; management writes are never auto-retried. - **Typed exceptions** — every error extends `Siren\Sdk\Exception\SirenException` and carries the status code, error code, and error data. +- **Typed taxonomy** — Siren's domain vocabulary as constants, so no magic strings cross the boundary: `WebhookEventType`, `EventSlug`, and the status vocabularies (`ConversionStatus`, `TransactionStatus`, `ObligationStatus`, `PayoutStatus`, `FulfillmentStatus`, `OpportunityStatus`, `ApiKeyStatus`, `WebhookSubscriptionStatus`). + +```php +use Siren\Sdk\ConversionStatus; + +$approved = $siren->conversions->list(['status' => ConversionStatus::APPROVED]); +``` ### Configuration diff --git a/composer.json b/composer.json index afe959c..9164798 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,8 @@ "guzzlehttp/guzzle": "^7.8" }, "require-dev": { + "phpnomad/phpstan-rules": "^2.1", + "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^10.5" }, "autoload": { @@ -45,7 +47,8 @@ } }, "scripts": { - "test": "phpunit" + "test": "phpunit", + "lint": "phpstan analyse --no-progress" }, "config": { "sort-packages": true diff --git a/navigator.yaml b/navigator.yaml index 299602e..48b691b 100644 --- a/navigator.yaml +++ b/navigator.yaml @@ -2,6 +2,7 @@ initiative: siren ci: setup: composer install test: composer test + lint: composer lint knowledgeDependencies: - initiative: phpnomad description: "Org-wide engineering doctrine" diff --git a/openapi.yaml b/openapi.yaml index 7476aab..117ab6e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -403,6 +403,10 @@ components: - conversion.rejected - conversion.renewed - coupon.applied + - credit.issued + - credit.redeemed + - currency.created + - currency.deleted - distribution.completed - engagement.awarded - engagement.completed diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..9929173 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,17 @@ +includes: + # PHPNomad layer-purity ruleset (Stack Elevator: Core -> Service -> Platform). + # This SDK is a flat library (Siren\Sdk, no Core/Service segments), so the + # layer rules have nothing to flag today — they are wired in so that any + # future layered namespace is checked from day one. + - vendor/phpnomad/phpstan-rules/stack-elevator.neon + +parameters: + level: 6 + paths: + - src + treatPhpDocTypesAsCertain: false + phpNomadStackElevator: + namespaceRoots: + - Siren + - Novatorius + - PHPNomad diff --git a/src/ApiKeyStatus.php b/src/ApiKeyStatus.php new file mode 100644 index 0000000..966f56b --- /dev/null +++ b/src/ApiKeyStatus.php @@ -0,0 +1,18 @@ + */ public function json(): array { diff --git a/src/ConversionStatus.php b/src/ConversionStatus.php new file mode 100644 index 0000000..b2915ee --- /dev/null +++ b/src/ConversionStatus.php @@ -0,0 +1,24 @@ +|null $errorData + */ public function __construct( string $message, int $statusCode = 429, diff --git a/src/Exception/SirenException.php b/src/Exception/SirenException.php index 0b5f1a3..f054f79 100644 --- a/src/Exception/SirenException.php +++ b/src/Exception/SirenException.php @@ -13,6 +13,9 @@ */ class SirenException extends \RuntimeException { + /** + * @param array|null $errorData + */ public function __construct( string $message, private readonly int $statusCode = 0, @@ -35,7 +38,11 @@ public function getErrorCode(): ?string return $this->errorCode; } - /** Structured `error.data` from the response body, when present. */ + /** + * Structured `error.data` from the response body, when present. + * + * @return array|null + */ public function getErrorData(): ?array { return $this->errorData; diff --git a/src/FulfillmentStatus.php b/src/FulfillmentStatus.php new file mode 100644 index 0000000..ba3456d --- /dev/null +++ b/src/FulfillmentStatus.php @@ -0,0 +1,21 @@ +total; } + /** @return \ArrayIterator> */ public function getIterator(): \ArrayIterator { return new \ArrayIterator($this->data); diff --git a/src/ObligationStatus.php b/src/ObligationStatus.php new file mode 100644 index 0000000..671814c --- /dev/null +++ b/src/ObligationStatus.php @@ -0,0 +1,25 @@ + + */ + private function constantValues(string $class): array + { + $values = array_values((new ReflectionClass($class))->getConstants()); + sort($values); + + return $values; + } + + public function testWebhookEventTypeMatchesCanonicalSet(): void + { + $expected = [ + '*', + 'allocation.completed', + 'collaborator.created', + 'collaborator.registered', + 'conversion.approved', + 'conversion.created', + 'conversion.rejected', + 'conversion.renewed', + 'coupon.applied', + 'credit.issued', + 'credit.redeemed', + 'currency.created', + 'currency.deleted', + 'distribution.completed', + 'engagement.awarded', + 'engagement.completed', + 'engagement.created', + 'fulfillment.created', + 'fulfillment.updated', + 'lead.created', + 'metrics.updated', + 'obligation.completed', + 'obligation.created', + 'opportunity.created', + 'opportunity.invalidated', + 'payout.created', + 'payout.paid', + 'refund.created', + 'renewal.created', + 'sale.created', + 'transaction.completed', + 'transaction.created', + ]; + sort($expected); + + $this->assertSame($expected, $this->constantValues(WebhookEventType::class)); + } + + public function testWebhookEventTypeHasNoDuplicateValues(): void + { + $values = $this->constantValues(WebhookEventType::class); + + $this->assertSame($values, array_values(array_unique($values))); + } + + public function testEventSlugMatchesBuiltInIngestionSlugs(): void + { + $this->assertSame(['refund', 'sale', 'site-visited'], $this->constantValues(EventSlug::class)); + } + + public function testConversionStatusMatchesCanonicalSet(): void + { + $this->assertSame( + ['approved', 'deleted', 'expired', 'pending', 'rejected'], + $this->constantValues(ConversionStatus::class) + ); + } + + public function testTransactionStatusMatchesCanonicalSet(): void + { + $this->assertSame( + ['cancelled', 'complete', 'refunded'], + $this->constantValues(TransactionStatus::class) + ); + } + + public function testObligationStatusMatchesCanonicalSet(): void + { + $this->assertSame( + ['cancelled', 'complete', 'fulfilled', 'pending'], + $this->constantValues(ObligationStatus::class) + ); + } + + public function testPayoutStatusMatchesCanonicalSet(): void + { + $this->assertSame( + ['failed', 'paid', 'processing', 'unpaid'], + $this->constantValues(PayoutStatus::class) + ); + } + + public function testFulfillmentStatusMatchesCanonicalSet(): void + { + $this->assertSame( + ['complete', 'failed', 'pending', 'processing'], + $this->constantValues(FulfillmentStatus::class) + ); + } + + public function testOpportunityStatusMatchesCanonicalSet(): void + { + $this->assertSame( + ['active', 'inactive', 'invalid'], + $this->constantValues(OpportunityStatus::class) + ); + } + + public function testApiKeyStatusMatchesCanonicalSet(): void + { + $this->assertSame(['active', 'revoked'], $this->constantValues(ApiKeyStatus::class)); + } + + public function testWebhookSubscriptionStatusMatchesCanonicalSet(): void + { + $this->assertSame(['active', 'paused'], $this->constantValues(WebhookSubscriptionStatus::class)); + } +} diff --git a/tests/WebhooksTest.php b/tests/WebhooksTest.php index cc4e53a..c13c5d4 100644 --- a/tests/WebhooksTest.php +++ b/tests/WebhooksTest.php @@ -214,7 +214,8 @@ public function testEventTypeCatalogIsComplete(): void $constants = (new \ReflectionClass(WebhookEventType::class))->getConstants(); $this->assertSame('*', $constants['ALL']); - $this->assertCount(28, $constants, 'Expected 27 event types plus ALL.'); + // The full canonical set is pinned in TaxonomyTest. + $this->assertCount(32, $constants, 'Expected 31 event types plus ALL.'); foreach ($constants as $name => $value) { if ($name === 'ALL') { From b9ca30492de6f72d24fe4d5c0fc4f5d152db72b0 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 19 Jul 2026 21:30:47 -0400 Subject: [PATCH 2/3] docs(agents): reflect ci.lint in the verification contract Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138jQKyzwyDfEbbAeMmYNnL --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index b1e56b2..1e7f523 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,7 @@ after every change and leave them green: ```bash composer install # ci.setup composer test # ci.test +composer lint # ci.lint ``` What "done" means (PR audit, testing tiers, UAT proof) is defined in the KB: From 7529c5ef18dc6c5d23208468f25314d9d13a3ddd Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 19 Jul 2026 21:32:56 -0400 Subject: [PATCH 3/3] ci: let the PHP 8.1 test job ignore the dev-tooling platform requirement phpnomad/phpstan-rules ^2.1 requires PHP ^8.2 but is dev-only static analysis, never loaded at test runtime. The library still supports and tests PHP 8.1; lint runs on 8.3 with full platform checks. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138jQKyzwyDfEbbAeMmYNnL --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5210ffb..db3c781 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,11 @@ jobs: restore-keys: ${{ runner.os }}-php-${{ matrix.php }}- - name: Install dependencies - run: composer install --prefer-dist --no-interaction --no-progress + # phpnomad/phpstan-rules (dev-only static-analysis tooling, never loaded + # at test runtime) requires PHP ^8.2; the library itself supports 8.1. + # The 8.1 job ignores the platform check so the SDK + PHPUnit still run + # on the oldest supported PHP. The lint job runs on 8.3 with full checks. + run: composer install --prefer-dist --no-interaction --no-progress ${{ matrix.php == '8.1' && '--ignore-platform-req=php' || '' }} - name: Run test suite run: composer test