From 8441bad29d255698d34e6d87fee5dd8a9f5b5ff1 Mon Sep 17 00:00:00 2001 From: Eric Viana Date: Mon, 3 Aug 2026 20:36:43 -0300 Subject: [PATCH 1/5] feat(api-sync): deterministic spec-driven sync patcher, replacing the AI pipeline Adds scripts/api-sync.php, a dependency-free PHP patcher that reconciles this SDK against the OpenAPI spec by state (spec property/enum presence vs the SDK class that models it), not by diffing old vs new spec revisions. State reconciliation catches drift regardless of when it appeared; the old-vs-new diff is used only for removal detection (hard fail) and version-bump classification. New curated files: - .api-sync/spec-map.json: spec schema/enum -> SDK class mapping, including the bank-account (10 rails) and customer (3 KYC/KYB variants) discriminator fan-outs, verified by hand against src/. - .api-sync/unmodeled.json: every spec property on a mapped schema that is currently absent from the SDK, each with a specific reason and owner, so --check stays green without silently masking real gaps. - .api-sync/known-divergences.json: enum value mismatches where the SDK's case value doesn't match the spec's (e.g. BankAccountType's 'savings' vs the spec's 'saving', EstimatedAnnualRevenue's extra-digit typo) -- recorded rather than blindly patched, since the fix is correcting the existing case, not adding a near-duplicate one. Applies the pending drift found by state reconciliation against the current spec: refund_wallet_address on the quote-create input, plus 3 enum gaps (BankingPartner.portage, Currency.EUR, BusinessIndustry NAICS 446120) that predate this change and were invisible to the old AI pipeline's diff-only approach. Version bumped 3.0.0 -> 3.1.0 (enum additions require minor). CI: new api-sync-check job in main.yaml (state check, map validity, determinism proof, non-blocking coverage report). api-sync.yml rewritten to run the patcher instead of claude-code-action, with CLAUDE_CODE_OAUTH_TOKEN removed entirely. api-sync-merged.yml gains an auto-tag-release job that tags and pushes v$VERSION after an api-sync PR merges, guarded against double-tagging and against tagging when VERSION didn't change. Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs --- .api-sync/known-divergences.json | 37 + .api-sync/spec-map.json | 230 +++++ .api-sync/spec-snapshot.json | 162 +++- .api-sync/unmodeled.json | 604 +++++++++++++ .github/workflows/api-sync-merged.yml | 65 ++ .github/workflows/api-sync.yml | 126 +-- .github/workflows/main.yaml | 54 ++ .gitignore | 5 +- composer.json | 4 +- scripts/api-sync.php | 1164 +++++++++++++++++++++++++ src/BlindPay.php | 2 +- src/Resources/Quotes/Quotes.php | 4 +- src/Types/BankingPartner.php | 1 + src/Types/BusinessIndustry.php | 1 + src/Types/Currency.php | 1 + tests/ApiSync/ApiSyncTest.php | 619 +++++++++++++ 16 files changed, 3014 insertions(+), 65 deletions(-) create mode 100644 .api-sync/known-divergences.json create mode 100644 .api-sync/spec-map.json create mode 100644 .api-sync/unmodeled.json create mode 100644 scripts/api-sync.php create mode 100644 tests/ApiSync/ApiSyncTest.php diff --git a/.api-sync/known-divergences.json b/.api-sync/known-divergences.json new file mode 100644 index 0000000..672e306 --- /dev/null +++ b/.api-sync/known-divergences.json @@ -0,0 +1,37 @@ +{ + "$schema": "Recorded, reasoned, owned divergences that state reconciliation must not silently paper over. Two kinds: `enumValues` is a spec enum member whose value does not match any SDK case value even though the SDK models that enum (usually because an SDK case value has a typo/format bug) -- the fix is to CORRECT the existing case, not to add a near-duplicate new one, so scripts/api-sync.php treats a listed (enum, specValue) pair as satisfied rather than pending drift. `fields` is a spec property backed by a constrained enum in the spec but modeled as a plain untyped field in the SDK, so there is no case-completeness gap to begin with. Distinct from .api-sync/unmodeled.json, which is for properties absent from the SDK outright.", + "enumValues": [ + { + "enum": "BankAccountType", + "specValue": "saving", + "sdkCase": "SAVINGS", + "sdkValue": "savings", + "reason": "LIVE DEFECT (not fixed in this PR): the API's wire value is 'saving' (singular); this SDK's case value is 'savings' (plural), so a PHP backed enum currently serializes the wrong string and a real 'saving' account_type response would fail to parse via BankAccountType::from(). To be fixed in its own deliberate PR that corrects the case value directly instead of adding a second near-duplicate case.", + "owner": "eric@blindpay.com" + }, + { + "enum": "BankAccountType", + "specValue": null, + "sdkCase": "TED", + "sdkValue": "ted", + "reason": "SDK-only case with no counterpart in the spec's account_type enum (which only has checking/saving). Pre-existing, unrelated to account_type modeling; not touched here.", + "owner": "eric@blindpay.com" + }, + { + "enum": "EstimatedAnnualRevenue", + "specValue": "250000000_plus", + "sdkCase": "RANGE_2500000000_PLUS", + "sdkValue": "2500000000_plus", + "reason": "The API's top revenue bucket is '250000000_plus' (250 million); this SDK's case value has an extra digit, '2500000000_plus' (2.5 billion), so a real API response for the top bucket would fail to parse via EstimatedAnnualRevenue::from(). Same shape of bug as BankAccountType.saving/savings -- to be fixed in its own deliberate PR that corrects the case value, not by adding a second near-duplicate case.", + "owner": "eric@blindpay.com" + } + ], + "fields": [ + { + "schema": "CustomerOut", + "field": "kyc_status", + "reason": "Spec constrains kyc_status to an 8-value enum (verifying/approved/rejected/deprecated/pending_review/awaiting_contract/compliance_request/approved_rfi). This SDK models it as a plain `string` on BaseCustomer, not a backed enum, so every spec value already parses without error -- there is no case-completeness gap in code today. Recorded for visibility only.", + "owner": "eric@blindpay.com" + } + ] +} diff --git a/.api-sync/spec-map.json b/.api-sync/spec-map.json new file mode 100644 index 0000000..8f783ba --- /dev/null +++ b/.api-sync/spec-map.json @@ -0,0 +1,230 @@ +{ + "$schema": "Curated mapping from OpenAPI spec constructs to SDK symbols, used by scripts/api-sync.php for state reconciliation. `enums` pairs a spec enum-bearing property (or the webhooks map) with the SDK enum symbol that must contain every spec member. `types` pairs a spec schema (optionally a nested inline `path` within it, for shapes with no dedicated component schema) with the SDK class(es) that read/write it; `discriminator` is informational, documenting how a single flattened spec schema fans out into multiple SDK classes. `ignore.schemas` lists schemas this SDK deliberately does not model, with a reason. Verified by hand against src/ on 2026-08-03.", + "enums": [ + { + "spec": { "kind": "webhookTopics" }, + "sdk": { "file": "src/Resources/Webhooks/Webhooks.php", "class": "WebhookEvents" }, + "note": "contract-check.php Direction B hard-fails if a spec webhooks map key has no matching case value here." + }, + { + "spec": { "schema": "BankAccountOut", "property": "account_class" }, + "sdk": { "file": "src/Types/AccountClass.php", "class": "AccountClass" } + }, + { + "spec": { "schema": "CustomerOut", "property": "account_purpose" }, + "sdk": { "file": "src/Types/AccountPurpose.php", "class": "AccountPurpose" } + }, + { + "spec": { "schema": "CustomerOut", "property": "aml_status" }, + "sdk": { "file": "src/Types/AmlStatus.php", "class": "AmlStatus" } + }, + { + "spec": { "schema": "BankAccountOut", "property": "account_type" }, + "sdk": { "file": "src/Types/BankAccountType.php", "class": "BankAccountType" }, + "note": "KNOWN DIVERGENCE, see .api-sync/known-divergences.json: spec value 'saving' vs SDK case value 'savings'. Do not blind-add a new case for 'saving'; that would create a duplicate near-identical case instead of fixing the real one. Kept mapped on purpose so the divergence stays visible." + }, + { + "spec": { "schema": "VirtualAccountOut", "property": "banking_partner" }, + "sdk": { "file": "src/Types/BankingPartner.php", "class": "BankingPartner" } + }, + { + "spec": { "schema": "CustomerOut", "property": "business_industry" }, + "sdk": { "file": "src/Types/BusinessIndustry.php", "class": "BusinessIndustry" } + }, + { + "spec": { "schema": "CustomerOut", "property": "business_type" }, + "sdk": { "file": "src/Types/BusinessType.php", "class": "BusinessType" } + }, + { + "spec": { "schema": "CustomerOut", "property": "country" }, + "sdk": { "file": "src/Types/Country.php", "class": "Country" } + }, + { + "spec": { "operation": "POST /v1/instances/{instance_id}/payin-quotes/fx", "property": "from" }, + "sdk": { "file": "src/Types/Currency.php", "class": "Currency" }, + "note": "inline (non-$ref) request body; the widest currency enum in the spec, superset of the quotes/fx operation's from/to." + }, + { + "spec": { "schema": "QuoteIn", "property": "currency_type" }, + "sdk": { "file": "src/Types/CurrencyType.php", "class": "CurrencyType" } + }, + { + "spec": { "schema": "CustomerOut", "property": "estimated_annual_revenue" }, + "sdk": { "file": "src/Types/EstimatedAnnualRevenue.php", "class": "EstimatedAnnualRevenue" }, + "note": "KNOWN DIVERGENCE, see .api-sync/known-divergences.json: spec value '250000000_plus' vs SDK case value '2500000000_plus' (extra digit). Kept mapped on purpose so the divergence stays visible." + }, + { + "spec": { "schema": "WalletOut", "property": "network" }, + "sdk": { "file": "src/Types/Network.php", "class": "Network" } + }, + { + "spec": { "schema": "PayinOut", "property": "payment_method" }, + "sdk": { "file": "src/Types/PaymentMethod.php", "class": "PaymentMethod" } + }, + { + "spec": { "schema": "BankAccountOut", "property": "type" }, + "sdk": { "file": "src/Types/Rail.php", "class": "Rail" } + }, + { + "spec": { "schema": "BankAccountOut", "property": "recipient_relationship" }, + "sdk": { "file": "src/Types/RecipientRelationship.php", "class": "RecipientRelationship" } + }, + { + "spec": { "schema": "CreateVirtualAccountIn", "property": "sole_proprietor_doc_type" }, + "sdk": { "file": "src/Types/SoleProprietorDocType.php", "class": "SoleProprietorDocType" } + }, + { + "spec": { "schema": "CustomerOut", "property": "source_of_wealth" }, + "sdk": { "file": "src/Types/SourceOfWealth.php", "class": "SourceOfWealth" } + }, + { + "spec": { "schema": "VirtualAccountOut", "property": "token" }, + "sdk": { "file": "src/Types/StablecoinToken.php", "class": "StablecoinToken" } + }, + { + "spec": { "schema": "PayoutOut", "property": "transaction_document_type" }, + "sdk": { "file": "src/Types/TransactionDocumentType.php", "class": "TransactionDocumentType" } + }, + { + "spec": { "schema": "PayoutOut", "property": "status" }, + "sdk": { "file": "src/Types/TransactionStatus.php", "class": "TransactionStatus" }, + "note": "union of PayoutOut/PayinOut/TransferOut.status; SDK's PENDING_REVIEW case has no counterpart in any of the three today, which is fine (SDK may accept a superset)." + } + ], + "typesNote": "Class-per-file below always refers to src/Resources/**/*.php unless the file starts with src/Types/.", + "types": [ + { "spec": "BankAccountOut", "sdk": [ { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "BankAccountListItem" } ] }, + { + "spec": "CreateBankAccountIn", + "discriminator": { "property": "type", "values": ["pix", "transfers_bitso", "spei_bitso", "ach_cop_bitso", "ach", "wire", "international_swift", "rtp", "pix_safe", "sepa"] }, + "sdk": [ + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreatePixInput" }, + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreateArgentinaTransfersInput" }, + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreateSpeiInput" }, + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreateColombiaAchInput" }, + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreateAchInput" }, + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreateWireInput" }, + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreateInternationalSwiftInput" }, + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreateRtpInput" }, + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreatePixSafeInput" }, + { "file": "src/Resources/BankAccounts/BankAccounts.php", "class": "CreateSepaInput" } + ], + "note": "spec's `type` enum also has `ted`, which this SDK does not implement (no createTed method) -- a known, pre-existing coverage gap, not modeled here, tracked as a Phase C item." + }, + { + "spec": "CreateCustomerIn", + "discriminator": { "properties": ["type", "kyc_type"], "values": ["individual/standard", "individual/enhanced", "business/standard"] }, + "sdk": [ + { "file": "src/Resources/Customers/Customers.php", "class": "CreateIndividualWithStandardKYCInput" }, + { "file": "src/Resources/Customers/Customers.php", "class": "CreateIndividualWithEnhancedKYCInput" }, + { "file": "src/Resources/Customers/Customers.php", "class": "CreateBusinessWithStandardKYBInput" } + ], + "note": "spec's kyc_type also has `light`, and business kyc_type also has `enhanced`; neither is implemented by this SDK -- known, pre-existing coverage gaps, tracked as Phase C items." + }, + { + "spec": "CustomerOut", + "discriminator": { "properties": ["type", "kyc_type"], "dispatchedBy": "Customers::mapCustomer()" }, + "sdk": [ + { "file": "src/Resources/Customers/Customers.php", "class": "BaseCustomer" }, + { "file": "src/Resources/Customers/Customers.php", "class": "IndividualWithStandardKYC" }, + { "file": "src/Resources/Customers/Customers.php", "class": "IndividualWithEnhancedKYC" }, + { "file": "src/Resources/Customers/Customers.php", "class": "BusinessWithStandardKYB" } + ] + }, + { "spec": "UpdateCustomerIn", "sdk": [ { "file": "src/Resources/Customers/Customers.php", "class": "UpdateCustomerInput" } ] }, + { "spec": "CustomerLimitIncreaseIn", "sdk": [ { "file": "src/Resources/Customers/Customers.php", "class": "RequestLimitIncreaseInput" } ] }, + { "spec": "CustomerLimitIncreaseOut", "sdk": [ { "file": "src/Resources/Customers/Customers.php", "class": "RequestLimitIncreaseResponse" } ] }, + { "spec": "GetCustomerLimitIncreaseOut", "sdk": [ { "file": "src/Resources/Customers/Customers.php", "class": "LimitIncreaseRequest" } ] }, + { "spec": "GetCustomerLimitsOut", "sdk": [ { "file": "src/Resources/Customers/Customers.php", "class": "GetCustomerLimitsResponse" } ] }, + { "spec": "QuoteIn", "sdk": [ { "file": "src/Resources/Quotes/Quotes.php", "class": "CreateQuoteInput" } ] }, + { "spec": "QuoteOut", "sdk": [ { "file": "src/Resources/Quotes/Quotes.php", "class": "CreateQuoteResponse" } ] }, + { "spec": "CreatePayinQuoteIn", "sdk": [ { "file": "src/Resources/Payins/Quotes.php", "class": "CreatePayinQuoteInput" } ] }, + { "spec": "CreatePayinQuoteOut", "sdk": [ { "file": "src/Resources/Payins/Quotes.php", "class": "CreatePayinQuoteResponse" } ] }, + { "spec": "CreateTransferQuoteIn", "sdk": [ { "file": "src/Resources/Transfers/Transfers.php", "class": "CreateTransferQuoteInput" } ] }, + { "spec": "CreateTransferQuoteOut", "sdk": [ { "file": "src/Resources/Transfers/Transfers.php", "class": "CreateTransferQuoteResponse" } ] }, + { "spec": "CreateTransferIn", "sdk": [ { "file": "src/Resources/Transfers/Transfers.php", "class": "CreateTransferInput" } ] }, + { "spec": "CreateTransferOut", "sdk": [ { "file": "src/Resources/Transfers/Transfers.php", "class": "Transfer" } ], "note": "Transfers::create() deserializes via Transfer::fromArray, the same class used for TransferOut." }, + { "spec": "TransferOut", "sdk": [ { "file": "src/Resources/Transfers/Transfers.php", "class": "Transfer" } ] }, + { "spec": "PayinOut", "sdk": [ { "file": "src/Resources/Payins/Payins.php", "class": "Payin" } ] }, + { "spec": "CreatePayinOut", "sdk": [ { "file": "src/Resources/Payins/Payins.php", "class": "CreateEvmPayinResponse" } ] }, + { "spec": "PayoutOut", "sdk": [ { "file": "src/Resources/Payouts/Payouts.php", "class": "Payout" } ] }, + { + "spec": "PayoutOnEvmOut", + "discriminator": { "note": "one shared response shape returned by createStellar/createEvm/createSolana", "sdkMethods": ["createStellar", "createEvm", "createSolana"] }, + "sdk": [ + { "file": "src/Resources/Payouts/Payouts.php", "class": "CreateStellarPayoutResponse" }, + { "file": "src/Resources/Payouts/Payouts.php", "class": "CreateEvmPayoutResponse" }, + { "file": "src/Resources/Payouts/Payouts.php", "class": "CreateSolanaPayoutResponse" } + ] + }, + { + "spec": "PayoutOnEvmIn", + "sdk": [ + { "file": "src/Resources/Payouts/Payouts.php", "class": "CreateStellarPayoutInput" }, + { "file": "src/Resources/Payouts/Payouts.php", "class": "CreateEvmPayoutInput" }, + { "file": "src/Resources/Payouts/Payouts.php", "class": "CreateSolanaPayoutInput" } + ] + }, + { "spec": "PayoutOnStellarAuthorizeIn", "sdk": [ { "file": "src/Resources/Payouts/Payouts.php", "class": "AuthorizeStellarTokenInput" } ] }, + { "spec": "SubmitPayoutDocumentsIn", "sdk": [ { "file": "src/Resources/Payouts/Payouts.php", "class": "SubmitPayoutDocumentsInput" } ] }, + { + "spec": ["PayoutOut", "PayoutOnEvmOut"], + "path": "tracking_payment", + "sdk": [ { "file": "src/Types/TrackingPayment.php", "class": "TrackingPayment" } ], + "note": "inline object, not a $ref'd component schema (no `Tracking*` schema exists in components.schemas); the same 20-property shape also recurs verbatim, unchecked here, in PayoutNewWebhookOut/PayoutUpdateWebhookOut/PayoutCompleteWebhookOut/PayoutPartnerFeeWebhookOut, which this SDK never deserializes (see 'webhook payloads' note below)." + }, + { + "spec": "PayinOut", + "path": "tracking_payment", + "sdk": [ { "file": "src/Types/TrackingPayment.php", "class": "TrackingPayment" } ], + "note": "same SDK class as the payout-side tracking_payment, but PayinOut's inline tracking_payment is a DIFFERENT, smaller shape (step/provider_name/completed_at/review_source/review_context/approved_risk_sources) than the payout one. TrackingPayment models step/provider_name/completed_at plus 3 payout-only fields (provider_transaction_id/provider_status/estimated_time_of_arrival) that PayinOut's tracking_payment does not have." + }, + { "spec": "CreateVirtualAccountIn", "sdk": [ { "file": "src/Resources/VirtualAccounts/VirtualAccounts.php", "class": "CreateVirtualAccountInput" } ] }, + { "spec": "UpdateVirtualAccountIn", "sdk": [ { "file": "src/Resources/VirtualAccounts/VirtualAccounts.php", "class": "UpdateVirtualAccountInput" } ] }, + { "spec": "VirtualAccountOut", "sdk": [ { "file": "src/Resources/VirtualAccounts/VirtualAccounts.php", "class": "VirtualAccount" } ] }, + { + "spec": "CreateBlockchainWalletIn", + "discriminator": { "note": "body-shape discriminated: address vs signature hash", "sdkMethods": ["createWithAddress", "createWithHash"] }, + "sdk": [ + { "file": "src/Resources/Wallets/BlockchainWallets.php", "class": "CreateBlockchainWalletWithAddressInput" }, + { "file": "src/Resources/Wallets/BlockchainWallets.php", "class": "CreateBlockchainWalletWithHashInput" } + ] + }, + { "spec": "BlockchainWalletOut", "sdk": [ { "file": "src/Resources/Wallets/BlockchainWallets.php", "class": "BlockchainWallet" } ] }, + { "spec": "BlockchainWalletMessageOut", "sdk": [ { "file": "src/Resources/Wallets/BlockchainWallets.php", "class": "GetBlockchainWalletMessageResponse" } ] }, + { "spec": "OfframpWallet", "sdk": [ { "file": "src/Resources/Wallets/OfframpWallets.php", "class": "OfframpWallet" } ] }, + { "spec": "CreateWalletIn", "sdk": [ { "file": "src/Resources/CustodialWallets/CustodialWallets.php", "class": "CreateCustodialWalletInput" } ], "note": "`/customers/{id}/wallets` is the custodial-wallets resource, not offramp wallets -- do not confuse with CreateOfframpWalletInput." }, + { "spec": "WalletOut", "sdk": [ { "file": "src/Resources/CustodialWallets/CustodialWallets.php", "class": "CustodialWallet" } ] }, + { "spec": "WalletBalanceOut", "sdk": [ { "file": "src/Resources/CustodialWallets/CustodialWallets.php", "class": "CustodialWalletBalanceResponse" } ] }, + { "spec": "WalletTokenOut", "sdk": [ { "file": "src/Resources/CustodialWallets/CustodialWallets.php", "class": "WalletTokenBalance" } ], "note": "embedded under WalletBalanceOut.USDC/USDT/USDB and WalletInboundSchemaOut.token; a real $ref'd component, not inline." }, + { "spec": "WebhookEndpoint", "sdk": [ { "file": "src/Resources/Webhooks/Webhooks.php", "class": "WebhookEndpoint" } ] }, + { "spec": "WebhookEndpointIn", "sdk": [ { "file": "src/Resources/Webhooks/Webhooks.php", "class": "CreateWebhookEndpointInput" } ] }, + { "spec": "WebhookEndpointOut", "sdk": [ { "file": "src/Resources/Webhooks/Webhooks.php", "class": "CreateWebhookEndpointResponse" } ] }, + { "spec": "InitiateTosIn", "sdk": [ { "file": "src/Resources/TermsOfService/TermsOfService.php", "class": "InitiateInput" } ] }, + { "spec": "InitiateTosOut", "sdk": [ { "file": "src/Resources/TermsOfService/TermsOfService.php", "class": "InitiateResponse" } ] }, + { "spec": "PortalAccessOut", "sdk": [ { "file": "src/Resources/Webhooks/Webhooks.php", "class": "GetPortalAccessUrlResponse" } ] }, + { "spec": "MigrateInstanceOwnershipIn", "sdk": [ { "file": "src/Resources/Ownership/Ownership.php", "class": "MigrateInstanceOwnershipIn" } ] }, + { "spec": "UpdateInstanceIn", "sdk": [ { "file": "src/Resources/Instances/Instances.php", "class": "UpdateInstanceInput" } ] }, + { "spec": "UpdateInstanceMemberIn", "sdk": [ { "file": "src/Resources/Instances/Instances.php", "class": "UpdateMemberRoleInput" } ], "note": "class has no toArray; the `user_role` wire key is built inline in Instances::updateMemberRole(), not on this DTO." }, + { "spec": "GetFeesOut", "sdk": [ { "file": "src/Resources/Fees/Fees.php", "class": "FeesResponse" } ] }, + { "spec": "FeeOptions", "sdk": [ { "file": "src/Resources/Fees/Fees.php", "class": "FeeOptions" } ] }, + { "spec": "SwiftCodeItem", "sdk": [ { "file": "src/Resources/Available/Available.php", "class": "SwiftCodeBankDetails" } ], "note": "spec uses camelCase wire keys here, unlike the rest of the API; SDK matches it verbatim." }, + { "spec": "AvailableRails", "sdk": [ { "file": "src/Resources/Available/Available.php", "class": "RailInfo" } ] }, + { "spec": "AvailableBankDetails", "sdk": [ { "file": "src/Resources/Available/Available.php", "class": "BankDetail" } ] }, + { "spec": "AvailableNaicsList", "sdk": [ { "file": "src/Resources/Available/Available.php", "class": "NaicsCode" } ] }, + { "spec": "UploadIn", "sdk": [ { "file": "src/Resources/Upload/Upload.php", "class": "UploadInput" } ] }, + { "spec": "UploadOut", "sdk": [ { "file": "src/Resources/Upload/Upload.php", "class": "UploadResponse" } ] } + ], + "webhookPayloadsNote": "This SDK never deserializes webhook request bodies into typed classes -- BlindPay::verifyWebhookSignature() only verifies the signature and returns bool; callers get the raw payload. Consequently no `*WebhookOut` schema (BankAccountWebhookOut, BlockchainWalletWebhookOut, CustomerNewWebhookOut, CustomerUpdateWebhookOut, CustomerDeleteWebhookOut, PayinNewWebhookOut, PayinUpdateWebhookOut, PayinCompleteWebhookOut, PayinPartnerFeeWebhookOut, PayoutNewWebhookOut, PayoutUpdateWebhookOut, PayoutCompleteWebhookOut, PayoutPartnerFeeWebhookOut, LimitIncreaseNewWebhookOut, LimitIncreaseUpdateWebhookOut, TosAcceptWebhookOut) is mapped below; this is a structural fact about the SDK, not an oversight, and shows up as non-blocking coverage gaps rather than reconciliation failures.", + "ignore": { + "schemas": [ + { "schema": "LedgerOperation", "reason": "Unreferenced by any public operation or webhook (zero $ref anywhere in the spec) -- an orphan schema left over in components.schemas. Confirmed identical between .api-sync/spec-snapshot.json and the current spec." }, + { "schema": "Rfi", "reason": "RFI (request-for-information) resource is not implemented by this SDK. 4 operations, tracked as a Phase C item per the design doc." }, + { "schema": "RfiField", "reason": "Part of the unimplemented RFI resource, see Rfi." }, + { "schema": "RfiSection", "reason": "Part of the unimplemented RFI resource, see Rfi." }, + { "schema": "InstanceRfi", "reason": "Part of the unimplemented RFI resource, see Rfi." }, + { "schema": "UploadAnalyzeIn", "reason": "POST /v1/upload/analyze is not implemented by this SDK. Tracked as a Phase C item per the design doc." }, + { "schema": "UploadAnalyzeOut", "reason": "Part of the unimplemented upload/analyze operation, see UploadAnalyzeIn." } + ] + } +} diff --git a/.api-sync/spec-snapshot.json b/.api-sync/spec-snapshot.json index 2557f54..98de756 100644 --- a/.api-sync/spec-snapshot.json +++ b/.api-sync/spec-snapshot.json @@ -70,7 +70,7 @@ }, { "name": "Errors", - "description": "## Error responses\n\nEvery error response uses this envelope:\n\n```json\n{\n \"success\": false,\n \"code\": \"QUOTES_EXPIRED\",\n \"message\": \"quote_expired\",\n \"description\": \"This quote has expired. Request a new quote and try again.\",\n \"errors\": [],\n \"trace_id\": \"0af7651916cd43dd8448eb211c80319c\"\n}\n```\n\n- `code` is a stable identifier, safe to build programmatic handling on.\n- `message` is a legacy machine string kept for backward compatibility. Prefer `code`.\n- `description` is human-readable text, safe to show to end users.\n- `trace_id` identifies the request. Include it when contacting support.\n\nRetry semantics per code: `retryable` means the same request can be retried as is, `terminal` means do not retry, and `actionable` means the request succeeds only after you fix something (for example funding a balance or correcting bank details).\n\nThe Status column shows the HTTP status a code typically responds with; the per-endpoint error tabs list the exact status each endpoint returns.\n\n### Error codes\n\n| Code | Status | Retry | Description |\n| --- | --- | --- | --- |\n| `AUTH_EMAIL_ALREADY_REGISTERED` | 409 | terminal | This email address is already registered. |\n| `AUTH_FORBIDDEN` | 403 | terminal | You do not have permission to perform this action. |\n| `AUTH_IP_BLOCKED` | 403 | terminal | Requests from this IP address are not allowed for this API key. |\n| `AUTH_RATE_LIMITED` | 429 | retryable | Too many requests. Wait a moment and try again. |\n| `AUTH_SERVICE_UNAVAILABLE` | 503 | retryable | The authentication service is temporarily unavailable. Try again shortly. |\n| `AUTH_TOKEN_INVALID` | 401 | actionable | This link or token is invalid or has expired. Request a new one. |\n| `AUTH_UNAUTHORIZED` | 401 | actionable | Authentication failed. Check your API key or session and try again. |\n| `BANK_ACCOUNTS_INCOMPLETE` | 400 | actionable | The bank account details are incomplete for this payment method. Provide the missing fields. |\n| `BANK_ACCOUNTS_INVALID_BANK_CODE` | 400 | actionable | The bank code provided is not valid for this payment rail. The message lists where to fetch the valid codes. |\n| `BANK_ACCOUNTS_INVALID_ROUTING` | 400 | actionable | The routing number provided is not valid. Check the number and try again. |\n| `BANK_ACCOUNTS_NOT_APPROVED` | 403 | actionable | This bank account is not approved yet. |\n| `BANK_ACCOUNTS_NOT_FOUND` | 404 | actionable | The requested bank account was not found. |\n| `BANK_ACCOUNTS_PARTNER_NOT_SUPPORTED` | 400 | terminal | This banking option is not supported for this operation. |\n| `BANK_ACCOUNTS_RTP_NOT_SUPPORTED` | 400 | terminal | The provided routing number does not support instant payments. |\n| `BILLING_INVOICE_NOT_FOUND` | 404 | actionable | The requested invoice was not found. |\n| `BILLING_INVOICE_NOT_PAYABLE` | 409 | terminal | This invoice cannot be paid or collected in its current state. |\n| `BILLING_NOT_AVAILABLE` | 403 | terminal | Billing is not available for development instances. |\n| `BLOCKCHAIN_CALL_FAILED` | 400 | retryable | The blockchain call could not be completed. Check the transaction details and try again. |\n| `BLOCKCHAIN_NETWORK_NOT_SUPPORTED` | 400 | terminal | This network or token combination is not supported for this operation. |\n| `BLOCKCHAIN_TOKEN_NOT_SUPPORTED` | 400 | actionable | This token is not supported for this instance type. Development instances settle in USDB; production instances settle in USDC or USDT. |\n| `BLOCKCHAIN_TX_FAILED` | 502 | retryable | The blockchain transaction failed. Try again, and contact support if the problem persists. |\n| `COMPLIANCE_RFI_IN_PROGRESS` | 409 | actionable | An information request is already in progress. Resolve it before continuing. |\n| `COMPLIANCE_RFI_INVALID` | 400 | actionable | The information request could not be processed. Check the submitted fields. |\n| `CUSTOMERS_ALREADY_APPROVED` | 409 | terminal | This customer is already approved. |\n| `CUSTOMERS_BUSINESS_ENHANCED_NOT_AVAILABLE` | 400 | terminal | Enhanced verification is not available for business customers. Use standard verification (kyc_type: standard) for businesses. |\n| `CUSTOMERS_COUNTRY_NOT_SUPPORTED` | 400 | terminal | This country is not supported for this operation. |\n| `CUSTOMERS_ENHANCED_KYC_REQUIRED` | 400 | actionable | Individuals from high-risk countries must use enhanced verification (kyc_type: enhanced); standard verification is not available for them. See https://www.blindpay.com/docs/kb/supported-countries for country tiers. |\n| `CUSTOMERS_ID_DOCUMENT_INVALID` | 400 | actionable | The identity document is not an accepted passport or national ID. Provide a valid passport or national ID for this customer (a driver license is not accepted for high-risk countries). |\n| `CUSTOMERS_INVALID_DATA` | 422 | actionable | The customer data was rejected by the banking provider. Check the highlighted fields and try again. |\n| `CUSTOMERS_INVALID_PHONE` | 400 | actionable | The phone number provided is not valid. Check the number and try again. |\n| `CUSTOMERS_INVALID_TAX_ID` | 400 | actionable | The tax ID provided is not valid. Check the value and try again. |\n| `CUSTOMERS_KYC_NOT_APPROVED` | 403 | actionable | This customer has not completed verification yet. Complete the verification before continuing. |\n| `CUSTOMERS_NOT_FOUND` | 404 | actionable | The requested customer was not found. |\n| `CUSTOMERS_ONBOARDING_INCOMPLETE` | 400 | actionable | Onboarding is not complete yet. Finish the remaining steps before continuing. |\n| `DATA_INTEGRITY_ERROR` | 422 | terminal | This record could not be loaded because it contains invalid stored data. Contact support with the ID below. |\n| `FEES_NOT_FOUND` | 404 | actionable | The requested fee configuration was not found. |\n| `FEES_PARTNER_FEE_EXCEEDED` | 400 | actionable | The partner fee exceeds the allowed maximum for this transaction. |\n| `FILES_ANALYSIS_FAILED` | 502 | retryable | The document could not be analyzed. Try again, and contact support if the problem persists. |\n| `FILES_EMPTY` | 400 | actionable | The uploaded file is empty. Upload a valid file. |\n| `FILES_TOO_LARGE` | 400 | actionable | The file is too large. Upload a smaller file. |\n| `FILES_TYPE_NOT_ALLOWED` | 400 | actionable | This file type is not allowed. Upload a supported file type. |\n| `FILES_UNREADABLE` | 400 | actionable | We could not read the uploaded file. Check the file and try again. |\n| `INSTANCES_BLOCKED` | 403 | terminal | This account is blocked. Contact support for assistance. |\n| `INSTANCES_LIMIT_REACHED` | 400 | terminal | You reached the maximum number of instances allowed for your account. |\n| `INSTANCES_NOT_FOUND` | 404 | actionable | The requested instance was not found. |\n| `INTERNAL_ERROR` | 500 | retryable | Something went wrong on our side. Try again, and contact support with the trace ID if the problem persists. |\n| `LIMITS_AMOUNT_OUT_OF_RANGE` | 400 | actionable | The amount is outside the allowed range. The message includes the allowed range. |\n| `LIMITS_VOLUME_EXCEEDED` | 400 | actionable | You exceeded your transaction volume limit. The message includes the remaining amount available. |\n| `PAYINS_ALREADY_TERMINAL` | 409 | terminal | This payin already reached a final status and cannot be changed. |\n| `PAYINS_METHOD_NOT_SUPPORTED` | 400 | terminal | This payment method is not supported for this operation. |\n| `PAYINS_NOT_FOUND` | 404 | actionable | The requested payin was not found. |\n| `PAYINS_TAX_ID_REQUIRED` | 400 | actionable | A tax ID is required for this payment method. Provide the tax ID and try again. |\n| `PAYOUTS_ALLOWANCE_NOT_CONFIRMED` | 400 | retryable | The token approval for this payout has not been confirmed on-chain yet, or the approved amount is lower than the payout amount. Wait for the approval transaction to confirm and execute the quote again. |\n| `PAYOUTS_AMOUNT_BELOW_MINIMUM` | 400 | actionable | The amount is below the minimum for this payment rail. |\n| `PAYOUTS_DOCUMENTS_NOT_ACCEPTED` | 400 | terminal | Document upload is only available for international wire payouts. |\n| `PAYOUTS_INSUFFICIENT_BALANCE` | 400 | actionable | There is not enough balance to complete this payout. Fund your balance and try again. |\n| `PAYOUTS_NOT_FOUND` | 404 | actionable | The requested payout was not found. |\n| `QUOTES_ALREADY_USED` | 409 | actionable | This quote was already used. Request a new quote. |\n| `QUOTES_EXPIRED` | 400 | actionable | This quote has expired. Request a new quote and try again. |\n| `QUOTES_INSUFFICIENT_LIQUIDITY` | 400 | actionable | There is not enough market liquidity to fill this amount right now. Try a smaller amount or try again later. |\n| `QUOTES_INVALID_STATE` | 400 | actionable | This quote cannot be authorized in its current state. Request a new quote and try again. |\n| `QUOTES_NOT_FOUND` | 404 | actionable | The requested quote was not found. |\n| `QUOTES_OTC_NOT_SUPPORTED` | 400 | actionable | This OTC configuration is not supported. OTC requires BRL with USDT, and either the sender amount with fees not covered, or the receiver amount with fees covered. |\n| `QUOTES_RATE_UNAVAILABLE` | 502 | retryable | An exchange rate is temporarily unavailable. Try again shortly. |\n| `TOS_ALREADY_ACCEPTED` | 409 | terminal | These terms of service were already accepted. |\n| `TOS_NOT_ACCEPTED` | 400 | actionable | The terms of service must be accepted before continuing. |\n| `TOS_NOT_FOUND` | 404 | actionable | The requested terms of service record was not found. |\n| `TRANSFERS_NOT_ENABLED` | 403 | terminal | Wallets and transfers are not enabled for this account. Contact support to enable them. |\n| `VALIDATION_FAILED` | 400 | actionable | The request contains invalid fields. Check the errors list for details. |\n| `VALIDATION_INVALID_REQUEST` | 400 | actionable | The request is not valid for this operation. Check the parameters and try again. |\n| `VALIDATION_MISSING_BODY` | 400 | actionable | The request body is missing or malformed. Send valid JSON with the Content-Type: application/json header. |\n| `VALIDATION_MISSING_REQUIRED_FIELDS` | 400 | actionable | Required fields are missing. Provide the missing fields and try again. |\n| `VIRTUAL_ACCOUNTS_DISABLED` | 403 | terminal | Virtual accounts are not enabled for this account. Contact support to enable them. |\n| `VIRTUAL_ACCOUNTS_DOCUMENTS_REQUIRED` | 400 | actionable | Additional documents are required to create this virtual account. Upload the requested documents. |\n| `VIRTUAL_ACCOUNTS_NOT_APPROVED` | 403 | actionable | This virtual account is not approved yet. |\n| `VIRTUAL_ACCOUNTS_NOT_FOUND` | 404 | actionable | The requested virtual account was not found. |\n| `VIRTUAL_ACCOUNTS_PROVISION_FAILED` | 502 | retryable | We could not create the virtual account. Try again, and contact support if the problem persists. |\n| `VIRTUAL_ACCOUNTS_REGION_NOT_SUPPORTED` | 400 | terminal | Virtual accounts are not available for this country or region. |\n| `WALLETS_ATTACHED_TO_VIRTUAL_ACCOUNT` | 400 | terminal | This wallet is attached to a virtual account and cannot be deleted. Delete the virtual account first. |\n| `WALLETS_BALANCE_UNAVAILABLE` | 503 | retryable | The wallet balance is temporarily unavailable. Try again shortly. |\n| `WALLETS_NOT_FOUND` | 404 | actionable | The requested wallet was not found. |\n| `WEBHOOKS_LIMIT_REACHED` | 400 | terminal | You reached the maximum number of webhook endpoints. |\n| `WEBHOOKS_SIGNATURE_INVALID` | 401 | terminal | The webhook signature is missing or invalid. |\n| `WEBHOOKS_URL_INVALID` | 400 | actionable | The webhook URL is not valid. Use a reachable HTTPS URL. |\n" + "description": "## Error responses\n\nEvery error response uses this envelope:\n\n```json\n{\n \"success\": false,\n \"code\": \"QUOTES_EXPIRED\",\n \"message\": \"quote_expired\",\n \"description\": \"This quote has expired. Request a new quote and try again.\",\n \"errors\": [],\n \"trace_id\": \"0af7651916cd43dd8448eb211c80319c\"\n}\n```\n\n- `code` is a stable identifier, safe to build programmatic handling on.\n- `message` is a legacy machine string kept for backward compatibility. Prefer `code`.\n- `description` is human-readable text, safe to show to end users.\n- `trace_id` identifies the request. Include it when contacting support.\n\nRetry semantics per code: `retryable` means the same request can be retried as is, `terminal` means do not retry, and `actionable` means the request succeeds only after you fix something (for example funding a balance or correcting bank details).\n\nThe Status column shows the HTTP status a code typically responds with; the per-endpoint error tabs list the exact status each endpoint returns.\n\n### Error codes\n\n| Code | Status | Retry | Description |\n| --- | --- | --- | --- |\n| `AUTH_EMAIL_ALREADY_REGISTERED` | 409 | terminal | This email address is already registered. |\n| `AUTH_FORBIDDEN` | 403 | terminal | You do not have permission to perform this action. |\n| `AUTH_IP_BLOCKED` | 403 | terminal | Requests from this IP address are not allowed for this API key. |\n| `AUTH_RATE_LIMITED` | 429 | retryable | Too many requests. Wait a moment and try again. |\n| `AUTH_SERVICE_UNAVAILABLE` | 503 | retryable | The authentication service is temporarily unavailable. Try again shortly. |\n| `AUTH_TOKEN_INVALID` | 401 | actionable | This link or token is invalid or has expired. Request a new one. |\n| `AUTH_UNAUTHORIZED` | 401 | actionable | Authentication failed. Check your API key or session and try again. |\n| `BANK_ACCOUNTS_INCOMPLETE` | 400 | actionable | The bank account details are incomplete for this payment method. Provide the missing fields. |\n| `BANK_ACCOUNTS_INVALID_BANK_CODE` | 400 | actionable | The bank code provided is not valid for this payment rail. The message lists where to fetch the valid codes. |\n| `BANK_ACCOUNTS_INVALID_ROUTING` | 400 | actionable | The routing number provided is not valid. Check the number and try again. |\n| `BANK_ACCOUNTS_NOT_APPROVED` | 403 | actionable | This bank account is not approved yet. |\n| `BANK_ACCOUNTS_NOT_FOUND` | 404 | actionable | The requested bank account was not found. |\n| `BANK_ACCOUNTS_PARTNER_NOT_SUPPORTED` | 400 | terminal | This banking option is not supported for this operation. |\n| `BANK_ACCOUNTS_RTP_NOT_SUPPORTED` | 400 | terminal | The provided routing number does not support instant payments. |\n| `BILLING_INVOICE_NOT_FOUND` | 404 | actionable | The requested invoice was not found. |\n| `BILLING_INVOICE_NOT_PAYABLE` | 409 | terminal | This invoice cannot be paid or collected in its current state. |\n| `BILLING_NOT_AVAILABLE` | 403 | terminal | Billing is not available for development instances. |\n| `BLOCKCHAIN_CALL_FAILED` | 400 | retryable | The blockchain call could not be completed. Check the transaction details and try again. |\n| `BLOCKCHAIN_NETWORK_NOT_SUPPORTED` | 400 | terminal | This network or token combination is not supported for this operation. |\n| `BLOCKCHAIN_TOKEN_NOT_SUPPORTED` | 400 | actionable | This token is not supported for this instance type. Development instances settle in USDB; production instances settle in USDC or USDT. |\n| `BLOCKCHAIN_TX_FAILED` | 502 | retryable | The blockchain transaction failed. Try again, and contact support if the problem persists. |\n| `COMPLIANCE_RFI_IN_PROGRESS` | 409 | actionable | An information request is already in progress. Resolve it before continuing. |\n| `COMPLIANCE_RFI_INVALID` | 400 | actionable | The information request could not be processed. Check the submitted fields. |\n| `CUSTOMERS_ALREADY_APPROVED` | 409 | terminal | This customer is already approved. |\n| `CUSTOMERS_BUSINESS_ENHANCED_NOT_AVAILABLE` | 400 | terminal | Enhanced verification is not available for business customers. Use standard verification (kyc_type: standard) for businesses. |\n| `CUSTOMERS_COUNTRY_NOT_SUPPORTED` | 400 | terminal | This country is not supported for this operation. |\n| `CUSTOMERS_ENHANCED_KYC_REQUIRED` | 400 | actionable | Individuals from high-risk countries must use enhanced verification (kyc_type: enhanced); standard verification is not available for them. See https://www.blindpay.com/docs/kb/supported-countries for country tiers. |\n| `CUSTOMERS_ID_DOCUMENT_INVALID` | 400 | actionable | The identity document is not an accepted passport or national ID. Provide a valid passport or national ID for this customer (a driver license is not accepted for high-risk countries). |\n| `CUSTOMERS_INVALID_DATA` | 422 | actionable | The customer data was rejected by the banking provider. Check the highlighted fields and try again. |\n| `CUSTOMERS_INVALID_PHONE` | 400 | actionable | The phone number provided is not valid. Check the number and try again. |\n| `CUSTOMERS_INVALID_TAX_ID` | 400 | actionable | The tax ID provided is not valid. Check the value and try again. |\n| `CUSTOMERS_KYC_NOT_APPROVED` | 403 | actionable | This customer has not completed verification yet. Complete the verification before continuing. |\n| `CUSTOMERS_NOT_FOUND` | 404 | actionable | The requested customer was not found. |\n| `CUSTOMERS_ONBOARDING_INCOMPLETE` | 400 | actionable | Onboarding is not complete yet. Finish the remaining steps before continuing. |\n| `DATA_INTEGRITY_ERROR` | 422 | terminal | This record could not be loaded because it contains invalid stored data. Contact support with the ID below. |\n| `FEES_NOT_FOUND` | 404 | actionable | The requested fee configuration was not found. |\n| `FEES_PARTNER_FEE_EXCEEDED` | 400 | actionable | The partner fee exceeds the allowed maximum for this transaction. |\n| `FILES_ANALYSIS_FAILED` | 502 | retryable | The document could not be analyzed. Try again, and contact support if the problem persists. |\n| `FILES_EMPTY` | 400 | actionable | The uploaded file is empty. Upload a valid file. |\n| `FILES_TOO_LARGE` | 400 | actionable | The file is too large. Upload a smaller file. |\n| `FILES_TYPE_NOT_ALLOWED` | 400 | actionable | This file type is not allowed. Upload a supported file type. |\n| `FILES_UNREADABLE` | 400 | actionable | We could not read the uploaded file. Check the file and try again. |\n| `INSTANCES_BLOCKED` | 403 | terminal | This account is blocked. Contact support for assistance. |\n| `INSTANCES_LIMIT_REACHED` | 400 | terminal | You reached the maximum number of instances allowed for your account. |\n| `INSTANCES_NOT_FOUND` | 404 | actionable | The requested instance was not found. |\n| `INTERNAL_ERROR` | 500 | retryable | Something went wrong on our side. Try again, and contact support with the trace ID if the problem persists. |\n| `LIMITS_AMOUNT_OUT_OF_RANGE` | 400 | actionable | The amount is outside the allowed range. The message includes the allowed range. |\n| `LIMITS_VOLUME_EXCEEDED` | 400 | actionable | You exceeded your transaction volume limit. The message includes the remaining amount available. |\n| `PAYINS_ALREADY_TERMINAL` | 409 | terminal | This payin already reached a final status and cannot be changed. |\n| `PAYINS_METHOD_NOT_SUPPORTED` | 400 | terminal | This payment method is not supported for this operation. |\n| `PAYINS_NOT_FOUND` | 404 | actionable | The requested payin was not found. |\n| `PAYINS_TAX_ID_REQUIRED` | 400 | actionable | A tax ID is required for this payment method. Provide the tax ID and try again. |\n| `PAYOUTS_ALLOWANCE_NOT_CONFIRMED` | 400 | retryable | The token approval for this payout has not been confirmed on-chain yet, or the approved amount is lower than the payout amount. Wait for the approval transaction to confirm and execute the quote again. |\n| `PAYOUTS_AMOUNT_BELOW_MINIMUM` | 400 | actionable | The amount is below the minimum for this payment rail. |\n| `PAYOUTS_DOCUMENTS_NOT_ACCEPTED` | 400 | terminal | Document upload is only available for international wire payouts. |\n| `PAYOUTS_INSUFFICIENT_BALANCE` | 400 | actionable | There is not enough balance to complete this payout. Fund your balance and try again. |\n| `PAYOUTS_NOT_FOUND` | 404 | actionable | The requested payout was not found. |\n| `QUOTES_ALREADY_USED` | 409 | actionable | This quote was already used. Request a new quote. |\n| `QUOTES_EXPIRED` | 400 | actionable | This quote has expired. Request a new quote and try again. |\n| `QUOTES_INSUFFICIENT_LIQUIDITY` | 400 | actionable | There is not enough market liquidity to fill this amount right now. Try a smaller amount or try again later. |\n| `QUOTES_INVALID_STATE` | 400 | actionable | This quote cannot be authorized in its current state. Request a new quote and try again. |\n| `QUOTES_NOT_FOUND` | 404 | actionable | The requested quote was not found. |\n| `QUOTES_OTC_NOT_SUPPORTED` | 400 | actionable | This OTC configuration is not supported. OTC requires BRL with USDT, and either the sender amount with fees not covered, or the receiver amount with fees covered. |\n| `QUOTES_RATE_UNAVAILABLE` | 502 | retryable | An exchange rate is temporarily unavailable. Try again shortly. |\n| `REQUEST_REJECTED` | 400 | actionable | The request was rejected. Check the error message for details. |\n| `TOS_ALREADY_ACCEPTED` | 409 | terminal | These terms of service were already accepted. |\n| `TOS_NOT_ACCEPTED` | 400 | actionable | The terms of service must be accepted before continuing. |\n| `TOS_NOT_FOUND` | 404 | actionable | The requested terms of service record was not found. |\n| `TRANSFERS_NOT_ENABLED` | 403 | terminal | Wallets and transfers are not enabled for this account. Contact support to enable them. |\n| `VALIDATION_FAILED` | 400 | actionable | The request contains invalid fields. Check the errors list for details. |\n| `VALIDATION_INVALID_REQUEST` | 400 | actionable | The request is not valid for this operation. Check the parameters and try again. |\n| `VALIDATION_MISSING_BODY` | 400 | actionable | The request body is missing or malformed. Send valid JSON with the Content-Type: application/json header. |\n| `VALIDATION_MISSING_REQUIRED_FIELDS` | 400 | actionable | Required fields are missing. Provide the missing fields and try again. |\n| `VIRTUAL_ACCOUNTS_DISABLED` | 403 | terminal | Virtual accounts are not enabled for this account. Contact support to enable them. |\n| `VIRTUAL_ACCOUNTS_DOCUMENTS_REQUIRED` | 400 | actionable | Additional documents are required to create this virtual account. Upload the requested documents. |\n| `VIRTUAL_ACCOUNTS_NOT_APPROVED` | 403 | actionable | This virtual account is not approved yet. |\n| `VIRTUAL_ACCOUNTS_NOT_FOUND` | 404 | actionable | The requested virtual account was not found. |\n| `VIRTUAL_ACCOUNTS_PROVISION_FAILED` | 502 | retryable | We could not create the virtual account. Try again, and contact support if the problem persists. |\n| `VIRTUAL_ACCOUNTS_REGION_NOT_SUPPORTED` | 400 | terminal | Virtual accounts are not available for this country or region. |\n| `WALLETS_ATTACHED_TO_VIRTUAL_ACCOUNT` | 400 | terminal | This wallet is attached to a virtual account and cannot be deleted. Delete the virtual account first. |\n| `WALLETS_BALANCE_UNAVAILABLE` | 503 | retryable | The wallet balance is temporarily unavailable. Try again shortly. |\n| `WALLETS_NOT_FOUND` | 404 | actionable | The requested wallet was not found. |\n| `WEBHOOKS_LIMIT_REACHED` | 400 | terminal | You reached the maximum number of webhook endpoints. |\n| `WEBHOOKS_SIGNATURE_INVALID` | 401 | terminal | The webhook signature is missing or invalid. |\n| `WEBHOOKS_URL_INVALID` | 400 | actionable | The webhook URL is not valid. Use a reachable HTTPS URL. |\n" } ], "security": [ @@ -3250,6 +3250,11 @@ "example": "bh_swift_afl" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -4747,9 +4752,19 @@ "example": "re_000000000000" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -4871,9 +4886,19 @@ "example": "re_000000000000" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -10526,9 +10551,19 @@ } }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "limit": { @@ -12567,9 +12602,19 @@ } }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "limit": { @@ -14238,9 +14283,19 @@ "example": "f06dbc45-58a4-389f-beb8-581c3fafff3c" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "image_url": { @@ -17355,6 +17410,11 @@ "example": "abcd1234" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -17451,6 +17511,11 @@ ] }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -17625,9 +17690,19 @@ "$ref": "#/components/schemas/FeeOptions" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -17944,9 +18019,19 @@ "example": "in_000000000000" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -18090,9 +18175,19 @@ "example": "in_000000000000" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -18604,9 +18699,19 @@ "example": null }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "image_url": { @@ -21188,9 +21293,19 @@ ] }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "image_url": { @@ -21937,9 +22052,19 @@ "example": "re_000000000000" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -22012,9 +22137,19 @@ "example": "TALJN9zTTEL9TVBb4WuTt6wLvPqJZr3hvb" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -22077,6 +22212,11 @@ "example": "polygon" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -25360,6 +25500,11 @@ "example": "bh_swift_afl" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -32561,9 +32706,19 @@ } }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "limit": { @@ -35656,6 +35811,11 @@ "example": "https://example.com/image.png" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "has_passkey": { diff --git a/.api-sync/unmodeled.json b/.api-sync/unmodeled.json new file mode 100644 index 0000000..a4364c5 --- /dev/null +++ b/.api-sync/unmodeled.json @@ -0,0 +1,604 @@ +{ + "$schema": "Seeded state-reconciliation baseline: every spec property, on a schema mapped in .api-sync/spec-map.json, that scripts/api-sync.php currently finds absent from its mapped SDK class(es). Each entry needs a specific reason and an owner. `--check` treats anything absent from both the SDK and this file as pending drift and fails. Do not add an entry for a field this PR's changelist is adding instead.", + "entries": [ + { + "schema": "BankAccountOut", + "field": "business_industry", + "reason": "Compliance metadata captured on ACH/wire/RTP/SWIFT bank-account creation; the shared list/get response type (BankAccountListItem) does not echo it back.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "date_of_birth", + "reason": "Beneficiary date of birth captured on ACH/wire/RTP/SWIFT bank-account creation; not echoed back on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "phone_number", + "reason": "Beneficiary phone number captured on ACH/wire/RTP/SWIFT bank-account creation; not echoed back on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "tax_id", + "reason": "Beneficiary tax ID captured on ACH/wire/RTP/SWIFT bank-account creation; not echoed back on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "recipient_relationship", + "reason": "Modeled on the rail-specific create Input/Response classes (ACH/wire/RTP/SWIFT) but not surfaced on the shared list/get response type (BankAccountListItem).", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "status", + "reason": "Bank account verification status (verifying/approved/rejected/deprecated); not modeled on any bank-account response type in this SDK.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "pix_safe_bank_code", + "reason": "PIX Safe rail field modeled on create (CreatePixSafeInput/CreatePixSafeResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "pix_safe_branch_code", + "reason": "PIX Safe rail field modeled on create (CreatePixSafeInput/CreatePixSafeResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "pix_safe_cpf_cnpj", + "reason": "PIX Safe rail field modeled on create (CreatePixSafeInput/CreatePixSafeResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "sepa_beneficiary_address_line_1", + "reason": "SEPA rail field modeled on create (CreateSepaInput/CreateSepaResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "sepa_beneficiary_address_line_2", + "reason": "SEPA rail field modeled on create (CreateSepaInput/CreateSepaResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "sepa_beneficiary_city", + "reason": "SEPA rail field modeled on create (CreateSepaInput/CreateSepaResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "sepa_beneficiary_country", + "reason": "SEPA rail field modeled on create (CreateSepaInput/CreateSepaResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "sepa_beneficiary_legal_name", + "reason": "SEPA rail field modeled on create (CreateSepaInput/CreateSepaResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "sepa_beneficiary_postal_code", + "reason": "SEPA rail field modeled on create (CreateSepaInput/CreateSepaResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "sepa_beneficiary_state_province_region", + "reason": "SEPA rail field modeled on create (CreateSepaInput/CreateSepaResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "sepa_iban", + "reason": "SEPA rail field modeled on create (CreateSepaInput/CreateSepaResponse) but not surfaced on the shared list/get response type.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "swift_ifsc_branch_code", + "reason": "India-specific SWIFT branch code; not modeled on any SWIFT bank-account type in this SDK.", + "owner": "eric@blindpay.com" + }, + { + "schema": "BankAccountOut", + "field": "swift_payment_code", + "reason": "SWIFT payment/purpose code; not modeled on any SWIFT bank-account type in this SDK.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateBankAccountIn", + "field": "checkbook_account_id", + "reason": "Internal payment-provider account-linkage field; not exposed as a constructor parameter on any rail's create Input class.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateBankAccountIn", + "field": "checkbook_user_key", + "reason": "Internal payment-provider account-linkage field; not exposed as a constructor parameter on any rail's create Input class.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateBankAccountIn", + "field": "onemoney_external_account_id", + "reason": "Internal payment-provider account-linkage field; not exposed as a constructor parameter on any rail's create Input class.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateBankAccountIn", + "field": "force_cpf_cnpj", + "reason": "Brazil-specific override flag; not exposed as a constructor parameter on any rail's create Input class.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateBankAccountIn", + "field": "status", + "reason": "Bank account status is server-assigned on creation, not client-set; no create Input class accepts it.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateBankAccountIn", + "field": "swift_ifsc_branch_code", + "reason": "India-specific SWIFT branch code; not modeled on CreateInternationalSwiftInput.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateCustomerIn", + "field": "additional_info", + "reason": "Free-form key/value compliance metadata; not exposed as a constructor parameter on any of the 3 create Input classes.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateCustomerIn", + "field": "latitude", + "reason": "Geolocation captured at KYC submission time; not exposed as a constructor parameter on any of the 3 create Input classes.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateCustomerIn", + "field": "longitude", + "reason": "Geolocation captured at KYC submission time; not exposed as a constructor parameter on any of the 3 create Input classes.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CustomerOut", + "field": "additional_info", + "reason": "Free-form key/value compliance metadata; not modeled on BaseCustomer or any of its 3 subclasses.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CustomerOut", + "field": "customer_id", + "reason": "Response envelope carries both `id` (modeled) and a duplicate `customer_id`; not modeled on BaseCustomer.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CustomerOut", + "field": "kyc_type", + "reason": "Used by Customers::mapCustomer() to select which subclass to instantiate, but not itself stored as a field on any subclass afterwards.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CustomerOut", + "field": "latitude", + "reason": "Geolocation captured at KYC submission time; not modeled on BaseCustomer or any of its 3 subclasses.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CustomerOut", + "field": "longitude", + "reason": "Geolocation captured at KYC submission time; not modeled on BaseCustomer or any of its 3 subclasses.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CustomerOut", + "field": "type", + "reason": "Used by Customers::mapCustomer() to select which subclass to instantiate, but not itself stored as a field on any subclass afterwards.", + "owner": "eric@blindpay.com" + }, + { + "schema": "UpdateCustomerIn", + "field": "additional_info", + "reason": "Free-form key/value compliance metadata; not exposed as a constructor parameter on UpdateCustomerInput.", + "owner": "eric@blindpay.com" + }, + { + "schema": "UpdateCustomerIn", + "field": "latitude", + "reason": "Geolocation; not exposed as a constructor parameter on UpdateCustomerInput.", + "owner": "eric@blindpay.com" + }, + { + "schema": "UpdateCustomerIn", + "field": "longitude", + "reason": "Geolocation; not exposed as a constructor parameter on UpdateCustomerInput.", + "owner": "eric@blindpay.com" + }, + { + "schema": "QuoteOut", + "field": "billing_fee_amount", + "reason": "Fee breakdown field; CreateQuoteResponse models flat_fee and partner_fee_amount but not this separate billing_fee_amount.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreatePayinQuoteOut", + "field": "billing_fee_amount", + "reason": "Fee breakdown field; CreatePayinQuoteResponse models flat_fee and partner_fee_amount but not this separate billing_fee_amount.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreatePayinQuoteOut", + "field": "is_otc", + "reason": "OTC (over-the-counter) quote flag; not modeled on CreatePayinQuoteResponse.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayinOut", + "field": "manual_concluded_at", + "reason": "Manual-review audit timestamp; not modeled on Payin.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayinOut", + "field": "manual_concluded_by", + "reason": "Manual-review audit actor; not modeled on Payin.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayinOut", + "field": "partner_fee", + "reason": "Nested partner-fee detail object; Payin models the flat partner_fee_amount/partner_fee_id pair but not this nested object.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayinOut", + "field": "payer_rules", + "reason": "Mirrors the payer_rules object sent on CreatePayinQuoteInput, but the payin response does not echo it back and Payin does not model it.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayinOut", + "field": "transaction_fee_amount", + "reason": "Fee breakdown field; not modeled on Payin (which models total_fee_amount/partner_fee_amount but not this one).", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreatePayinOut", + "field": "billing_fee_amount", + "reason": "CreateEvmPayinResponse is a slim create-response shape; does not model this fee breakdown field (present on the full Payin/PayinOut shape considerations aside).", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreatePayinOut", + "field": "partner_fee", + "reason": "Nested partner-fee detail object; not modeled on the slim CreateEvmPayinResponse.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreatePayinOut", + "field": "payment_method", + "reason": "Not modeled on the slim CreateEvmPayinResponse (this create path is EVM-only, so the method is implicit).", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreatePayinOut", + "field": "sender_amount", + "reason": "Not modeled on the slim CreateEvmPayinResponse, which only models receiver_amount.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreatePayinOut", + "field": "transaction_fee_amount", + "reason": "Fee breakdown field; not modeled on the slim CreateEvmPayinResponse.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "field": "jpm_track_data", + "reason": "Internal banking-partner tracking blob; not modeled on Payout.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "field": "partner_fee", + "reason": "Nested partner-fee detail object; Payout models the flat partner_fee_amount/partner_fee_id pair but not this nested object.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "field": "pix_safe_bank_code", + "reason": "PIX Safe rail field; not surfaced on the generic Payout response (mirrors the same gap on BankAccountOut).", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "field": "pix_safe_branch_code", + "reason": "PIX Safe rail field; not surfaced on the generic Payout response (mirrors the same gap on BankAccountOut).", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "field": "pix_safe_cpf_cnpj", + "reason": "PIX Safe rail field; not surfaced on the generic Payout response (mirrors the same gap on BankAccountOut).", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "field": "tracking_documents", + "reason": "SWIFT payout document-request tracking sub-object; no SDK class models it at all (no TrackingDocuments type exists).", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "field": "transaction_fee_amount", + "reason": "Fee breakdown field; not modeled on Payout (which models total_fee_amount/partner_fee_amount but not this one).", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOnEvmOut", + "field": "bank_account_id", + "reason": "Not modeled on the slim CreateStellarPayoutResponse/CreateEvmPayoutResponse/CreateSolanaPayoutResponse create-response shapes.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOnEvmOut", + "field": "billing_fee_amount", + "reason": "Fee breakdown field; not modeled on the slim create-response shapes.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOnEvmOut", + "field": "offramp_wallet_id", + "reason": "Not modeled on the slim create-response shapes.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOnEvmOut", + "field": "partner_fee", + "reason": "Nested partner-fee detail object; not modeled on the slim create-response shapes.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOnEvmOut", + "field": "tracking_documents", + "reason": "SWIFT payout document-request tracking sub-object; no SDK class models it at all, same gap as PayoutOut.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOnEvmOut", + "field": "transaction_fee_amount", + "reason": "Fee breakdown field; not modeled on the slim create-response shapes.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "provider_integration", + "reason": "Payout tracking_payment carries 20 properties in the current spec; TrackingPayment models 6 (step/provider_name/provider_transaction_id/provider_status/estimated_time_of_arrival/completed_at). Exposing the remaining payout-only fields (this one plus provider_error_reason, provider_uetr, provider_imad, provider_reference, provider_clearing_system, recipient_name, recipient_tax_id, recipient_bank_code, recipient_branch_code, recipient_account_number, recipient_account_type, coelsa_id, end_to_end_id) is a deliberate additive change that needs one reviewed PR across all SDKs, not a blind per-field add here.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "provider_error_reason", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "provider_uetr", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "provider_imad", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "provider_reference", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch. Provider payment reference captured from the banking partner's booking webhook; present for some ACH/wire payouts.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "provider_clearing_system", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "recipient_name", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "recipient_tax_id", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "recipient_bank_code", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "recipient_branch_code", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "recipient_account_number", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "recipient_account_type", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "coelsa_id", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayoutOut", + "path": "tracking_payment", + "field": "end_to_end_id", + "reason": "See provider_integration on this same schema/path -- part of the same deferred payout tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayinOut", + "path": "tracking_payment", + "field": "review_source", + "reason": "Payin-side tracking_payment carries manual-review metadata (review_source/review_context/approved_risk_sources) that the shared TrackingPayment class, modeled after the payout shape, does not have. Same deferred-batch reasoning as the payout tracking_payment fields: needs a reviewed PR, not a blind add onto a class shared with payouts.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayinOut", + "path": "tracking_payment", + "field": "review_context", + "reason": "See review_source on this same schema/path -- part of the same deferred payin tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayinOut", + "path": "tracking_payment", + "field": "approved_risk_sources", + "reason": "See review_source on this same schema/path -- part of the same deferred payin tracking_payment batch.", + "owner": "eric@blindpay.com" + }, + { + "schema": "VirtualAccountOut", + "field": "blockchain_wallet", + "reason": "Nested embedded wallet object; VirtualAccount only stores blockchainWalletId (the ID string), not the embedded object.", + "owner": "eric@blindpay.com" + }, + { + "schema": "VirtualAccountOut", + "field": "customer_id", + "reason": "Not modeled on VirtualAccount; callers already have the customer_id they used to make the request.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateWalletIn", + "field": "name", + "reason": "Wallet display name; not exposed as a constructor parameter on CreateCustodialWalletInput (only network is accepted).", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreateWalletIn", + "field": "external_id", + "reason": "Caller-supplied external ID; not exposed as a constructor parameter on CreateCustodialWalletInput.", + "owner": "eric@blindpay.com" + }, + { + "schema": "WalletOut", + "field": "name", + "reason": "Wallet display name; not modeled on CustodialWallet.", + "owner": "eric@blindpay.com" + }, + { + "schema": "WalletOut", + "field": "external_id", + "reason": "Caller-supplied external ID; not modeled on CustodialWallet.", + "owner": "eric@blindpay.com" + }, + { + "schema": "WalletTokenOut", + "field": "id", + "reason": "Not modeled on WalletTokenBalance (which models amount/token/address).", + "owner": "eric@blindpay.com" + }, + { + "schema": "WalletTokenOut", + "field": "symbol", + "reason": "WalletTokenBalance reads a `token` key instead of `symbol` for the currency code; worth confirming against a live response whether this is the intended wire key or a pre-existing key-name mismatch. Not touched in this PR.", + "owner": "eric@blindpay.com" + }, + { + "schema": "UpdateInstanceMemberIn", + "field": "user_role", + "reason": "UpdateMemberRoleInput has no toArray; Instances::updateMemberRole() builds `['user_role' => $input->role->value]` inline instead of calling a method on this DTO.", + "owner": "eric@blindpay.com" + }, + { + "schema": "UpdateInstanceIn", + "field": "compliance_emails", + "reason": "Instance-level notification routing setting added after UpdateInstanceInput was last generated; not modeled.", + "owner": "eric@blindpay.com" + }, + { + "schema": "UpdateInstanceIn", + "field": "customer_rfi_emails_enabled", + "reason": "Instance-level notification routing setting added after UpdateInstanceInput was last generated; not modeled.", + "owner": "eric@blindpay.com" + }, + { + "schema": "GetFeesOut", + "field": "id", + "reason": "Fee-config envelope metadata; FeesResponse only models the per-rail fee breakdown, not the record's own id.", + "owner": "eric@blindpay.com" + }, + { + "schema": "GetFeesOut", + "field": "instance_id", + "reason": "Fee-config envelope metadata; FeesResponse only models the per-rail fee breakdown, not instance_id.", + "owner": "eric@blindpay.com" + }, + { + "schema": "GetFeesOut", + "field": "created_at", + "reason": "Fee-config envelope metadata; FeesResponse only models the per-rail fee breakdown, not timestamps.", + "owner": "eric@blindpay.com" + }, + { + "schema": "GetFeesOut", + "field": "updated_at", + "reason": "Fee-config envelope metadata; FeesResponse only models the per-rail fee breakdown, not timestamps.", + "owner": "eric@blindpay.com" + }, + { + "schema": "GetFeesOut", + "field": "sepa", + "reason": "SEPA rail fee bucket added to the spec after FeesResponse was last generated; FeesResponse enumerates the other rails' FeeOptions but has no `sepa` property.", + "owner": "eric@blindpay.com" + }, + { + "schema": "AvailableBankDetails", + "field": "requiredWhen", + "reason": "Conditional-requirement expression added to the spec after BankDetail was last generated; not modeled.", + "owner": "eric@blindpay.com" + } + ] +} diff --git a/.github/workflows/api-sync-merged.yml b/.github/workflows/api-sync-merged.yml index 9ec73b6..46e32d3 100644 --- a/.github/workflows/api-sync-merged.yml +++ b/.github/workflows/api-sync-merged.yml @@ -37,3 +37,68 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git commit -m "chore: clear baseline — SDK synced" --allow-empty git push -f origin api-sync-data + + auto-tag-release: + name: Auto-tag release + if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'api-sync') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Read the VERSION const src/BlindPay.php was merged with + id: version + run: | + VERSION=$(php -r ' + $source = file_get_contents("src/BlindPay.php"); + if (! preg_match("/private const VERSION = .([0-9]+\.[0-9]+\.[0-9]+)./", $source, $m)) { + fwrite(STDERR, "could not find VERSION const in src/BlindPay.php\n"); + exit(1); + } + echo $m[1]; + ') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: "Guard: did this merge actually change VERSION?" + id: guard + run: | + # The api-sync PR is squash-merged onto main by api-sync.yml, so this merge is exactly + # one commit; its parent is main's previous tip, before the version bump. + BEFORE=$(git show HEAD~1:src/BlindPay.php 2>/dev/null | grep -oE "VERSION = '[0-9.]+'" || echo "none") + AFTER=$(grep -oE "VERSION = '[0-9.]+'" src/BlindPay.php) + echo "before=$BEFORE after=$AFTER" + if [ "$BEFORE" = "$AFTER" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: "Guard: does the tag already exist?" + id: tagcheck + run: | + git fetch origin --tags --quiet + if git rev-parse "refs/tags/v${{ steps.version.outputs.version }}" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Tag and push + if: steps.guard.outputs.changed == 'true' && steps.tagcheck.outputs.exists == 'false' + env: + GH_TOKEN: ${{ secrets.SDK_SYNC_PAT }} + run: | + git remote set-url origin "https://x-access-token:${{ secrets.SDK_SYNC_PAT }}@github.com/${{ github.repository }}.git" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "v${{ steps.version.outputs.version }}" + git push origin "v${{ steps.version.outputs.version }}" + + - name: Skip (no-op) reason + if: steps.guard.outputs.changed == 'false' || steps.tagcheck.outputs.exists == 'true' + run: | + echo "Not tagging: VERSION changed on this merge = ${{ steps.guard.outputs.changed }}, tag v${{ steps.version.outputs.version }} already exists = ${{ steps.tagcheck.outputs.exists }}" diff --git a/.github/workflows/api-sync.yml b/.github/workflows/api-sync.yml index 96470d6..fdb501c 100644 --- a/.github/workflows/api-sync.yml +++ b/.github/workflows/api-sync.yml @@ -15,30 +15,21 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Fetch API sync data - run: | - mkdir -p /tmp/api-sync - git fetch origin api-sync-data - git show origin/api-sync-data:.api-sync/changelog.md > /tmp/api-sync/changelog.md - echo "=== Changelog ===" - cat /tmp/api-sync/changelog.md + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none - - name: Check for existing api-sync PR - id: check-pr - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Fetch the new spec from api-sync-data run: | - PR_NUMBER=$(gh pr list --head api-sync --json number --jq '.[0].number // empty') - if [ -n "$PR_NUMBER" ]; then - echo "existing_pr=$PR_NUMBER" >> $GITHUB_OUTPUT - echo "Found existing api-sync PR: #$PR_NUMBER" - else - echo "existing_pr=" >> $GITHUB_OUTPUT - echo "No existing api-sync PR found" - fi + git fetch origin api-sync-data + git show origin/api-sync-data:.api-sync/spec-current.json > .api-sync/spec-current.json - - name: Create or checkout api-sync branch + - name: Create or reset the api-sync branch run: | git fetch origin api-sync 2>/dev/null || true if git rev-parse --verify origin/api-sync >/dev/null 2>&1; then @@ -48,63 +39,78 @@ jobs: git checkout -b api-sync fi - - name: Apply changes with Claude Code - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - claude_args: '--model claude-opus-5 --allowedTools "Bash(*),Read,Edit,Write,Glob,Grep"' - prompt: | - You are updating this SDK to match API changes. Read CLAUDE.md for this SDK's conventions. - - The changelog is at /tmp/api-sync/changelog.md — it contains ALL the information you need. - Do NOT read openapi.json. The changelog is self-contained. - - INSTRUCTIONS: - 1. Read CLAUDE.md thoroughly for this SDK's patterns and conventions. - 2. Read /tmp/api-sync/changelog.md — it lists every enum type with every value, every field to add, and every change to make. - 3. Implement ALL changes. Go through the changelog section by section: - - Section 1 (Enum Types): For each enum listed, check if the SDK has it. If missing, create it. If it exists, add any missing values. The changelog lists ALL values — add the ones that don't exist yet. - - Section 2 (Receiver Fields): Add each listed field to the receiver input/output types. - - Section 3 (Version): Minor bump. - 4. Do not skip ANY enum or field. Every item in the changelog must be addressed. - 5. Follow CLAUDE.md patterns exactly for naming, typing, and file organization. - 6. MINOR version bump only. - 7. Run lint and type check commands (see CLAUDE.md). Fix any errors until clean. - 8. Do NOT create commits — just modify the files. + - name: Run the patcher + id: patch + run: | + set +e + php scripts/api-sync.php --apply --report=/tmp/api-sync-report.json + echo "exit_code=$?" >> "$GITHUB_OUTPUT" + set -e + + - name: Fail loudly on needs-human + if: steps.patch.outputs.exit_code != '0' + run: | + echo "::error::api-sync found drift it cannot apply mechanically -- a human must decide. See the patcher output above, and update .api-sync/spec-map.json, .api-sync/unmodeled.json or .api-sync/known-divergences.json accordingly." + cat /tmp/api-sync-report.json || true + exit 1 + + - name: Check for changes + id: changes + run: | + if [ -n "$(git status --porcelain -- . ':!.github')" ]; then + echo "has_changes=true" >> "$GITHUB_OUTPUT" + else + echo "has_changes=false" >> "$GITHUB_OUTPUT" + fi + + - name: Exit quietly, nothing to sync + if: steps.changes.outputs.has_changes == 'false' + run: echo "No pending drift. Nothing to sync." + + - name: Determine version bump + if: steps.changes.outputs.has_changes == 'true' + id: bump + run: | + BUMP=$(php -r '$r = json_decode(file_get_contents("/tmp/api-sync-report.json"), true); echo $r["bump"]["type"] ?? "patch";') + VERSION=$(php -r '$r = json_decode(file_get_contents("/tmp/api-sync-report.json"), true); echo $r["bump"]["version"] ?? "";') + echo "type=$BUMP" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Commit and push - id: commit + if: steps.changes.outputs.has_changes == 'true' run: | git remote set-url origin "https://x-access-token:${{ secrets.SDK_SYNC_PAT }}@github.com/${{ github.repository }}.git" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" git checkout -- .github/workflows/ 2>/dev/null || true git add -A git reset HEAD .github/workflows/ 2>/dev/null || true - if git diff --staged --quiet; then - echo "No changes to commit" - echo "has_changes=false" >> $GITHUB_OUTPUT - exit 0 - fi - echo "has_changes=true" >> $GITHUB_OUTPUT - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git commit -m "feat: sync SDK with API changes" + git commit -m "${{ steps.bump.outputs.type == 'minor' && 'feat' || 'fix' }}: sync SDK with API changes (${{ steps.bump.outputs.type }}, v${{ steps.bump.outputs.version }})" git push --force-with-lease origin api-sync - name: Create or update PR - if: steps.commit.outputs.has_changes == 'true' + if: steps.changes.outputs.has_changes == 'true' env: GH_TOKEN: ${{ secrets.SDK_SYNC_PAT }} run: | - EXISTING_PR="${{ steps.check-pr.outputs.existing_pr }}" + PR_NUMBER=$(gh pr list --head api-sync --json number --jq '.[0].number // empty') + BODY="Automated deterministic sync from the latest API spec. + + Version bump: **${{ steps.bump.outputs.type }}** -> \`${{ steps.bump.outputs.version }}\` - if [ -n "$EXISTING_PR" ]; then - echo "Updating existing PR #$EXISTING_PR" - gh pr comment "$EXISTING_PR" --body "Updated with latest API changes." + Generated by \`php scripts/api-sync.php --apply\`. See the workflow run for the full report." + + if [ -n "$PR_NUMBER" ]; then + echo "Updating existing PR #$PR_NUMBER" + gh pr edit "$PR_NUMBER" --body "$BODY" else gh pr create \ - --title "feat: sync SDK with API changes" \ - --body "Automated SDK update from API changes." \ + --title "sync: SDK with API changes" \ + --body "$BODY" \ --base main \ --head api-sync \ --label api-sync + PR_NUMBER=$(gh pr list --head api-sync --json number --jq '.[0].number') fi + + gh pr merge "$PR_NUMBER" --auto --squash diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 119754e..0865425 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -27,6 +27,60 @@ jobs: coverage: none - name: Verify SDK wire keys against the spec snapshot run: php scripts/contract-check.php + api-sync-check: + name: API sync check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + - name: State reconciliation (spec-map.json + unmodeled.json + known-divergences.json vs spec-snapshot.json) + # Self-consistency gate: checks the committed code against the committed baseline, which is + # always present. .api-sync/spec-current.json is deliberately NOT committed -- it only + # exists transiently inside the api-sync.yml workflow run, fetched from api-sync-data. + run: php scripts/api-sync.php --check --spec=.api-sync/spec-snapshot.json + - name: Map validity (every spec-map.json entry resolves) + run: | + php -r ' + define("API_SYNC_LIB_ONLY", true); + require "scripts/api-sync.php"; + $map = loadJson(".api-sync/spec-map.json"); + $classIndex = scanAllClasses(getcwd()); + $errors = validateMap($map, $classIndex, getcwd()); + if (! empty($errors)) { + fwrite(STDERR, "Map validity FAILED:\n"); + foreach ($errors as $e) { + fwrite(STDERR, " - {$e}\n"); + } + exit(1); + } + fwrite(STDOUT, "Map validity OK: every spec-map.json entry resolves.\n"); + ' + - name: Determinism proof (apply into two scratch copies, diff byte-identical) + run: | + set -e + rm -rf /tmp/api-sync-determinism + mkdir -p /tmp/api-sync-determinism/a /tmp/api-sync-determinism/b + cp -R . /tmp/api-sync-determinism/a + cp -R . /tmp/api-sync-determinism/b + (cd /tmp/api-sync-determinism/a && php scripts/api-sync.php --apply --spec=.api-sync/spec-snapshot.json --report=report.json) + (cd /tmp/api-sync-determinism/b && php scripts/api-sync.php --apply --spec=.api-sync/spec-snapshot.json --report=report.json) + diff -rq --exclude=.git /tmp/api-sync-determinism/a /tmp/api-sync-determinism/b + - name: Coverage report (non-blocking -- spec operations/schemas with no SDK mapping) + if: always() + run: | + php scripts/api-sync.php --check --spec=.api-sync/spec-snapshot.json --report=/tmp/api-sync-report.json || true + php -r ' + $r = json_decode(file_get_contents("/tmp/api-sync-report.json"), true); + $gaps = $r["coverageGaps"] ?? []; + echo "Reachable spec schemas with no spec-map.json mapping (non-blocking; expected: the Rfi family and upload/analyze):\n"; + foreach ($gaps as $g) { + echo " - {$g}\n"; + } + echo count($gaps)." total.\n"; + ' snyk: uses: ./.github/workflows/snyk.yaml needs: [lint, tests] diff --git a/.gitignore b/.gitignore index 0be9515..9a8ac0e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,7 @@ /.php-cs-fixer.cache /phpunit.xml /.env -*.log \ No newline at end of file +*.log + +# Delivered fresh by api-sync.yml from the api-sync-data branch on each dispatch; never committed. +/.api-sync/spec-current.json \ No newline at end of file diff --git a/composer.json b/composer.json index e5f6a26..cea0cbe 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,8 @@ "test:coverage": "pest --coverage", "lint:check": "pint --test", "lint:fix": "pint", - "contract-check": "php scripts/contract-check.php" + "contract-check": "php scripts/contract-check.php", + "api-sync": "php scripts/api-sync.php --apply", + "api-sync:check": "php scripts/api-sync.php --check" } } diff --git a/scripts/api-sync.php b/scripts/api-sync.php new file mode 100644 index 0000000..eef526f --- /dev/null +++ b/scripts/api-sync.php @@ -0,0 +1,1164 @@ + SDK sync patcher. + * + * State-based reconciliation (primary): for every (spec schema[/path] -> SDK class) pair in + * .api-sync/spec-map.json, every spec property must be present in the SDK class, or be listed + * in .api-sync/unmodeled.json. For every mapped enum, every spec member value must be present as + * an SDK case value, or be listed in .api-sync/known-divergences.json. Anything missing is + * pending drift, regardless of when it appeared. + * + * Old-vs-new spec diff (secondary): only used for removal detection (always a hard fail) and + * version-bump classification. + * + * Usage: + * php scripts/api-sync.php [--check] [--apply] [--spec=path] [--report=path] + * Default mode is --check. Default --spec is .api-sync/spec-current.json. + * No Composer dependencies -- uses only the Tokenizer/JSON extensions PHP ships with. + */ + +declare(strict_types=1); + +$root = dirname(__DIR__); + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function parseArgs(array $argv): array +{ + $opts = ['apply' => false, 'check' => false, 'spec' => null, 'report' => null]; + foreach (array_slice($argv, 1) as $arg) { + if ($arg === '--apply') { + $opts['apply'] = true; + } elseif ($arg === '--check') { + $opts['check'] = true; + } elseif (str_starts_with($arg, '--spec=')) { + $opts['spec'] = substr($arg, strlen('--spec=')); + } elseif (str_starts_with($arg, '--report=')) { + $opts['report'] = substr($arg, strlen('--report=')); + } else { + fwrite(STDERR, "[api-sync] unknown argument: {$arg}\n"); + exit(1); + } + } + + return $opts; +} + +// --------------------------------------------------------------------------- +// JSON / spec helpers +// --------------------------------------------------------------------------- + +function loadJson(string $path): array +{ + if (! is_file($path)) { + fwrite(STDERR, "[api-sync] FAIL: file not found: {$path}\n"); + exit(1); + } + + return json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); +} + +/** Recursively collect every `#/components/schemas/X` ref target under $node. */ +function collectRefs(mixed $node, array &$out): void +{ + if (is_array($node)) { + if (isset($node['$ref']) && is_string($node['$ref']) && str_starts_with($node['$ref'], '#/components/schemas/')) { + $out[substr($node['$ref'], strlen('#/components/schemas/'))] = true; + } + foreach ($node as $v) { + collectRefs($v, $out); + } + } +} + +/** BFS transitive closure of schema names reachable from paths + webhooks. */ +function computeReachable(array $spec): array +{ + $schemas = $spec['components']['schemas'] ?? []; + $roots = []; + collectRefs($spec['paths'] ?? [], $roots); + collectRefs($spec['webhooks'] ?? [], $roots); + + $visited = $roots; + $queue = array_keys($roots); + while (! empty($queue)) { + $name = array_pop($queue); + if (! isset($schemas[$name])) { + continue; + } + $refs = []; + collectRefs($schemas[$name], $refs); + foreach ($refs as $r => $_) { + if (! isset($visited[$r])) { + $visited[$r] = true; + $queue[] = $r; + } + } + } + + return $visited; // name => true +} + +/** Top-level property names of a component schema. */ +function schemaProps(array $spec, string $schema): ?array +{ + $s = $spec['components']['schemas'][$schema] ?? null; + if ($s === null || ! isset($s['properties'])) { + return null; + } + + return array_keys($s['properties']); +} + +/** Enum values for `schema.property`, where property may itself be a nested inline object (dot path). */ +function specEnumValues(array $spec, string $schema, string $propertyPath): ?array +{ + $node = $spec['components']['schemas'][$schema] ?? null; + if ($node === null) { + return null; + } + foreach (explode('.', $propertyPath) as $part) { + $node = $node['properties'][$part] ?? null; + if ($node === null) { + return null; + } + } + + return $node['enum'] ?? null; +} + +/** Enum values for an inline (non-$ref) request-body property, addressed by "METHOD /path". */ +function operationEnumValues(array $spec, string $operation, string $property): ?array +{ + [$method, $path] = explode(' ', $operation, 2); + $method = strtolower($method); + $node = $spec['paths'][$path][$method]['requestBody']['content']['application/json']['schema']['properties'][$property] ?? null; + + return $node['enum'] ?? null; +} + +/** Nested inline object property names, e.g. schemaProps but for a dotted nested path. */ +function nestedProps(array $spec, string $schema, string $path): ?array +{ + $node = $spec['components']['schemas'][$schema] ?? null; + if ($node === null) { + return null; + } + foreach (explode('.', $path) as $part) { + $node = $node['properties'][$part] ?? null; + if ($node === null) { + return null; + } + } + + return isset($node['properties']) ? array_keys($node['properties']) : null; +} + +// --------------------------------------------------------------------------- +// SDK source scanning (tokenizer, mirrors scripts/contract-check.php's style) +// --------------------------------------------------------------------------- + +function phpFilesUnder(string $dir): array +{ + $files = []; + if (! is_dir($dir)) { + return $files; + } + $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)); + foreach ($it as $file) { + if ($file->getExtension() === 'php') { + $files[] = $file->getPathname(); + } + } + sort($files); + + return $files; +} + +/** + * Scan one PHP file and return per-class info: + * isEnum, enumCases: [{name, value, line}], ctor: {startLine?, closeParenLine?, lastParamLine?, params:[{name,line}]}, + * fromArray: {startLine?, closeParenLine?, lastArgLine?, keys:[...]}, + * toArray: {style: 'none'|'literal'|'conditional', literalCloseLine?, literalLastLine?, + * conditionalReturnLine?, conditionalLastIfLine?, keys:[...]} + */ +function scanClassesDetailed(string $path): array +{ + $source = file_get_contents($path); + $tokens = token_get_all($source); + $tokens = array_values(array_filter($tokens, function ($t) { + if (! is_array($t)) { + return true; + } + + return ! in_array($t[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true); + })); + + $classes = []; + $braceDepth = 0; + $classStack = []; // [depth, name] + $functionStack = []; // [depth, name] + $pendingClassName = null; + $pendingIsEnum = false; + $pendingFunctionName = null; + $awaitingCtorParen = false; + $inCtorParams = false; + $ctorParenDepth = 0; + + $count = count($tokens); + $line = 1; + + for ($i = 0; $i < $count; $i++) { + $t = $tokens[$i]; + $id = is_array($t) ? $t[0] : null; + $text = is_array($t) ? $t[1] : $t; + $tokenLine = is_array($t) ? $t[2] : $line; + $line = $tokenLine; + + if (is_array($t) && in_array($id, [T_CLASS, T_ENUM, T_INTERFACE, T_TRAIT], true)) { + $pendingIsEnum = ($id === T_ENUM); + for ($j = $i + 1; $j < $count; $j++) { + $nt = $tokens[$j]; + if (is_array($nt) && $nt[0] === T_STRING) { + $pendingClassName = $nt[1]; + + break; + } + if (! is_array($nt) && $nt === '{') { + break; + } + } + } + + if (is_array($t) && $id === T_FUNCTION) { + for ($j = $i + 1; $j < $count; $j++) { + $nt = $tokens[$j]; + if (is_array($nt) && $nt[0] === T_STRING) { + $pendingFunctionName = $nt[1]; + if ($nt[1] === '__construct') { + $awaitingCtorParen = true; + } + + break; + } + if (! is_array($nt) && $nt === '{') { + break; + } + } + } + + $currentClass = end($classStack)[1] ?? null; + $currentFunction = end($functionStack)[1] ?? null; + + // Promoted constructor properties live in the parameter list, which closes BEFORE the + // function body opens -- track it via paren depth instead of the function-body stack. + if (! is_array($t) && $text === '(' && $awaitingCtorParen && ! $inCtorParams) { + $inCtorParams = true; + $ctorParenDepth = 1; + $awaitingCtorParen = false; + if ($currentClass !== null && isset($classes[$currentClass])) { + $classes[$currentClass]['ctor'] ??= ['params' => []]; + } + + continue; + } + if ($inCtorParams) { + if (! is_array($t) && $text === '(') { + $ctorParenDepth++; + } elseif (! is_array($t) && $text === ')') { + $ctorParenDepth--; + if ($ctorParenDepth === 0) { + $inCtorParams = false; + } + } elseif ($ctorParenDepth === 1 && is_array($t) && in_array($id, [T_PUBLIC, T_PROTECTED, T_PRIVATE], true) + && $currentClass !== null && isset($classes[$currentClass])) { + for ($j = $i + 1; $j < $count; $j++) { + $nt = $tokens[$j]; + if (! is_array($nt) && in_array($nt, [',', ')'], true)) { + break; + } + if (is_array($nt) && $nt[0] === T_VARIABLE) { + $classes[$currentClass]['ctor']['params'][] = ['name' => ltrim($nt[1], '$'), 'line' => $nt[2]]; + + break; + } + } + } + } + + if (! is_array($t) && $text === '{') { + $braceDepth++; + if ($pendingClassName !== null) { + $classStack[] = [$braceDepth, $pendingClassName]; + if (! isset($classes[$pendingClassName])) { + $classes[$pendingClassName] = [ + 'isEnum' => $pendingIsEnum, + 'enumCases' => [], + 'ctor' => null, + 'fromArray' => null, + 'toArray' => ['style' => 'none', 'keys' => []], + ]; + } + $pendingClassName = null; + $pendingIsEnum = false; + } elseif ($pendingFunctionName !== null) { + $functionStack[] = [$braceDepth, $pendingFunctionName]; + // record function open info + if ($currentClass !== null && isset($classes[$currentClass])) { + if ($pendingFunctionName === '__construct') { + $classes[$currentClass]['ctor'] ??= ['params' => []]; + } elseif ($pendingFunctionName === 'fromArray') { + $classes[$currentClass]['fromArray'] ??= ['args' => []]; + } + } + $pendingFunctionName = null; + } + + continue; + } + + if (! is_array($t) && $text === '}') { + if (! empty($functionStack) && end($functionStack)[0] === $braceDepth) { + array_pop($functionStack); + } elseif (! empty($classStack) && end($classStack)[0] === $braceDepth) { + array_pop($classStack); + } + $braceDepth--; + + continue; + } + + // enum case NAME = 'value'; at line $line + // The case name is always the token immediately after `case` -- read it positionally + // rather than by token type, since PHP allows semi-reserved keywords (e.g. `AS`, `DO`) + // as enum case names, and those tokenize as their keyword type, not T_STRING. + if ($currentClass !== null && isset($classes[$currentClass]) && $classes[$currentClass]['isEnum'] + && is_array($t) && $id === T_CASE) { + $caseName = (isset($tokens[$i + 1]) && is_array($tokens[$i + 1])) ? $tokens[$i + 1][1] : null; + $caseValue = null; + $caseLine = $line; + for ($j = $i + 1; $j < $count && $tokens[$j] !== ';'; $j++) { + $nt = $tokens[$j]; + if (is_array($nt) && $nt[0] === T_CONSTANT_ENCAPSED_STRING) { + $caseValue = trim($nt[1], "'\""); + } + } + if ($caseName !== null) { + $classes[$currentClass]['enumCases'][] = ['name' => $caseName, 'value' => $caseValue, 'line' => $caseLine]; + } + + continue; + } + + // fromArray: $data['key'] read anywhere in the body + if ($currentFunction === 'fromArray' && $currentClass !== null && isset($classes[$currentClass])) { + if (is_array($t) && $id === T_VARIABLE + && isset($tokens[$i + 1], $tokens[$i + 2], $tokens[$i + 3]) + && $tokens[$i + 1] === '[' + && is_array($tokens[$i + 2]) && $tokens[$i + 2][0] === T_CONSTANT_ENCAPSED_STRING + && $tokens[$i + 3] === ']' + ) { + $key = trim($tokens[$i + 2][1], "'\""); + $classes[$currentClass]['fromArray']['keys'][$key] = true; + } + // named-argument line: `camelName: ` at top level of the `new self(` call -- record for anchor purposes + if (is_array($t) && $id === T_STRING + && isset($tokens[$i + 1]) && $tokens[$i + 1] === ':' + && (! isset($tokens[$i + 2]) || ! (is_array($tokens[$i + 2]) && $tokens[$i + 2][0] === T_DOUBLE_COLON)) + ) { + $classes[$currentClass]['fromArray']['args'][] = ['name' => $text, 'line' => $line]; + } + } + + // toArray: two possible styles. + if ($currentFunction === 'toArray' && $currentClass !== null && isset($classes[$currentClass])) { + $ta = &$classes[$currentClass]['toArray']; + + // style "literal": 'key' => value inside an array literal + if (is_array($t) && $id === T_CONSTANT_ENCAPSED_STRING + && isset($tokens[$i + 1]) && is_array($tokens[$i + 1]) && $tokens[$i + 1][0] === T_DOUBLE_ARROW + ) { + $key = trim($text, "'\""); + $ta['keys'][$key] = true; + if ($ta['style'] !== 'conditional') { + $ta['style'] = 'literal'; + } + $ta['literalLastLine'] = $line; + } + + // style "conditional": $data['key'] = value; (subscript assignment, not literal) + if (is_array($t) && $id === T_VARIABLE + && isset($tokens[$i + 1], $tokens[$i + 2], $tokens[$i + 3], $tokens[$i + 4]) + && $tokens[$i + 1] === '[' + && is_array($tokens[$i + 2]) && $tokens[$i + 2][0] === T_CONSTANT_ENCAPSED_STRING + && $tokens[$i + 3] === ']' + && $tokens[$i + 4] === '=' + ) { + $key = trim($tokens[$i + 2][1], "'\""); + $ta['keys'][$key] = true; + $ta['style'] = 'conditional'; + $ta['conditionalLastIfLine'] = $line; + } + + // track `return $data;` line for the conditional style anchor + if (is_array($t) && $id === T_RETURN) { + for ($j = $i + 1; $j < $count; $j++) { + $nt = $tokens[$j]; + if (! is_array($nt)) { + break; + } + if ($nt[0] === T_VARIABLE && $nt[1] === '$data') { + $ta['conditionalReturnLine'] = $line; + } + + break; + } + } + unset($ta); + } + } + + return $classes; +} + +/** name => {file, isEnum, enumCases, ctor, fromArray, toArray} across src/Resources and src/Types. */ +function scanAllClasses(string $root): array +{ + $out = []; + foreach (['src/Resources', 'src/Types'] as $dir) { + foreach (phpFilesUnder("{$root}/{$dir}") as $file) { + $classes = scanClassesDetailed($file); + foreach ($classes as $name => $info) { + $info['file'] = substr($file, strlen($root) + 1); + $out[$name] = $info; + } + } + } + + return $out; +} + +// --------------------------------------------------------------------------- +// JSON schema type -> PHP type +// --------------------------------------------------------------------------- + +function phpTypeFor(array $propSchema): string +{ + $type = $propSchema['type'] ?? 'string'; + $types = is_array($type) ? $type : [$type]; + $types = array_values(array_diff($types, ['null'])); + $t = $types[0] ?? 'string'; + + return match ($t) { + 'integer' => 'int', + 'number' => 'float', + 'boolean' => 'bool', + 'array' => 'array', + 'object' => 'array', + default => 'string', + }; +} + +function snakeToCamel(string $snake): string +{ + $parts = explode('_', $snake); + $first = array_shift($parts); + + return $first.implode('', array_map(fn ($p) => $p === '' ? '' : ucfirst($p), $parts)); +} + +// --------------------------------------------------------------------------- +// Map validation +// --------------------------------------------------------------------------- + +function validateMap(array $map, array $classIndex, string $root): array +{ + $errors = []; + foreach ($map['types'] as $entry) { + foreach ($entry['sdk'] as $site) { + $class = $site['class']; + if (! isset($classIndex[$class])) { + $errors[] = "map anchor not found: class {$class} declared in spec-map.json types (file {$site['file']}) does not exist in src/"; + + continue; + } + $actualFile = $classIndex[$class]['file']; + if ($actualFile !== $site['file']) { + $errors[] = "map anchor mismatch: class {$class} is declared at {$actualFile}, spec-map.json says {$site['file']}"; + } + } + } + foreach ($map['enums'] as $entry) { + $class = $entry['sdk']['class']; + if (! isset($classIndex[$class]) || ! $classIndex[$class]['isEnum']) { + $errors[] = "map anchor not found: enum {$class} declared in spec-map.json enums does not exist (or is not an enum) in src/"; + } + } + + return $errors; +} + +// --------------------------------------------------------------------------- +// Reconciliation: enums +// --------------------------------------------------------------------------- + +function enumSpecValues(array $spec, array $entry): ?array +{ + $s = $entry['spec']; + if (isset($s['kind']) && $s['kind'] === 'webhookTopics') { + $topics = $spec['webhooks'] ?? []; + + return array_keys($topics); + } + if (isset($s['operation'])) { + return operationEnumValues($spec, $s['operation'], $s['property']); + } + + return specEnumValues($spec, $s['schema'], $s['property']); +} + +function reconcileEnums(array $map, array $newSpec, array $reachable, array $classIndex, array $divergenceEnumIndex): array +{ + $applicable = []; + $needsHuman = []; + + foreach ($map['enums'] as $entry) { + $s = $entry['spec']; + if (isset($s['schema']) && ! isset($reachable[$s['schema']])) { + continue; // schema no longer reachable -- skip by construction + } + + $specValues = enumSpecValues($newSpec, $entry); + if ($specValues === null) { + continue; // property/operation gone entirely; handled by structural diff + } + + $className = $entry['sdk']['class']; + $classInfo = $classIndex[$className] ?? null; + if ($classInfo === null) { + continue; // already reported by validateMap + } + $sdkValues = array_map(fn ($c) => $c['value'], $classInfo['enumCases']); + $sdkValueSet = array_flip($sdkValues); + + foreach ($specValues as $value) { + if (isset($sdkValueSet[$value])) { + continue; + } + if (isset($divergenceEnumIndex["{$className}|{$value}"])) { + continue; // recorded known divergence, not pending drift + } + $applicable[] = [ + 'kind' => 'enum-member-added', + 'enum' => $className, + 'file' => $classInfo['file'], + 'value' => $value, + 'sortKey' => "{$className}|{$value}", + ]; + } + } + + return [$applicable, $needsHuman]; +} + +// --------------------------------------------------------------------------- +// Reconciliation: types / fields +// --------------------------------------------------------------------------- + +function typeSpecSchemas(array $entry): array +{ + $spec = $entry['spec']; + + return is_array($spec) ? $spec : [$spec]; +} + +function reconcileTypes(array $map, array $newSpec, array $reachable, array $classIndex, array $unmodeledIndex): array +{ + $applicable = []; + $needsHuman = []; + + foreach ($map['types'] as $entry) { + $schemas = typeSpecSchemas($entry); + $path = $entry['path'] ?? null; + // unmodeled.json records one entry per group (using the first schema as the canonical + // name), not once per schema in a multi-schema entry like ["PayoutOut","PayoutOnEvmOut"]. + $canonicalSchema = $schemas[0]; + + foreach ($schemas as $schemaName) { + if (! isset($reachable[$schemaName])) { + continue; // no longer reachable -- skip by construction + } + + $specProps = $path !== null + ? nestedProps($newSpec, $schemaName, $path) + : schemaProps($newSpec, $schemaName); + + if ($specProps === null) { + continue; // schema/property gone; handled by structural diff + } + + // union of wire keys already modeled across all mapped SDK classes for this schema + $modeled = []; + foreach ($entry['sdk'] as $site) { + $info = $classIndex[$site['class']] ?? null; + if ($info === null) { + continue; + } + foreach (array_keys($info['fromArray']['keys'] ?? []) as $k) { + $modeled[$k] = true; + } + foreach (array_keys($info['toArray']['keys'] ?? []) as $k) { + $modeled[$k] = true; + } + } + + foreach ($specProps as $field) { + if (isset($modeled[$field])) { + continue; + } + $unmodeledKey = $canonicalSchema.'|'.($path ?? '').'|'.$field; + if (isset($unmodeledIndex[$unmodeledKey])) { + continue; + } + + // determine target class(es) to patch: prefer a class whose family (input vs response) + // is unambiguous; for a fan-out with multiple sdk sites we apply to ALL of them, since + // any of them might legitimately accept/return the new field. + foreach ($entry['sdk'] as $site) { + $applicable[] = [ + 'kind' => 'field-added', + 'schema' => $schemaName, + 'path' => $path, + 'field' => $field, + 'class' => $site['class'], + 'file' => $site['file'], + 'propSchema' => $path !== null + ? (nestedPropSchema($newSpec, $schemaName, $path, $field)) + : ($newSpec['components']['schemas'][$schemaName]['properties'][$field] ?? []), + 'sortKey' => "{$schemaName}|".($path ?? '')."|{$field}|{$site['class']}", + ]; + } + } + } + } + + return [$applicable, $needsHuman]; +} + +function nestedPropSchema(array $spec, string $schema, string $path, string $field): array +{ + $node = $spec['components']['schemas'][$schema] ?? []; + foreach (explode('.', $path) as $part) { + $node = $node['properties'][$part] ?? []; + } + + return $node['properties'][$field] ?? []; +} + +// --------------------------------------------------------------------------- +// Structural diff (old vs new): removals + new-and-unmapped surface +// --------------------------------------------------------------------------- + +function pathMethodSet(array $spec): array +{ + $out = []; + foreach ($spec['paths'] ?? [] as $p => $methods) { + foreach ($methods as $m => $_) { + if (in_array($m, ['get', 'post', 'put', 'patch', 'delete'], true)) { + $out["{$m} {$p}"] = true; + } + } + } + + return $out; +} + +function computeStructuralDiff(array $oldSpec, array $newSpec, array $reachableOld, array $reachableNew, array $map): array +{ + $issues = []; + + $oldOps = pathMethodSet($oldSpec); + $newOps = pathMethodSet($newSpec); + foreach (array_diff(array_keys($oldOps), array_keys($newOps)) as $op) { + $issues[] = "NEEDS_HUMAN: operation removed: {$op} (breaking, requires a deliberate major)"; + } + foreach (array_diff(array_keys($newOps), array_keys($oldOps)) as $op) { + $issues[] = "NEEDS_HUMAN: new operation: {$op} (needs naming/grouping decisions, cannot be auto-modeled)"; + } + + $oldSchemas = array_keys($oldSpec['components']['schemas'] ?? []); + $newSchemas = array_keys($newSpec['components']['schemas'] ?? []); + foreach (array_diff($oldSchemas, $newSchemas) as $s) { + if (isset($reachableOld[$s])) { + $issues[] = "NEEDS_HUMAN: schema removed: {$s} (breaking, requires a deliberate major)"; + } + } + + $ignored = []; + foreach ($map['ignore']['schemas'] ?? [] as $ig) { + $ignored[$ig['schema']] = true; + } + $mapped = []; + foreach ($map['types'] as $entry) { + foreach (typeSpecSchemas($entry) as $s) { + $mapped[$s] = true; + } + } + foreach ($map['enums'] as $entry) { + if (isset($entry['spec']['schema'])) { + $mapped[$entry['spec']['schema']] = true; + } + } + foreach (array_diff($newSchemas, $oldSchemas) as $s) { + if (isset($reachableNew[$s]) && ! isset($ignored[$s]) && ! isset($mapped[$s])) { + $issues[] = "NEEDS_HUMAN: new schema: {$s} (needs a mapping decision, cannot be auto-modeled)"; + } + } + + $oldWebhooks = array_keys($oldSpec['webhooks'] ?? []); + $newWebhooks = array_keys($newSpec['webhooks'] ?? []); + foreach (array_diff($oldWebhooks, $newWebhooks) as $w) { + $issues[] = "NEEDS_HUMAN: webhook topic removed: {$w} (breaking, requires a deliberate major)"; + } + + // removed enum values / removed or type-changed properties on mapped schemas + foreach ($map['enums'] as $entry) { + $s = $entry['spec']; + if (isset($s['schema']) && (! isset($reachableOld[$s['schema']]) || ! isset($reachableNew[$s['schema']]))) { + continue; + } + $oldValues = enumSpecValues($oldSpec, $entry); + $newValues = enumSpecValues($newSpec, $entry); + if ($oldValues === null || $newValues === null) { + continue; + } + $removed = array_diff($oldValues, $newValues); + if (! empty($removed)) { + $label = $entry['sdk']['class']; + $issues[] = 'NEEDS_HUMAN: enum value(s) removed from '.$label.': '.implode(', ', $removed).' (breaking, requires a deliberate major)'; + } + } + + foreach ($map['types'] as $entry) { + $path = $entry['path'] ?? null; + foreach (typeSpecSchemas($entry) as $schemaName) { + if (! isset($reachableOld[$schemaName]) || ! isset($reachableNew[$schemaName])) { + continue; + } + $oldProps = $path !== null ? nestedProps($oldSpec, $schemaName, $path) : schemaProps($oldSpec, $schemaName); + $newProps = $path !== null ? nestedProps($newSpec, $schemaName, $path) : schemaProps($newSpec, $schemaName); + if ($oldProps === null || $newProps === null) { + continue; + } + $removed = array_diff($oldProps, $newProps); + if (! empty($removed)) { + $label = $schemaName.($path !== null ? ".{$path}" : ''); + $issues[] = 'NEEDS_HUMAN: propert'.(count($removed) === 1 ? 'y' : 'ies')." removed from {$label}: ".implode(', ', $removed).' (breaking, requires a deliberate major)'; + } + } + } + + sort($issues); + + return $issues; +} + +// --------------------------------------------------------------------------- +// Coverage report (non-blocking) +// --------------------------------------------------------------------------- + +function computeCoverageReport(array $map, array $spec, array $reachable): array +{ + $mapped = []; + foreach ($map['types'] as $entry) { + foreach (typeSpecSchemas($entry) as $s) { + $mapped[$s] = true; + } + } + $ignored = []; + foreach ($map['ignore']['schemas'] ?? [] as $ig) { + $ignored[$ig['schema']] = true; + } + + $gaps = []; + foreach (array_keys($reachable) as $schema) { + if (! isset($mapped[$schema]) && ! isset($ignored[$schema])) { + $gaps[] = $schema; + } + } + sort($gaps); + + return $gaps; +} + +// --------------------------------------------------------------------------- +// Applying changes (surgical text splicing) +// --------------------------------------------------------------------------- + +function detectIndent(string $line): string +{ + preg_match('/^(\s*)/', $line, $m); + + return $m[1]; +} + +function applyEnumCaseInsertion(string $root, string $file, string $className, string $value, array $newSpec, array $specValuesByEnum): void +{ + $path = "{$root}/{$file}"; + $lines = file($path, FILE_IGNORE_NEW_LINES); + $classes = scanClassesDetailed($path); + $info = $classes[$className]; + $cases = $info['enumCases']; + + $specOrder = $specValuesByEnum[$className] ?? null; + $anchorLine = null; + + if ($specOrder !== null) { + $idx = array_search($value, $specOrder, true); + if ($idx !== false) { + $sdkValueToLine = []; + foreach ($cases as $c) { + $sdkValueToLine[$c['value']] = $c['line']; + } + for ($j = $idx - 1; $j >= 0; $j--) { + if (isset($sdkValueToLine[$specOrder[$j]])) { + $anchorLine = $sdkValueToLine[$specOrder[$j]]; + + break; + } + } + } + } + + if ($anchorLine === null) { + // no earlier spec value already modeled -- insert as the first case, right after the enum's opening brace + $anchorLine = $cases[0]['line'] - 1; // insert before first case (i.e. after opening brace line) + $indent = detectIndent($lines[$cases[0]['line'] - 1]); + $caseName = deriveEnumCaseName($className, $value); + array_splice($lines, $anchorLine, 0, ["{$indent}case {$caseName} = '{$value}';"]); + } else { + $indent = detectIndent($lines[$anchorLine - 1]); + $caseName = deriveEnumCaseName($className, $value); + array_splice($lines, $anchorLine, 0, ["{$indent}case {$caseName} = '{$value}';"]); + } + + file_put_contents($path, implode("\n", $lines)."\n"); +} + +/** Derive an UPPER_SNAKE case name for a new enum value, following the file's own convention. */ +function deriveEnumCaseName(string $className, string $value): string +{ + if ($className === 'BusinessIndustry' && ctype_digit($value)) { + return "NAICS_{$value}"; + } + if ($className === 'EstimatedAnnualRevenue') { + return 'RANGE_'.strtoupper($value); + } + $name = strtoupper(preg_replace('/[^A-Za-z0-9]+/', '_', $value)); + if (preg_match('/^[0-9]/', $name)) { + $name = 'V_'.$name; + } + + return $name; +} + +/** + * Apply a 2-or-3-part field insertion to one class: constructor property (always), + * fromArray line (only if the class has fromArray), toArray line (only if the class has toArray, + * honoring its existing literal-vs-conditional style). + */ +function applyFieldInsertion(string $root, string $file, string $className, string $field, array $propSchema): void +{ + $path = "{$root}/{$file}"; + $camel = snakeToCamel($field); + $phpType = phpTypeFor($propSchema); + + // Re-scan fresh each time so line numbers stay valid across successive edits to the same file. + $lines = file($path, FILE_IGNORE_NEW_LINES); + $classes = scanClassesDetailed($path); + $info = $classes[$className]; + + // 1) constructor promoted property -- always last param, so it is always syntactically safe. + // Preserve the file's own convention: if the previous last param had no trailing comma, the + // new (now-last) line gets none either, and the previous line gains one since it's no longer last. + if ($info['ctor'] !== null && ! empty($info['ctor']['params'])) { + $lastParam = end($info['ctor']['params']); + $lastLine = $lastParam['line']; + $indent = detectIndent($lines[$lastLine - 1]); + $hadTrailingComma = str_ends_with(rtrim($lines[$lastLine - 1]), ','); + if (! $hadTrailingComma) { + $lines[$lastLine - 1] = rtrim($lines[$lastLine - 1]).','; + } + $newLine = "{$indent}public ?{$phpType} \${$camel} = null".($hadTrailingComma ? ',' : ''); + array_splice($lines, $lastLine, 0, [$newLine]); + file_put_contents($path, implode("\n", $lines)."\n"); + $lines = file($path, FILE_IGNORE_NEW_LINES); + $classes = scanClassesDetailed($path); + $info = $classes[$className]; + } + + // 2) fromArray line -- only if the method exists. Same trailing-comma convention as above. + if ($info['fromArray'] !== null && ! empty($info['fromArray']['args'])) { + $lastArg = end($info['fromArray']['args']); + $lastLine = $lastArg['line']; + $indent = detectIndent($lines[$lastLine - 1]); + $hadTrailingComma = str_ends_with(rtrim($lines[$lastLine - 1]), ','); + if (! $hadTrailingComma) { + $lines[$lastLine - 1] = rtrim($lines[$lastLine - 1]).','; + } + $newLine = "{$indent}{$camel}: \$data['{$field}'] ?? null".($hadTrailingComma ? ',' : ''); + array_splice($lines, $lastLine, 0, [$newLine]); + file_put_contents($path, implode("\n", $lines)."\n"); + $lines = file($path, FILE_IGNORE_NEW_LINES); + $classes = scanClassesDetailed($path); + $info = $classes[$className]; + } + + // 3) toArray line -- only if the method exists, honoring the class's own style. + $ta = $info['toArray']; + if ($ta['style'] === 'literal' && isset($ta['literalLastLine'])) { + $lastLine = $ta['literalLastLine']; + $indent = detectIndent($lines[$lastLine - 1]); + if (! str_ends_with(rtrim($lines[$lastLine - 1]), ',')) { + $lines[$lastLine - 1] = rtrim($lines[$lastLine - 1]).','; + } + array_splice($lines, $lastLine, 0, ["{$indent}'{$field}' => \$this->{$camel},"]); + file_put_contents($path, implode("\n", $lines)."\n"); + } elseif ($ta['style'] === 'conditional' && isset($ta['conditionalReturnLine'])) { + $returnLine = $ta['conditionalReturnLine']; + $indent = detectIndent($lines[$returnLine - 1]); + $block = [ + '', + "{$indent}if (\$this->{$camel} !== null) {", + "{$indent} \$data['{$field}'] = \$this->{$camel};", + "{$indent}}", + ]; + array_splice($lines, $returnLine - 1, 0, $block); + file_put_contents($path, implode("\n", $lines)."\n"); + } +} + +// --------------------------------------------------------------------------- +// Version bump +// --------------------------------------------------------------------------- + +function computeBump(array $applicable): ?string +{ + $hasEnum = false; + $hasField = false; + foreach ($applicable as $c) { + if ($c['kind'] === 'enum-member-added') { + $hasEnum = true; + } + if ($c['kind'] === 'field-added') { + $hasField = true; + } + } + if ($hasEnum) { + return 'minor'; + } + if ($hasField) { + return 'patch'; + } + + return null; +} + +function bumpVersionString(string $version, string $bump): string +{ + [$major, $minor, $patch] = array_map('intval', explode('.', $version)); + if ($bump === 'minor') { + $minor++; + $patch = 0; + } elseif ($bump === 'patch') { + $patch++; + } + + return "{$major}.{$minor}.{$patch}"; +} + +function bumpVersion(string $root, string $bump): string +{ + $file = "{$root}/src/BlindPay.php"; + $source = file_get_contents($file); + if (! preg_match("/private const VERSION = '([0-9]+\\.[0-9]+\\.[0-9]+)';/", $source, $m)) { + fwrite(STDERR, "[api-sync] FAIL: could not find VERSION const in src/BlindPay.php\n"); + exit(1); + } + $new = bumpVersionString($m[1], $bump); + $source = str_replace("private const VERSION = '{$m[1]}';", "private const VERSION = '{$new}';", $source); + file_put_contents($file, $source); + + return $new; +} + +// --------------------------------------------------------------------------- +// Determinism helpers +// --------------------------------------------------------------------------- + +function encodeJsonDeterministic(array $data): string +{ + return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n"; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +/** + * Entry point, extracted into a function so tests can `define('API_SYNC_LIB_ONLY', true)` + * and require this file to get the helpers above without running the CLI. + */ +function runCli(array $argv, string $root): void +{ + $opts = parseArgs($argv); + $mode = $opts['apply'] ? 'apply' : 'check'; + $specPath = $opts['spec'] ?? ($root.'/.api-sync/spec-current.json'); + $reportPath = $opts['report'] ?? null; + + $map = loadJson($root.'/.api-sync/spec-map.json'); + $unmodeled = loadJson($root.'/.api-sync/unmodeled.json'); + $divergences = loadJson($root.'/.api-sync/known-divergences.json'); + $oldSpec = loadJson($root.'/.api-sync/spec-snapshot.json'); + $newSpec = loadJson($specPath); + + $unmodeledIndex = []; + foreach ($unmodeled['entries'] as $e) { + $key = ($e['schema'] ?? '').'|'.($e['path'] ?? '').'|'.($e['field'] ?? ''); + $unmodeledIndex[$key] = true; + } + + $divergenceEnumIndex = []; + foreach ($divergences['enumValues'] as $d) { + if ($d['specValue'] !== null) { + $divergenceEnumIndex["{$d['enum']}|{$d['specValue']}"] = true; + } + } + + $classIndex = scanAllClasses($root); + + $mapErrors = validateMap($map, $classIndex, $root); + $mapErrors = array_map(fn ($e) => "NEEDS_HUMAN: {$e}", $mapErrors); + + $reachableOld = computeReachable($oldSpec); + $reachableNew = computeReachable($newSpec); + + $structuralIssues = empty($mapErrors) ? computeStructuralDiff($oldSpec, $newSpec, $reachableOld, $reachableNew, $map) : []; + + [$enumApplicable, $enumNeedsHuman] = empty($mapErrors) + ? reconcileEnums($map, $newSpec, $reachableNew, $classIndex, $divergenceEnumIndex) + : [[], []]; + [$fieldApplicable, $fieldNeedsHuman] = empty($mapErrors) + ? reconcileTypes($map, $newSpec, $reachableNew, $classIndex, $unmodeledIndex) + : [[], []]; + + $coverage = computeCoverageReport($map, $newSpec, $reachableNew); + + $needsHuman = array_merge($mapErrors, $structuralIssues, $enumNeedsHuman, $fieldNeedsHuman); + + $applicable = array_merge($enumApplicable, $fieldApplicable); + usort($applicable, fn ($a, $b) => $a['sortKey'] <=> $b['sortKey']); + + $report = [ + 'mode' => $mode, + 'spec' => $specPath, + 'applied' => [], + 'needsHuman' => $needsHuman, + 'bump' => null, + 'coverageGaps' => $coverage, + ]; + + if ($mode === 'check') { + if (! empty($needsHuman)) { + fwrite(STDERR, "\n[api-sync] FAIL -- needs a human decision:\n"); + foreach ($needsHuman as $line) { + fwrite(STDERR, " - {$line}\n"); + } + if ($reportPath !== null) { + file_put_contents($reportPath, encodeJsonDeterministic($report)); + } + exit(1); + } + if (! empty($applicable)) { + fwrite(STDERR, "\n[api-sync] FAIL -- pending drift, run `php scripts/api-sync.php --apply`:\n"); + foreach ($applicable as $c) { + if ($c['kind'] === 'enum-member-added') { + fwrite(STDERR, " - {$c['enum']}: missing case for spec value '{$c['value']}' ({$c['file']})\n"); + } else { + fwrite(STDERR, " - {$c['schema']}".($c['path'] ? ".{$c['path']}" : '')." -> {$c['class']}: missing field '{$c['field']}' ({$c['file']})\n"); + } + } + if ($reportPath !== null) { + $report['applied'] = $applicable; + file_put_contents($reportPath, encodeJsonDeterministic($report)); + } + exit(1); + } + if ($reportPath !== null) { + file_put_contents($reportPath, encodeJsonDeterministic($report)); + } + exit(0); + } + + // --apply + if (! empty($needsHuman)) { + fwrite(STDERR, "\n[api-sync] FAIL -- needs a human decision, nothing applied:\n"); + foreach ($needsHuman as $line) { + fwrite(STDERR, " - {$line}\n"); + } + if ($reportPath !== null) { + file_put_contents($reportPath, encodeJsonDeterministic($report)); + } + exit(1); + } + + if (empty($applicable)) { + if ($reportPath !== null) { + file_put_contents($reportPath, encodeJsonDeterministic($report)); + } + exit(0); + } + + // Precompute the full spec-ordered value list per enum (for anchor lookup), sorted deterministically. + $specValuesByEnum = []; + foreach ($map['enums'] as $entry) { + $values = enumSpecValues($newSpec, $entry); + if ($values !== null) { + $specValuesByEnum[$entry['sdk']['class']] = array_values($values); + } + } + + foreach ($applicable as $change) { + if ($change['kind'] === 'enum-member-added') { + applyEnumCaseInsertion($root, $change['file'], $change['enum'], $change['value'], $newSpec, $specValuesByEnum); + } else { + applyFieldInsertion($root, $change['file'], $change['class'], $change['field'], $change['propSchema']); + } + } + + $bump = computeBump($applicable); + if ($bump !== null) { + $newVersion = bumpVersion($root, $bump); + $report['bump'] = ['type' => $bump, 'version' => $newVersion]; + } + + // Refresh the snapshot with the SOURCE SPEC FILE'S BYTES, verbatim -- never re-serialize via + // json_decode/json_encode, which would silently reformat indentation, escaping and key order + // and turn every future sync PR into an ~86k-line unreviewable snapshot diff. + copy($specPath, $root.'/.api-sync/spec-snapshot.json'); + + $report['applied'] = $applicable; + if ($reportPath !== null) { + file_put_contents($reportPath, encodeJsonDeterministic($report)); + } + + fwrite(STDOUT, '[api-sync] applied '.count($applicable).' change(s)'.($bump !== null ? ", version bump: {$bump} -> {$report['bump']['version']}" : '').".\n"); + exit(0); +} + +if (! defined('API_SYNC_LIB_ONLY')) { + runCli($argv, $root); +} diff --git a/src/BlindPay.php b/src/BlindPay.php index 41b8c8c..ca44cd3 100644 --- a/src/BlindPay.php +++ b/src/BlindPay.php @@ -39,7 +39,7 @@ class BlindPay implements ApiClientInterface { private const BASE_URL = 'https://api.blindpay.com/v1/'; - private const VERSION = '3.0.0'; + private const VERSION = '3.1.0'; private Client $httpClient; diff --git a/src/Resources/Quotes/Quotes.php b/src/Resources/Quotes/Quotes.php index 94032fb..da98c3a 100644 --- a/src/Resources/Quotes/Quotes.php +++ b/src/Resources/Quotes/Quotes.php @@ -25,7 +25,8 @@ public function __construct( public TransactionDocumentType $transactionDocumentType, public ?Network $network = null, public ?StablecoinToken $token = null, - public ?string $description = null + public ?string $description = null, + public ?string $refundWalletAddress = null ) {} public function toArray(): array @@ -42,6 +43,7 @@ public function toArray(): array 'network' => $this->network?->value, 'token' => $this->token?->value, 'description' => $this->description, + 'refund_wallet_address' => $this->refundWalletAddress, ]; } } diff --git a/src/Types/BankingPartner.php b/src/Types/BankingPartner.php index 39d0786..ea170f7 100644 --- a/src/Types/BankingPartner.php +++ b/src/Types/BankingPartner.php @@ -10,4 +10,5 @@ enum BankingPartner: string case CITI = 'citi'; case HSBC = 'hsbc'; case CFSB = 'cfsb'; + case PORTAGE = 'portage'; } diff --git a/src/Types/BusinessIndustry.php b/src/Types/BusinessIndustry.php index 8d360c3..9ed578b 100644 --- a/src/Types/BusinessIndustry.php +++ b/src/Types/BusinessIndustry.php @@ -118,6 +118,7 @@ enum BusinessIndustry: string case NAICS_455219 = '455219'; case NAICS_424210 = '424210'; case NAICS_456110 = '456110'; + case NAICS_446120 = '446120'; case NAICS_541511 = '541511'; case NAICS_541512 = '541512'; case NAICS_541519 = '541519'; diff --git a/src/Types/Currency.php b/src/Types/Currency.php index e9d30c9..0aa4457 100644 --- a/src/Types/Currency.php +++ b/src/Types/Currency.php @@ -14,4 +14,5 @@ enum Currency: string case MXN = 'MXN'; case COP = 'COP'; case ARS = 'ARS'; + case EUR = 'EUR'; } diff --git a/tests/ApiSync/ApiSyncTest.php b/tests/ApiSync/ApiSyncTest.php new file mode 100644 index 0000000..5b67c5d --- /dev/null +++ b/tests/ApiSync/ApiSyncTest.php @@ -0,0 +1,619 @@ +fixtureRoot = sys_get_temp_dir().'/blindpay-api-sync-test-'.bin2hex(random_bytes(6)); + mkdir("{$this->fixtureRoot}/src/Types", 0777, true); + mkdir("{$this->fixtureRoot}/src/Resources/Widgets", 0777, true); + + file_put_contents("{$this->fixtureRoot}/src/Types/WidgetColor.php", <<<'PHP' + fixtureRoot}/src/Resources/Widgets/Widgets.php", <<<'PHP' + $this->name, + ]; + } + } + + readonly class WidgetInputConditional + { + public function __construct( + public string $name, + public ?string $extra = null + ) {} + + public function toArray(): array + { + $data = [ + 'name' => $this->name, + ]; + + if ($this->extra !== null) { + $data['extra'] = $this->extra; + } + + return $data; + } + } + + readonly class WidgetNoToArray + { + public function __construct( + public string $id + ) {} + + public static function fromArray(array $data): self + { + return new self( + id: $data['id'] + ); + } + } + + PHP); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->fixtureRoot); + } + + private function removeDirectory(string $dir): void + { + if (! is_dir($dir)) { + return; + } + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($items as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($dir); + } + + private function baseSpec(array $overrides = []): array + { + $spec = [ + 'paths' => [ + '/v1/widgets' => [ + 'post' => [ + 'requestBody' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/WidgetOut']]]], + 'responses' => ['200' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/WidgetOut']]]]], + ], + ], + ], + 'webhooks' => [], + 'components' => [ + 'schemas' => [ + 'WidgetOut' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'color' => ['type' => 'string', 'enum' => ['red', 'blue']], + ], + ], + ], + ], + ]; + + return array_replace_recursive($spec, $overrides); + } + + private function widgetMap(): array + { + return [ + 'enums' => [ + [ + 'spec' => ['schema' => 'WidgetOut', 'property' => 'color'], + 'sdk' => ['file' => 'src/Types/WidgetColor.php', 'class' => 'WidgetColor'], + ], + ], + 'types' => [ + [ + 'spec' => 'WidgetOut', + 'sdk' => [['file' => 'src/Resources/Widgets/Widgets.php', 'class' => 'WidgetResponse']], + ], + ], + 'ignore' => ['schemas' => []], + ]; + } + + // ---- enum case insertion ---- + + #[Test] + public function it_detects_a_missing_enum_case_as_applicable(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $newSpec = $this->baseSpec(['components' => ['schemas' => ['WidgetOut' => ['properties' => ['color' => ['enum' => ['red', 'blue', 'green']]]]]]]); + $reachable = computeReachable($newSpec); + + [$applicable, $needsHuman] = reconcileEnums($this->widgetMap(), $newSpec, $reachable, $classIndex, []); + + $this->assertEmpty($needsHuman); + $this->assertCount(1, $applicable); + $this->assertSame('enum-member-added', $applicable[0]['kind']); + $this->assertSame('green', $applicable[0]['value']); + } + + #[Test] + public function it_applies_a_missing_enum_case_following_spec_order(): void + { + $newSpec = $this->baseSpec(['components' => ['schemas' => ['WidgetOut' => ['properties' => ['color' => ['enum' => ['red', 'green', 'blue']]]]]]]); + $specValuesByEnum = ['WidgetColor' => ['red', 'green', 'blue']]; + + applyEnumCaseInsertion($this->fixtureRoot, 'src/Types/WidgetColor.php', 'WidgetColor', 'green', $newSpec, $specValuesByEnum); + + $source = file_get_contents("{$this->fixtureRoot}/src/Types/WidgetColor.php"); + $this->assertStringContainsString("case RED = 'red';\n case GREEN = 'green';\n case BLUE = 'blue';", $source); + } + + #[Test] + public function it_honors_a_known_divergence_and_does_not_flag_a_mismatched_enum_value_as_missing(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $newSpec = $this->baseSpec(['components' => ['schemas' => ['WidgetOut' => ['properties' => ['color' => ['enum' => ['red', 'blu']]]]]]]); // spec has a typo'd value + $reachable = computeReachable($newSpec); + $divergenceIndex = ['WidgetColor|blu' => true]; + + [$applicable, $needsHuman] = reconcileEnums($this->widgetMap(), $newSpec, $reachable, $classIndex, $divergenceIndex); + + $this->assertEmpty($needsHuman); + $this->assertEmpty($applicable); + } + + // ---- field insertion: three-part (constructor + fromArray + toArray) ---- + + #[Test] + public function it_detects_a_missing_field_as_applicable(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $newSpec = $this->baseSpec(['components' => ['schemas' => ['WidgetOut' => ['properties' => ['description' => ['type' => ['string', 'null']]]]]]]); + $reachable = computeReachable($newSpec); + + [$applicable, $needsHuman] = reconcileTypes($this->widgetMap(), $newSpec, $reachable, $classIndex, []); + + $this->assertEmpty($needsHuman); + $this->assertCount(1, $applicable); + $this->assertSame('field-added', $applicable[0]['kind']); + $this->assertSame('description', $applicable[0]['field']); + $this->assertSame('WidgetResponse', $applicable[0]['class']); + } + + #[Test] + public function it_applies_the_three_part_insertion_for_a_class_with_both_fromarray_and_toarray(): void + { + // WidgetInputConditional has neither fromArray nor a *response* shape by itself; use a + // class with both fromArray and toArray to exercise all three edits at once. + file_put_contents("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php", <<<'PHP' + $this->id, + 'name' => $this->name, + ]; + } + } + + PHP); + + applyFieldInsertion( + $this->fixtureRoot, + 'src/Resources/Widgets/Widgets.php', + 'WidgetBoth', + 'description', + ['type' => ['string', 'null']] + ); + + $source = file_get_contents("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"); + + $this->assertStringContainsString('public string $name,', $source); + $this->assertStringContainsString('public ?string $description = null', $source); + $this->assertStringContainsString("name: \$data['name'],", $source); + $this->assertStringContainsString("description: \$data['description'] ?? null", $source); + $this->assertStringContainsString("'name' => \$this->name,", $source); + $this->assertStringContainsString("'description' => \$this->description,", $source); + + // still valid PHP + $this->assertPhpFileParses("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"); + } + + #[Test] + public function it_applies_only_two_edits_for_a_class_without_toarray(): void + { + applyFieldInsertion( + $this->fixtureRoot, + 'src/Resources/Widgets/Widgets.php', + 'WidgetNoToArray', + 'label', + ['type' => 'string'] + ); + + $source = file_get_contents("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"); + $this->assertStringContainsString('public ?string $label = null', $source); + $this->assertStringContainsString("label: \$data['label'] ?? null", $source); + // no toArray in this class at all -- nothing named 'label' => should appear + $this->assertStringNotContainsString("'label' =>", $source); + + $this->assertPhpFileParses("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"); + } + + #[Test] + public function it_uses_the_literal_style_for_a_class_whose_toarray_is_an_unconditional_array(): void + { + applyFieldInsertion( + $this->fixtureRoot, + 'src/Resources/Widgets/Widgets.php', + 'WidgetInputLiteral', + 'nickname', + ['type' => ['string', 'null']] + ); + + $source = file_get_contents("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"); + $this->assertStringContainsString("'nickname' => \$this->nickname,", $source); + $this->assertStringNotContainsString('if ($this->nickname !== null)', $source); + $this->assertPhpFileParses("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"); + } + + #[Test] + public function it_uses_the_conditional_style_for_a_class_whose_toarray_already_conditionally_includes_fields(): void + { + applyFieldInsertion( + $this->fixtureRoot, + 'src/Resources/Widgets/Widgets.php', + 'WidgetInputConditional', + 'nickname', + ['type' => ['string', 'null']] + ); + + $source = file_get_contents("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"); + $this->assertStringContainsString('if ($this->nickname !== null) {', $source); + $this->assertStringContainsString("\$data['nickname'] = \$this->nickname;", $source); + $this->assertPhpFileParses("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"); + } + + #[Test] + public function it_honors_unmodeled_json_and_does_not_flag_a_listed_field_as_missing(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $newSpec = $this->baseSpec(['components' => ['schemas' => ['WidgetOut' => ['properties' => ['legacy_field' => ['type' => 'string']]]]]]); + $reachable = computeReachable($newSpec); + $unmodeledIndex = ['WidgetOut||legacy_field' => true]; + + [$applicable, $needsHuman] = reconcileTypes($this->widgetMap(), $newSpec, $reachable, $classIndex, $unmodeledIndex); + + $this->assertEmpty($needsHuman); + $this->assertEmpty($applicable); + } + + // ---- idempotency ---- + + #[Test] + public function applying_a_field_twice_only_inserts_it_once(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $newSpec = $this->baseSpec(['components' => ['schemas' => ['WidgetOut' => ['properties' => ['description' => ['type' => ['string', 'null']]]]]]]); + $reachable = computeReachable($newSpec); + + [$applicable] = reconcileTypes($this->widgetMap(), $newSpec, $reachable, $classIndex, []); + $this->assertCount(1, $applicable); + applyFieldInsertion($this->fixtureRoot, $applicable[0]['file'], $applicable[0]['class'], $applicable[0]['field'], $applicable[0]['propSchema']); + + // re-scan and reconcile again -- should now be fully caught up, nothing left to apply + $classIndex = scanAllClasses($this->fixtureRoot); + [$applicableAgain, $needsHumanAgain] = reconcileTypes($this->widgetMap(), $newSpec, $reachable, $classIndex, []); + $this->assertEmpty($applicableAgain); + $this->assertEmpty($needsHumanAgain); + + // one edit each in the constructor, fromArray and toArray -- never a duplicate insertion. + $source = file_get_contents("{$this->fixtureRoot}/{$applicable[0]['file']}"); + $this->assertSame(3, substr_count($source, 'description')); + } + + // ---- NEEDS_HUMAN: removals ---- + + #[Test] + public function it_flags_a_removed_property_as_needs_human(): void + { + $oldSpec = $this->baseSpec(); + $newSpec = $this->baseSpec(); + unset($newSpec['components']['schemas']['WidgetOut']['properties']['name']); + $reachableOld = computeReachable($oldSpec); + $reachableNew = computeReachable($newSpec); + + $issues = computeStructuralDiff($oldSpec, $newSpec, $reachableOld, $reachableNew, $this->widgetMap()); + + $this->assertNotEmpty(array_filter($issues, fn ($i) => str_contains($i, 'removed from WidgetOut') && str_contains($i, 'name'))); + } + + #[Test] + public function it_flags_a_removed_enum_value_as_needs_human(): void + { + $oldSpec = $this->baseSpec(); + $newSpec = $this->baseSpec(); + // array_replace_recursive merges indexed arrays by key, it cannot truncate one -- replace outright. + $newSpec['components']['schemas']['WidgetOut']['properties']['color']['enum'] = ['red']; + $reachableOld = computeReachable($oldSpec); + $reachableNew = computeReachable($newSpec); + + $issues = computeStructuralDiff($oldSpec, $newSpec, $reachableOld, $reachableNew, $this->widgetMap()); + + $this->assertNotEmpty(array_filter($issues, fn ($i) => str_contains($i, 'enum value(s) removed from WidgetColor') && str_contains($i, 'blue'))); + } + + #[Test] + public function it_flags_a_removed_operation_as_needs_human(): void + { + $oldSpec = $this->baseSpec(); + $newSpec = $this->baseSpec(); + unset($newSpec['paths']['/v1/widgets']['post']); + unset($newSpec['paths']['/v1/widgets']); + $reachableOld = computeReachable($oldSpec); + $reachableNew = computeReachable($newSpec); + + $issues = computeStructuralDiff($oldSpec, $newSpec, $reachableOld, $reachableNew, $this->widgetMap()); + + $this->assertNotEmpty(array_filter($issues, fn ($i) => str_contains($i, 'operation removed: post /v1/widgets'))); + } + + #[Test] + public function it_flags_a_new_operation_as_needs_human(): void + { + $oldSpec = $this->baseSpec(); + $newSpec = $this->baseSpec(); + $newSpec['paths']['/v1/gadgets'] = ['post' => ['requestBody' => [], 'responses' => []]]; + $reachableOld = computeReachable($oldSpec); + $reachableNew = computeReachable($newSpec); + + $issues = computeStructuralDiff($oldSpec, $newSpec, $reachableOld, $reachableNew, $this->widgetMap()); + + $this->assertNotEmpty(array_filter($issues, fn ($i) => str_contains($i, 'new operation: post /v1/gadgets'))); + } + + #[Test] + public function it_flags_a_new_reachable_unmapped_schema_as_needs_human(): void + { + $oldSpec = $this->baseSpec(); + $newSpec = $this->baseSpec(); + $newSpec['components']['schemas']['GadgetOut'] = ['type' => 'object', 'properties' => ['id' => ['type' => 'string']]]; + $newSpec['paths']['/v1/widgets']['post']['responses']['200']['content']['application/json']['schema'] = ['$ref' => '#/components/schemas/GadgetOut']; + $reachableOld = computeReachable($oldSpec); + $reachableNew = computeReachable($newSpec); + + $issues = computeStructuralDiff($oldSpec, $newSpec, $reachableOld, $reachableNew, $this->widgetMap()); + + $this->assertNotEmpty(array_filter($issues, fn ($i) => str_contains($i, 'new schema: GadgetOut'))); + } + + #[Test] + public function a_new_unreferenced_schema_is_not_flagged_at_all(): void + { + $oldSpec = $this->baseSpec(); + $newSpec = $this->baseSpec(); + $newSpec['components']['schemas']['OrphanOut'] = ['type' => 'object', 'properties' => ['id' => ['type' => 'string']]]; + $reachableOld = computeReachable($oldSpec); + $reachableNew = computeReachable($newSpec); + + $issues = computeStructuralDiff($oldSpec, $newSpec, $reachableOld, $reachableNew, $this->widgetMap()); + + $this->assertEmpty(array_filter($issues, fn ($i) => str_contains($i, 'OrphanOut'))); + } + + // ---- map validity ---- + + #[Test] + public function it_reports_a_map_anchor_that_does_not_exist(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $map = $this->widgetMap(); + $map['types'][0]['sdk'][0]['class'] = 'NoSuchClass'; + + $errors = validateMap($map, $classIndex, $this->fixtureRoot); + + $this->assertNotEmpty(array_filter($errors, fn ($e) => str_contains($e, 'NoSuchClass'))); + } + + #[Test] + public function it_reports_a_map_anchor_at_the_wrong_file(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $map = $this->widgetMap(); + $map['types'][0]['sdk'][0]['file'] = 'src/Resources/Widgets/WrongFile.php'; + + $errors = validateMap($map, $classIndex, $this->fixtureRoot); + + $this->assertNotEmpty(array_filter($errors, fn ($e) => str_contains($e, 'mismatch'))); + } + + #[Test] + public function a_valid_map_produces_no_errors(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $errors = validateMap($this->widgetMap(), $classIndex, $this->fixtureRoot); + $this->assertEmpty($errors); + } + + // ---- version bump classification ---- + + #[Test] + public function an_enum_member_addition_bumps_minor(): void + { + $this->assertSame('minor', computeBump([['kind' => 'enum-member-added']])); + } + + #[Test] + public function a_field_only_addition_bumps_patch(): void + { + $this->assertSame('patch', computeBump([['kind' => 'field-added']])); + } + + #[Test] + public function a_mix_of_enum_and_field_changes_bumps_minor(): void + { + $this->assertSame('minor', computeBump([['kind' => 'field-added'], ['kind' => 'enum-member-added']])); + } + + #[Test] + public function no_changes_means_no_bump(): void + { + $this->assertNull(computeBump([])); + } + + #[Test] + public function version_string_bumps_correctly(): void + { + $this->assertSame('3.1.0', bumpVersionString('3.0.0', 'minor')); + $this->assertSame('3.0.6', bumpVersionString('3.0.5', 'patch')); + $this->assertSame('3.1.0', bumpVersionString('3.0.9', 'minor')); + } + + // ---- snapshot refresh must copy bytes verbatim, never re-serialize ---- + + #[Test] + public function apply_refreshes_the_snapshot_as_a_byte_identical_copy_of_the_source_spec(): void + { + // A full, real invocation via a copy of the real script, run as a subprocess against a + // throwaway repo root -- exercises the exact code path apply uses, including the file + // copy, without exiting the test process. + mkdir("{$this->fixtureRoot}/.api-sync", 0777, true); + mkdir("{$this->fixtureRoot}/scripts", 0777, true); + copy(__DIR__.'/../../scripts/api-sync.php', "{$this->fixtureRoot}/scripts/api-sync.php"); + file_put_contents("{$this->fixtureRoot}/src/BlindPay.php", <<<'PHP' + fixtureRoot}/.api-sync/spec-map.json", json_encode($this->widgetMap())); + file_put_contents("{$this->fixtureRoot}/.api-sync/unmodeled.json", json_encode(['entries' => []])); + file_put_contents("{$this->fixtureRoot}/.api-sync/known-divergences.json", json_encode(['enumValues' => [], 'fields' => []])); + file_put_contents("{$this->fixtureRoot}/.api-sync/spec-snapshot.json", json_encode($this->baseSpec(), JSON_PRETTY_PRINT)); + + // Deliberately unusual formatting (2-space indent, escaped slashes, a trailing newline) so + // a round-tripped json_decode/json_encode would visibly differ from a raw byte copy. + $newSpec = $this->baseSpec(); + $newSpec['components']['schemas']['WidgetOut']['properties']['description'] = ['type' => ['string', 'null']]; + $sourceSpecPath = "{$this->fixtureRoot}/.api-sync/spec-current.json"; + // default json_encode escapes slashes and this appends a distinctive double trailing + // newline, so a re-serialized (json_decode + json_encode) snapshot would visibly differ. + file_put_contents($sourceSpecPath, json_encode($newSpec, JSON_PRETTY_PRINT).\PHP_EOL.\PHP_EOL); + + $cmd = sprintf( + 'php %s --apply --spec=%s 2>&1', + escapeshellarg("{$this->fixtureRoot}/scripts/api-sync.php"), + escapeshellarg($sourceSpecPath) + ); + exec($cmd, $output, $exitCode); + + $this->assertSame(0, $exitCode, 'apply failed: '.implode("\n", $output)); + $this->assertSame( + file_get_contents($sourceSpecPath), + file_get_contents("{$this->fixtureRoot}/.api-sync/spec-snapshot.json"), + 'refreshed snapshot must be a byte-identical copy of the source spec file, not a re-serialization' + ); + } + + // ---- helper assertion ---- + + private function assertPhpFileParses(string $path): void + { + $output = []; + $exitCode = 0; + exec('php -l '.escapeshellarg($path).' 2>&1', $output, $exitCode); + $this->assertSame(0, $exitCode, "php -l failed for {$path}: ".implode("\n", $output)); + } +} From 5d2f493ac0613b6e3070ae86227d4019d4bc5979 Mon Sep 17 00:00:00 2001 From: Eric Viana Date: Mon, 3 Aug 2026 20:52:23 -0300 Subject: [PATCH 2/5] fix(api-sync): byte-copy the snapshot, detect type mismatches, add --audit-types Three defects found by cross-repo review (the same patcher design ported to python/swift hit them too): - The snapshot refresh re-serialized the spec via json_decode/json_encode instead of copying bytes verbatim, which is semantically a no-op but rewrites indentation/escaping/key order -- every future sync PR would have carried an ~86k-line unreviewable snapshot diff. Now a raw file copy; regression-tested. - Property TYPE changes were not detected at all: a spec property could silently change from string to integer, or lose/gain nullability, with --check exit 0 and --apply reporting no changes. Adds a spec-declared-type vs SDK-declared-PHP-type comparison (string/integer/number/boolean/array, object for class/enum types, nullability from both the `["x","null"]` and `?T` forms), restricted to unambiguous 1:1 schema-to-class mappings (discriminator fan-outs are exempted: a flattened schema's nullability genuinely differs per rail). Nullability-only drift is checked old-vs-new (this codebase has pervasive, pre-existing nullable-vs-non-nullable divergence that predates every snapshot on record and isn't this patcher's job to re-litigate); base-category drift (string vs integer, enum constraint dropped to a bare string, etc.) is checked as current state, regardless of history. 3 genuine pre-existing mismatches found this way are recorded in known-divergences.json rather than silently accepted or blindly auto-fixed. - New optional constructor properties are appended strictly after every existing parameter (never inserted near related fields), since positional callers exist in PHP and a promoted property without a default following a defaulted one is a fatal error. Was already correct; added an explicit regression test asserting parameter order. Also adds `--audit-types`: a non-blocking, always-exit-0 mode doing the full state comparison (not just forward drift) of every mapped property's current spec type against its SDK type, wired into main.yaml next to the coverage report, so pre-existing type debt stays visible without gating CI. Findings triaged in this PR's report; none fixed here (Phase C). Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs --- .api-sync/known-divergences.json | 20 +- .github/workflows/main.yaml | 3 + scripts/api-sync.php | 315 ++++++++++++++++++++++++++++++- tests/ApiSync/ApiSyncTest.php | 165 ++++++++++++++++ 4 files changed, 494 insertions(+), 9 deletions(-) diff --git a/.api-sync/known-divergences.json b/.api-sync/known-divergences.json index 672e306..a0e04b9 100644 --- a/.api-sync/known-divergences.json +++ b/.api-sync/known-divergences.json @@ -1,5 +1,5 @@ { - "$schema": "Recorded, reasoned, owned divergences that state reconciliation must not silently paper over. Two kinds: `enumValues` is a spec enum member whose value does not match any SDK case value even though the SDK models that enum (usually because an SDK case value has a typo/format bug) -- the fix is to CORRECT the existing case, not to add a near-duplicate new one, so scripts/api-sync.php treats a listed (enum, specValue) pair as satisfied rather than pending drift. `fields` is a spec property backed by a constrained enum in the spec but modeled as a plain untyped field in the SDK, so there is no case-completeness gap to begin with. Distinct from .api-sync/unmodeled.json, which is for properties absent from the SDK outright.", + "$schema": "Recorded, reasoned, owned divergences that state reconciliation must not silently paper over. Two kinds: `enumValues` is a spec enum member whose value does not match any SDK case value even though the SDK models that enum (usually because an SDK case value has a typo/format bug) -- the fix is to CORRECT the existing case, not to add a near-duplicate new one, so scripts/api-sync.php treats a listed (enum, specValue) pair as satisfied rather than pending drift. `fields` covers two situations, both keyed by {schema, field}: (a) a spec property backed by a constrained enum but modeled as a plain untyped field in the SDK, so there is no case-completeness gap to begin with; (b) a genuine type-representation mismatch between the spec's declared type and the SDK's declared PHP property type on an already-modeled field, recorded here rather than silently accepted or blindly auto-fixed, since correcting it is a deliberate code change a human must make. Distinct from .api-sync/unmodeled.json, which is for properties absent from the SDK outright.", "enumValues": [ { "enum": "BankAccountType", @@ -32,6 +32,24 @@ "field": "kyc_status", "reason": "Spec constrains kyc_status to an 8-value enum (verifying/approved/rejected/deprecated/pending_review/awaiting_contract/compliance_request/approved_rfi). This SDK models it as a plain `string` on BaseCustomer, not a backed enum, so every spec value already parses without error -- there is no case-completeness gap in code today. Recorded for visibility only.", "owner": "eric@blindpay.com" + }, + { + "schema": "QuoteOut", + "field": "expires_at", + "reason": "TYPE MISMATCH, pre-existing and identical between the last two spec snapshots (not new drift): spec declares expires_at as `number|null` (a float), CreateQuoteResponse declares `public int $expiresAt` with no cast in fromArray. In practice this is a Unix timestamp (spec example: 1712958191, always a whole number), so json_decode already hands back a PHP int and the mismatch has not surfaced -- but a genuinely fractional or null value from the API would throw a TypeError under strict_types. Not fixed here: changing the property type is a deliberate decision, not a mechanical field add.", + "owner": "eric@blindpay.com" + }, + { + "schema": "CreatePayinQuoteOut", + "field": "expires_at", + "reason": "Same pre-existing mismatch as QuoteOut.expires_at, on the payin-quote equivalent (CreatePayinQuoteResponse). Here the field IS cast, `expiresAt: (int) $data['expires_at']`, so a fractional value would silently truncate rather than throw -- a real risk in disguise if the API ever returns one.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PayinOut", + "field": "billing_fee_amount", + "reason": "TYPE MISMATCH, pre-existing and identical between the last two spec snapshots (not new drift): spec declares billing_fee_amount as `number|null` (a fee in cents), Payin declares `public ?string $billingFeeAmount` with no cast in fromArray. A present, non-null value from the API would be a PHP int/float from json_decode, which would throw a TypeError against the `?string` property under strict_types. Not fixed here: choosing the correct type (float? string with an explicit cast?) is a deliberate decision.", + "owner": "eric@blindpay.com" } ] } diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 0865425..03cd86b 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -81,6 +81,9 @@ jobs: } echo count($gaps)." total.\n"; ' + - name: Type audit (non-blocking -- full state comparison, surfaces pre-existing type debt too, not just new drift) + if: always() + run: php scripts/api-sync.php --audit-types --spec=.api-sync/spec-snapshot.json snyk: uses: ./.github/workflows/snyk.yaml needs: [lint, tests] diff --git a/scripts/api-sync.php b/scripts/api-sync.php index eef526f..8e4bcd5 100644 --- a/scripts/api-sync.php +++ b/scripts/api-sync.php @@ -13,8 +13,12 @@ * version-bump classification. * * Usage: - * php scripts/api-sync.php [--check] [--apply] [--spec=path] [--report=path] + * php scripts/api-sync.php [--check] [--apply] [--audit-types] [--spec=path] [--report=path] * Default mode is --check. Default --spec is .api-sync/spec-current.json. + * --audit-types is a separate, always-exit-0 mode: it prints every already-modeled property + * whose CURRENT spec type disagrees with its SDK type (state, not just forward drift), so + * pre-existing type debt stays visible without blocking CI. See auditTypes() for why this is + * broader than the blocking check. * No Composer dependencies -- uses only the Tokenizer/JSON extensions PHP ships with. */ @@ -28,12 +32,14 @@ function parseArgs(array $argv): array { - $opts = ['apply' => false, 'check' => false, 'spec' => null, 'report' => null]; + $opts = ['apply' => false, 'check' => false, 'auditTypes' => false, 'spec' => null, 'report' => null]; foreach (array_slice($argv, 1) as $arg) { if ($arg === '--apply') { $opts['apply'] = true; } elseif ($arg === '--check') { $opts['check'] = true; + } elseif ($arg === '--audit-types') { + $opts['auditTypes'] = true; } elseif (str_starts_with($arg, '--spec=')) { $opts['spec'] = substr($arg, strlen('--spec=')); } elseif (str_starts_with($arg, '--report=')) { @@ -275,16 +281,30 @@ function scanClassesDetailed(string $path): array } } elseif ($ctorParenDepth === 1 && is_array($t) && in_array($id, [T_PUBLIC, T_PROTECTED, T_PRIVATE], true) && $currentClass !== null && isset($classes[$currentClass])) { + $typeParts = []; for ($j = $i + 1; $j < $count; $j++) { $nt = $tokens[$j]; if (! is_array($nt) && in_array($nt, [',', ')'], true)) { break; } if (is_array($nt) && $nt[0] === T_VARIABLE) { - $classes[$currentClass]['ctor']['params'][] = ['name' => ltrim($nt[1], '$'), 'line' => $nt[2]]; + $classes[$currentClass]['ctor']['params'][] = [ + 'name' => ltrim($nt[1], '$'), + 'line' => $nt[2], + 'type' => implode('', $typeParts), + ]; break; } + if (is_array($nt) && $nt[0] === T_READONLY) { + continue; + } + // Type declaration tokens: `?`, a name (possibly namespaced with `\`), `|` for + // unions, or `array` -- which tokenizes as the dedicated T_ARRAY, not T_STRING. + if ((! is_array($nt) && in_array($nt, ['?', '\\', '|'], true)) + || (is_array($nt) && in_array($nt[0], [T_STRING, T_ARRAY], true))) { + $typeParts[] = is_array($nt) ? $nt[1] : $nt; + } } } } @@ -470,6 +490,110 @@ function snakeToCamel(string $snake): string return $first.implode('', array_map(fn ($p) => $p === '' ? '' : ucfirst($p), $parts)); } +// --------------------------------------------------------------------------- +// Type-mismatch detection: spec declared type vs SDK declared property type +// --------------------------------------------------------------------------- + +/** + * Normalize a spec property's JSON Schema type into {category, nullable}. + * category is one of: null (untyped in spec -- cannot compare), 'ambiguous' (more than one + * non-null JSON type -- always needs a human), 'enum' (has an `enum` list; may legitimately be + * modeled as a scalar OR a backed-enum/object in the SDK), or a plain JSON Schema type name + * (string/integer/number/boolean/array/object). + */ +function specTypeCategory(array $propSchema): array +{ + $type = $propSchema['type'] ?? null; + if ($type === null) { + return ['category' => null, 'nullable' => null]; + } + $types = is_array($type) ? $type : [$type]; + $nullable = in_array('null', $types, true); + $nonNull = array_values(array_diff($types, ['null'])); + if (count($nonNull) !== 1) { + return ['category' => 'ambiguous', 'nullable' => $nullable]; + } + $category = isset($propSchema['enum']) ? 'enum' : $nonNull[0]; + + return ['category' => $category, 'nullable' => $nullable]; +} + +/** + * Normalize a PHP promoted-property type declaration (e.g. "?string", "Network", "?array") + * into {category, nullable}. A bare class/enum name normalizes to 'object'. + */ +function phpTypeCategory(?string $phpType): array +{ + if ($phpType === null || $phpType === '') { + return ['category' => null, 'nullable' => null, 'className' => null]; + } + $nullable = str_starts_with($phpType, '?'); + $base = ltrim($phpType, '?'); + if (str_contains($base, '|')) { + return ['category' => 'ambiguous', 'nullable' => $nullable, 'className' => null]; + } + $scalarMap = ['string' => 'string', 'int' => 'integer', 'float' => 'number', 'bool' => 'boolean', 'array' => 'array']; + $category = $scalarMap[$base] ?? 'object'; + + return ['category' => $category, 'nullable' => $nullable, 'className' => $category === 'object' ? $base : null]; +} + +/** + * Whether a spec property's base representation and an SDK property's base representation are + * compatible enough that a real spec value is guaranteed to decode correctly. Nullability is + * deliberately NOT considered here -- see typeNullabilityWidened() below for why. Deliberately + * conservative on the base category: anything not positively known to be safe is a mismatch. + */ +function categoriesCompatible(array $spec, array $php): bool +{ + if ($spec['category'] === null || $php['category'] === null) { + return true; // one side can't be determined -- do not force a false positive + } + if ($spec['category'] === 'ambiguous' || $php['category'] === 'ambiguous') { + return false; + } + // This SDK's established decode pattern: a date-time string is parsed into DateTimeImmutable. + if ($spec['category'] === 'string' && ($php['className'] ?? null) === 'DateTimeImmutable') { + return true; + } + // PHP widens int to float safely, even under strict_types (a documented special case) -- the + // reverse (spec `number`, SDK `int`) is NOT safe, a fractional value would lose precision. + if ($spec['category'] === 'integer' && $php['category'] === 'number') { + return true; + } + if ($spec['category'] === 'enum') { + // May be modeled as a plain scalar (deliberately loose, e.g. known-divergences.json) or as + // a backed enum/class -- both parse every spec value without error. + return in_array($php['category'], ['string', 'object'], true); + } + // json_decode(..., true) throughout this codebase represents a JSON object as a PHP + // associative array, so a spec `object` modeled as PHP `array` is this SDK's norm, not a bug. + if ($spec['category'] === 'object' && $php['category'] === 'array') { + return true; + } + + return $spec['category'] === $php['category']; +} + +/** + * Whether nullability was newly ADDED to this property's spec type between $oldPropSchema and + * $newPropSchema, while the SDK's declared type is not itself nullable. Checked as an old-vs-new + * event, not a pure state check: this codebase's spec has long-standing, pervasive `|null` + * annotations on fields the SDK still declares non-nullable (predating every snapshot on record), + * and re-litigating that entire backlog on every run is neither this patcher's job nor safe to + * silently mass-fix. A genuinely NEW nullability relaxation is a real, actionable signal though. + */ +function typeNullabilityWidened(array $oldPropSchema, array $newPropSchema, array $phpType): bool +{ + if ($phpType['nullable'] ?? true) { + return false; // SDK already tolerates null -- nothing at risk + } + $old = specTypeCategory($oldPropSchema); + $new = specTypeCategory($newPropSchema); + + return ! $old['nullable'] && $new['nullable']; +} + // --------------------------------------------------------------------------- // Map validation // --------------------------------------------------------------------------- @@ -575,7 +699,7 @@ function typeSpecSchemas(array $entry): array return is_array($spec) ? $spec : [$spec]; } -function reconcileTypes(array $map, array $newSpec, array $reachable, array $classIndex, array $unmodeledIndex): array +function reconcileTypes(array $map, array $newSpec, array $reachable, array $classIndex, array $unmodeledIndex, array $divergenceFieldIndex = []): array { $applicable = []; $needsHuman = []; @@ -587,6 +711,19 @@ function reconcileTypes(array $map, array $newSpec, array $reachable, array $cla // name), not once per schema in a multi-schema entry like ["PayoutOut","PayoutOnEvmOut"]. $canonicalSchema = $schemas[0]; + // camelCase param name -> PHP type string. Only meaningful for a 1:1 schema-to-class + // mapping: a discriminator fan-out (e.g. CreateBankAccountIn's 10 rails) has each class + // model only its own rail's requiredness for a shared wire key, which genuinely diverges + // from the flattened spec schema's nullability -- that's covered by field-presence + // checking only, not per-field type strictness. + $ctorTypesByParam = []; + if (count($entry['sdk']) === 1) { + $info = $classIndex[$entry['sdk'][0]['class']] ?? null; + foreach ($info['ctor']['params'] ?? [] as $param) { + $ctorTypesByParam[$param['name']] ??= $param['type']; + } + } + foreach ($schemas as $schemaName) { if (! isset($reachable[$schemaName])) { continue; // no longer reachable -- skip by construction @@ -617,6 +754,28 @@ function reconcileTypes(array $map, array $newSpec, array $reachable, array $cla foreach ($specProps as $field) { if (isset($modeled[$field])) { + // Already modeled -- check the declared type still matches, not just presence. + $divergenceKey = "{$canonicalSchema}|{$field}"; + if (isset($divergenceFieldIndex[$divergenceKey])) { + continue; + } + $camel = snakeToCamel($field); + $phpType = $ctorTypesByParam[$camel] ?? null; + if ($phpType === null) { + continue; // modeled via fromArray/toArray only (e.g. no promoted ctor prop) -- nothing to compare + } + $propSchema = $path !== null + ? nestedPropSchema($newSpec, $schemaName, $path, $field) + : ($newSpec['components']['schemas'][$schemaName]['properties'][$field] ?? []); + $specType = specTypeCategory($propSchema); + $sdkType = phpTypeCategory($phpType); + if (! categoriesCompatible($specType, $sdkType)) { + $label = $schemaName.($path !== null ? ".{$path}" : ''); + $needsHuman[] = "NEEDS_HUMAN: type mismatch on {$label}.{$field}: spec declares " + .($specType['category'] ?? 'unknown').($specType['nullable'] ? '|null' : '') + .", SDK declares {$phpType} (mapped class(es): ".implode(', ', array_column($entry['sdk'], 'class')).')'; + } + continue; } $unmodeledKey = $canonicalSchema.'|'.($path ?? '').'|'.$field; @@ -676,7 +835,7 @@ function pathMethodSet(array $spec): array return $out; } -function computeStructuralDiff(array $oldSpec, array $newSpec, array $reachableOld, array $reachableNew, array $map): array +function computeStructuralDiff(array $oldSpec, array $newSpec, array $reachableOld, array $reachableNew, array $map, array $classIndex = []): array { $issues = []; @@ -744,7 +903,19 @@ function computeStructuralDiff(array $oldSpec, array $newSpec, array $reachableO foreach ($map['types'] as $entry) { $path = $entry['path'] ?? null; - foreach (typeSpecSchemas($entry) as $schemaName) { + $schemas = typeSpecSchemas($entry); + // See reconcileTypes(): a discriminator fan-out's flattened schema nullability doesn't + // apply uniformly to every rail's class, so per-field type/nullability checks only make + // sense for an unambiguous 1:1 mapping. + $ctorTypesByParam = []; + if (count($entry['sdk']) === 1) { + $info = $classIndex[$entry['sdk'][0]['class']] ?? null; + foreach ($info['ctor']['params'] ?? [] as $param) { + $ctorTypesByParam[$param['name']] ??= $param['type']; + } + } + + foreach ($schemas as $schemaName) { if (! isset($reachableOld[$schemaName]) || ! isset($reachableNew[$schemaName])) { continue; } @@ -758,6 +929,19 @@ function computeStructuralDiff(array $oldSpec, array $newSpec, array $reachableO $label = $schemaName.($path !== null ? ".{$path}" : ''); $issues[] = 'NEEDS_HUMAN: propert'.(count($removed) === 1 ? 'y' : 'ies')." removed from {$label}: ".implode(', ', $removed).' (breaking, requires a deliberate major)'; } + + foreach (array_intersect($oldProps, $newProps) as $field) { + $phpType = $ctorTypesByParam[snakeToCamel($field)] ?? null; + if ($phpType === null) { + continue; + } + $oldPropSchema = $path !== null ? nestedPropSchema($oldSpec, $schemaName, $path, $field) : ($oldSpec['components']['schemas'][$schemaName]['properties'][$field] ?? []); + $newPropSchema = $path !== null ? nestedPropSchema($newSpec, $schemaName, $path, $field) : ($newSpec['components']['schemas'][$schemaName]['properties'][$field] ?? []); + if (typeNullabilityWidened($oldPropSchema, $newPropSchema, phpTypeCategory($phpType))) { + $label = $schemaName.($path !== null ? ".{$path}" : ''); + $issues[] = "NEEDS_HUMAN: {$label}.{$field} newly allows null in the spec, but the SDK declares a non-nullable {$phpType} (mapped class: {$entry['sdk'][0]['class']})"; + } + } } } @@ -794,6 +978,91 @@ function computeCoverageReport(array $map, array $spec, array $reachable): array return $gaps; } +// --------------------------------------------------------------------------- +// Type audit (non-blocking): full state comparison, not just forward drift +// --------------------------------------------------------------------------- + +/** + * Compares every already-modeled property's CURRENT spec type against its declared PHP type, + * for every mapped schema/path -- regardless of whether that mismatch is old or new. This is + * deliberately broader than the blocking --check type-mismatch gate (reconcileTypes) and than + * typeNullabilityWidened() (computeStructuralDiff), which only fire on genuinely NEW drift: this + * is the same state-vs-event distinction the whole design is built on, applied to types instead + * of presence. Never blocks: purely a printed report for a human to triage (Phase C). + * + * Discriminator fan-outs (more than one SDK class per entry) are reported as skipped, not + * silently omitted: a flattened spec schema's nullability/requiredness genuinely differs per + * rail, so a union-of-classes comparison would be noise, not signal. + */ +function auditTypes(array $map, array $spec, array $reachable, array $classIndex, array $divergenceFieldIndex): array +{ + $findings = []; + $skippedFanOuts = []; + + foreach ($map['types'] as $entry) { + $schemas = typeSpecSchemas($entry); + $path = $entry['path'] ?? null; + $canonicalSchema = $schemas[0]; + + if (count($entry['sdk']) > 1) { + $skippedFanOuts[] = $canonicalSchema.($path !== null ? ".{$path}" : ''); + + continue; + } + + $info = $classIndex[$entry['sdk'][0]['class']] ?? null; + $ctorTypesByParam = []; + foreach ($info['ctor']['params'] ?? [] as $param) { + $ctorTypesByParam[$param['name']] ??= $param['type']; + } + + foreach ($schemas as $schemaName) { + if (! isset($reachable[$schemaName])) { + continue; + } + $specProps = $path !== null ? nestedProps($spec, $schemaName, $path) : schemaProps($spec, $schemaName); + if ($specProps === null) { + continue; + } + + foreach ($specProps as $field) { + $camel = snakeToCamel($field); + $phpType = $ctorTypesByParam[$camel] ?? null; + if ($phpType === null) { + continue; // not modeled via a promoted constructor property -- nothing to compare + } + $propSchema = $path !== null + ? nestedPropSchema($spec, $schemaName, $path, $field) + : ($spec['components']['schemas'][$schemaName]['properties'][$field] ?? []); + $specType = specTypeCategory($propSchema); + $sdkType = phpTypeCategory($phpType); + + $categoryMismatch = ! categoriesCompatible($specType, $sdkType); + $nullabilityMismatch = $specType['nullable'] === true && $sdkType['nullable'] !== true; + if (! $categoryMismatch && ! $nullabilityMismatch) { + continue; + } + + $label = $schemaName.($path !== null ? ".{$path}" : ''); + $findings[] = [ + 'field' => "{$label}.{$field}", + 'class' => $entry['sdk'][0]['class'], + 'specType' => ($specType['category'] ?? 'unknown').($specType['nullable'] ? '|null' : ''), + 'sdkType' => $phpType, + 'categoryMismatch' => $categoryMismatch, + 'nullabilityMismatch' => $nullabilityMismatch, + 'recordedDivergence' => isset($divergenceFieldIndex["{$canonicalSchema}|{$field}"]), + ]; + } + } + } + + usort($findings, fn ($a, $b) => $a['field'] <=> $b['field']); + sort($skippedFanOuts); + + return ['findings' => $findings, 'skippedFanOuts' => $skippedFanOuts]; +} + // --------------------------------------------------------------------------- // Applying changes (surgical text splicing) // --------------------------------------------------------------------------- @@ -1037,6 +1306,10 @@ function runCli(array $argv, string $root): void $divergenceEnumIndex["{$d['enum']}|{$d['specValue']}"] = true; } } + $divergenceFieldIndex = []; + foreach ($divergences['fields'] as $d) { + $divergenceFieldIndex["{$d['schema']}|{$d['field']}"] = true; + } $classIndex = scanAllClasses($root); @@ -1046,13 +1319,39 @@ function runCli(array $argv, string $root): void $reachableOld = computeReachable($oldSpec); $reachableNew = computeReachable($newSpec); - $structuralIssues = empty($mapErrors) ? computeStructuralDiff($oldSpec, $newSpec, $reachableOld, $reachableNew, $map) : []; + if ($opts['auditTypes']) { + $audit = auditTypes($map, $newSpec, $reachableNew, $classIndex, $divergenceFieldIndex); + fwrite(STDOUT, "[api-sync] --audit-types: full state comparison of every mapped property's spec type vs SDK type.\nNon-blocking and informational -- pre-existing mismatches are Phase C triage, not a gate.\n\n"); + if (empty($audit['findings'])) { + fwrite(STDOUT, "No type mismatches found.\n"); + } + foreach ($audit['findings'] as $f) { + $tags = []; + if ($f['categoryMismatch']) { + $tags[] = 'category'; + } + if ($f['nullabilityMismatch']) { + $tags[] = 'nullability'; + } + $recorded = $f['recordedDivergence'] ? ' [recorded in known-divergences.json]' : ' [NOT YET RECORDED]'; + fwrite(STDOUT, " - {$f['field']} ({$f['class']}): spec={$f['specType']} sdk={$f['sdkType']} mismatch=".implode('+', $tags).$recorded."\n"); + } + if (! empty($audit['skippedFanOuts'])) { + fwrite(STDOUT, "\nSkipped (discriminator fan-out -- per-rail requiredness genuinely differs from the flattened schema): ".implode(', ', $audit['skippedFanOuts'])."\n"); + } + if ($reportPath !== null) { + file_put_contents($reportPath, encodeJsonDeterministic(['mode' => 'audit-types', 'spec' => $specPath, 'audit' => $audit])); + } + exit(0); + } + + $structuralIssues = empty($mapErrors) ? computeStructuralDiff($oldSpec, $newSpec, $reachableOld, $reachableNew, $map, $classIndex) : []; [$enumApplicable, $enumNeedsHuman] = empty($mapErrors) ? reconcileEnums($map, $newSpec, $reachableNew, $classIndex, $divergenceEnumIndex) : [[], []]; [$fieldApplicable, $fieldNeedsHuman] = empty($mapErrors) - ? reconcileTypes($map, $newSpec, $reachableNew, $classIndex, $unmodeledIndex) + ? reconcileTypes($map, $newSpec, $reachableNew, $classIndex, $unmodeledIndex, $divergenceFieldIndex) : [[], []]; $coverage = computeCoverageReport($map, $newSpec, $reachableNew); diff --git a/tests/ApiSync/ApiSyncTest.php b/tests/ApiSync/ApiSyncTest.php index 5b67c5d..abe7b2a 100644 --- a/tests/ApiSync/ApiSyncTest.php +++ b/tests/ApiSync/ApiSyncTest.php @@ -485,6 +485,88 @@ public function a_new_unreferenced_schema_is_not_flagged_at_all(): void $this->assertEmpty(array_filter($issues, fn ($i) => str_contains($i, 'OrphanOut'))); } + // ---- NEEDS_HUMAN: type mismatches on already-modeled properties ---- + + #[Test] + public function a_string_property_changing_to_integer_in_the_spec_is_needs_human(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $newSpec = $this->baseSpec(); + $newSpec['components']['schemas']['WidgetOut']['properties']['name'] = ['type' => 'integer']; + $reachable = computeReachable($newSpec); + + [$applicable, $needsHuman] = reconcileTypes($this->widgetMap(), $newSpec, $reachable, $classIndex, []); + + $this->assertEmpty($applicable); + $this->assertNotEmpty(array_filter($needsHuman, fn ($i) => str_contains($i, 'WidgetOut.name') && str_contains($i, 'integer') && str_contains($i, 'string'))); + } + + #[Test] + public function a_property_newly_allowing_null_while_the_sdk_stays_non_nullable_is_needs_human(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $oldSpec = $this->baseSpec(); // WidgetOut.name: {"type": "string"} -- non-nullable + $newSpec = $this->baseSpec(); + $newSpec['components']['schemas']['WidgetOut']['properties']['name'] = ['type' => ['string', 'null']]; + $reachableOld = computeReachable($oldSpec); + $reachableNew = computeReachable($newSpec); + + // WidgetResponse declares `public string $name` (no `?`) -- a real null would break it. + $issues = computeStructuralDiff($oldSpec, $newSpec, $reachableOld, $reachableNew, $this->widgetMap(), $classIndex); + + $this->assertNotEmpty(array_filter($issues, fn ($i) => str_contains($i, 'WidgetOut.name') && str_contains($i, 'newly allows null') && str_contains($i, 'string'))); + } + + #[Test] + public function an_enum_backed_sdk_property_whose_spec_constraint_degrades_to_a_bare_string_is_needs_human(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $newSpec = $this->baseSpec(); + // WidgetResponse declares `public WidgetColor $color` -- an enum class. If the spec drops + // the enum constraint down to a bare, unconstrained string, WidgetColor::from() can now + // throw on a value outside its known set. + $newSpec['components']['schemas']['WidgetOut']['properties']['color'] = ['type' => 'string']; + $reachable = computeReachable($newSpec); + + [$applicable, $needsHuman] = reconcileTypes($this->widgetMap(), $newSpec, $reachable, $classIndex, []); + + $this->assertEmpty($applicable); + $this->assertNotEmpty(array_filter($needsHuman, fn ($i) => str_contains($i, 'WidgetOut.color'))); + } + + #[Test] + public function an_integer_spec_property_modeled_as_a_php_float_is_deliberately_treated_as_compatible(): void + { + // PHP widens int to float safely, even under strict_types -- a spec `integer` read into a + // `float`-typed property parses every value without error, so this is not a mismatch. + $spec = specTypeCategory(['type' => 'integer']); + $php = phpTypeCategory('float'); + + $this->assertTrue(categoriesCompatible($spec, $php)); + } + + // ---- constructor insertion never reorders existing parameters ---- + + #[Test] + public function the_new_promoted_property_always_lands_last_and_no_existing_parameter_moves(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $before = array_column($classIndex['WidgetResponse']['ctor']['params'], 'name'); + + applyFieldInsertion( + $this->fixtureRoot, + 'src/Resources/Widgets/Widgets.php', + 'WidgetResponse', + 'nickname', + ['type' => ['string', 'null']] + ); + + $after = array_column(scanAllClasses($this->fixtureRoot)['WidgetResponse']['ctor']['params'], 'name'); + + $this->assertSame([...$before, 'nickname'], $after, 'existing parameters must keep their exact order, with the new one appended last'); + $this->assertPhpFileParses("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"); + } + // ---- map validity ---- #[Test] @@ -553,6 +635,89 @@ public function version_string_bumps_correctly(): void $this->assertSame('3.1.0', bumpVersionString('3.0.9', 'minor')); } + // ---- --audit-types: non-blocking, full state comparison (not just forward drift) ---- + + #[Test] + public function audit_types_reports_a_pre_existing_nullability_mismatch_that_check_mode_would_not_flag(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + // WidgetResponse declares `public string $name` (non-nullable); the spec allows null. + // This did NOT change between "old" and "new" here -- audit-types must still surface it, + // unlike the blocking checks, which only fire on genuinely new drift. + $spec = $this->baseSpec(); + $spec['components']['schemas']['WidgetOut']['properties']['name'] = ['type' => ['string', 'null']]; + $reachable = computeReachable($spec); + + $audit = auditTypes($this->widgetMap(), $spec, $reachable, $classIndex, []); + + $finding = current(array_filter($audit['findings'], fn ($f) => $f['field'] === 'WidgetOut.name')); + $this->assertNotFalse($finding); + $this->assertTrue($finding['nullabilityMismatch']); + $this->assertFalse($finding['categoryMismatch']); + $this->assertFalse($finding['recordedDivergence']); + } + + #[Test] + public function audit_types_marks_a_recorded_known_divergence_instead_of_hiding_it(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $spec = $this->baseSpec(); + $spec['components']['schemas']['WidgetOut']['properties']['name'] = ['type' => ['string', 'null']]; + $reachable = computeReachable($spec); + + $audit = auditTypes($this->widgetMap(), $spec, $reachable, $classIndex, ['WidgetOut|name' => true]); + + $finding = current(array_filter($audit['findings'], fn ($f) => $f['field'] === 'WidgetOut.name')); + $this->assertNotFalse($finding); + $this->assertTrue($finding['recordedDivergence'], 'a recorded divergence must still be reported, just marked, never hidden'); + } + + #[Test] + public function audit_types_skips_discriminator_fan_outs_instead_of_guessing(): void + { + $classIndex = scanAllClasses($this->fixtureRoot); + $map = $this->widgetMap(); + // Simulate a fan-out: the same WidgetOut schema modeled by two classes. + $map['types'][0]['sdk'][] = ['file' => 'src/Resources/Widgets/Widgets.php', 'class' => 'WidgetInputLiteral']; + $spec = $this->baseSpec(); + $reachable = computeReachable($spec); + + $audit = auditTypes($map, $spec, $reachable, $classIndex, []); + + $this->assertContains('WidgetOut', $audit['skippedFanOuts']); + $this->assertEmpty($audit['findings']); + } + + #[Test] + public function audit_types_cli_mode_always_exits_zero_even_with_findings(): void + { + // Reuse the byte-copy fixture wiring (real script copy + full .api-sync/*.json set), but + // seed a spec with a deliberate nullability mismatch so --audit-types has something to say. + mkdir("{$this->fixtureRoot}/.api-sync", 0777, true); + mkdir("{$this->fixtureRoot}/scripts", 0777, true); + copy(__DIR__.'/../../scripts/api-sync.php', "{$this->fixtureRoot}/scripts/api-sync.php"); + file_put_contents("{$this->fixtureRoot}/src/BlindPay.php", "fixtureRoot}/.api-sync/spec-map.json", json_encode($this->widgetMap())); + file_put_contents("{$this->fixtureRoot}/.api-sync/unmodeled.json", json_encode(['entries' => []])); + file_put_contents("{$this->fixtureRoot}/.api-sync/known-divergences.json", json_encode(['enumValues' => [], 'fields' => []])); + file_put_contents("{$this->fixtureRoot}/.api-sync/spec-snapshot.json", json_encode($this->baseSpec())); + + $spec = $this->baseSpec(); + $spec['components']['schemas']['WidgetOut']['properties']['name'] = ['type' => ['string', 'null']]; + $specPath = "{$this->fixtureRoot}/.api-sync/spec-current.json"; + file_put_contents($specPath, json_encode($spec)); + + exec(sprintf( + 'php %s --audit-types --spec=%s 2>&1', + escapeshellarg("{$this->fixtureRoot}/scripts/api-sync.php"), + escapeshellarg($specPath) + ), $output, $exitCode); + + $this->assertSame(0, $exitCode); + $this->assertNotEmpty(array_filter($output, fn ($l) => str_contains($l, 'WidgetOut.name'))); + } + // ---- snapshot refresh must copy bytes verbatim, never re-serialize ---- #[Test] From 18c02f72efd93c9086bd0194e4125739bdeea939 Mon Sep 17 00:00:00 2001 From: Eric Viana Date: Mon, 3 Aug 2026 21:27:51 -0300 Subject: [PATCH 3/5] fix(api-sync): harden path handling against Snyk Code path-traversal findings Snyk Code flagged 21 CWE-23 Path Traversal findings on scripts/api-sync.php, 10 shown inline on the PR. All 10 visible ones traced through a variable named $path used for two unrelated things in this file: an actual filesystem path in some functions, and a dotted JSON schema property path (e.g. "tracking_payment") in reconcileTypes()/computeStructuralDiff()/auditTypes() and their nestedProps()/nestedPropSchema() helpers. Renamed every non-filesystem occurrence to $schemaPath (and the URL-path lookup key in operationEnumValues() to $urlPath), so a same-name variable can no longer look like it flows into a file operation when it never does. For the genuine filesystem-path flows, added real validation rather than suppression: - resolveReadablePath()/resolveWritablePath(): --spec and --report are resolved via realpath() and rejected on a NUL byte or a missing file/ directory, right where they enter the program via parseArgs(), instead of trusted as raw strings at each later read/write call site. --report is deliberately NOT restricted to the repository root: CI writes it to /tmp and the determinism proof writes into scratch copies elsewhere on disk, both legitimate, required uses. - resolveWithinRoot(): every path built from a spec-map.json `file` entry (enum/class file edits, the VERSION file, the snapshot copy destination) is resolved and verified to stay inside the repository root, refusing to write outside it. spec-map.json is a committed, human-reviewed config file, not runtime input, but a corrupted or malicious entry should still never be able to direct a write outside the repo. Added tests for every new validation path (NUL byte rejection, missing file/directory, root containment, and that --report outside the root is still accepted since that's required functionality) and re-verified the determinism proof and full gate suite stay green. Also, from an independent review: added PaginationMetadata to spec-map.json (previously an unmapped coverage gap) and recorded two more LIVE, VERIFIED runtime defects in known-divergences.json -- next_page/prev_page are nullable string cursors on the wire but typed `int` here, so PaginationMetadata::fromArray() throws TypeError on any populated cursor (reproduced by execution, not theoretical). Strengthened the existing PayinOut.billing_fee_amount entry's wording the same way: reproduced the TypeError directly rather than describing it as a possibility. None fixed in this PR; a follow-up will address the underlying type changes. Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs --- .api-sync/known-divergences.json | 14 ++- .api-sync/spec-map.json | 3 +- scripts/api-sync.php | 157 +++++++++++++++++++++++-------- tests/ApiSync/ApiSyncTest.php | 140 +++++++++++++++++++++++++++ 4 files changed, 275 insertions(+), 39 deletions(-) diff --git a/.api-sync/known-divergences.json b/.api-sync/known-divergences.json index a0e04b9..5a22f66 100644 --- a/.api-sync/known-divergences.json +++ b/.api-sync/known-divergences.json @@ -48,7 +48,19 @@ { "schema": "PayinOut", "field": "billing_fee_amount", - "reason": "TYPE MISMATCH, pre-existing and identical between the last two spec snapshots (not new drift): spec declares billing_fee_amount as `number|null` (a fee in cents), Payin declares `public ?string $billingFeeAmount` with no cast in fromArray. A present, non-null value from the API would be a PHP int/float from json_decode, which would throw a TypeError against the `?string` property under strict_types. Not fixed here: choosing the correct type (float? string with an explicit cast?) is a deliberate decision.", + "reason": "LIVE DEFECT, VERIFIED BY RUNNING CODE (not fixed in this PR): spec declares billing_fee_amount as `number|null` (a fee in cents), Payin declares `public ?string $billingFeeAmount` with no cast in fromArray. Calling Payin::fromArray() with any present, non-null billing_fee_amount throws `TypeError: BlindPay\\SDK\\Resources\\Payins\\Payin::__construct(): Argument #35 ($billingFeeAmount) must be of type ?string, int given` -- reproduced directly, not theoretical. Every payin response where this fee is populated (end of month) fails to parse. Not fixed here: choosing the correct type (float? string with an explicit cast?) is a deliberate decision, pre-existing and identical between the last two spec snapshots (not new drift).", + "owner": "eric@blindpay.com" + }, + { + "schema": "PaginationMetadata", + "field": "next_page", + "reason": "LIVE DEFECT, VERIFIED BY RUNNING CODE (not fixed in this PR): spec declares next_page as a nullable STRING cursor (`type: [\"string\",\"null\"]`, example `\"pi_123\"`, the ID of the first item in the next page), but PaginationMetadata declares `public int $nextPage` with no cast. Calling PaginationMetadata::fromArray() with any present, non-null next_page throws `TypeError: BlindPay\\SDK\\Types\\PaginationMetadata::__construct(): Argument #2 ($nextPage) must be of type int, string given` -- reproduced directly, not theoretical. PaginationMetadata backs the customers/payouts/payins/transfers list responses, so ANY paginated list response that actually has a next page fails to parse through this SDK. Not fixed here: the property type needs to become `?string`, a deliberate code change with its own PR.", + "owner": "eric@blindpay.com" + }, + { + "schema": "PaginationMetadata", + "field": "prev_page", + "reason": "Same live defect as PaginationMetadata.next_page, on the previous-page cursor (`public int $prevPage`, spec declares a nullable string). Not independently re-verified by execution but identical shape and identical code path.", "owner": "eric@blindpay.com" } ] diff --git a/.api-sync/spec-map.json b/.api-sync/spec-map.json index 8f783ba..701e1a7 100644 --- a/.api-sync/spec-map.json +++ b/.api-sync/spec-map.json @@ -213,7 +213,8 @@ { "spec": "AvailableBankDetails", "sdk": [ { "file": "src/Resources/Available/Available.php", "class": "BankDetail" } ] }, { "spec": "AvailableNaicsList", "sdk": [ { "file": "src/Resources/Available/Available.php", "class": "NaicsCode" } ] }, { "spec": "UploadIn", "sdk": [ { "file": "src/Resources/Upload/Upload.php", "class": "UploadInput" } ] }, - { "spec": "UploadOut", "sdk": [ { "file": "src/Resources/Upload/Upload.php", "class": "UploadResponse" } ] } + { "spec": "UploadOut", "sdk": [ { "file": "src/Resources/Upload/Upload.php", "class": "UploadResponse" } ] }, + { "spec": "PaginationMetadata", "sdk": [ { "file": "src/Types/PaginationMetadata.php", "class": "PaginationMetadata" } ], "note": "referenced from the customers/payouts/payins/transfers list-response envelopes. next_page/prev_page type mismatch is a LIVE DEFECT, see known-divergences.json -- not fixed here." } ], "webhookPayloadsNote": "This SDK never deserializes webhook request bodies into typed classes -- BlindPay::verifyWebhookSignature() only verifies the signature and returns bool; callers get the raw payload. Consequently no `*WebhookOut` schema (BankAccountWebhookOut, BlockchainWalletWebhookOut, CustomerNewWebhookOut, CustomerUpdateWebhookOut, CustomerDeleteWebhookOut, PayinNewWebhookOut, PayinUpdateWebhookOut, PayinCompleteWebhookOut, PayinPartnerFeeWebhookOut, PayoutNewWebhookOut, PayoutUpdateWebhookOut, PayoutCompleteWebhookOut, PayoutPartnerFeeWebhookOut, LimitIncreaseNewWebhookOut, LimitIncreaseUpdateWebhookOut, TosAcceptWebhookOut) is mapped below; this is a structural fact about the SDK, not an oversight, and shows up as non-blocking coverage gaps rather than reconciliation failures.", "ignore": { diff --git a/scripts/api-sync.php b/scripts/api-sync.php index 8e4bcd5..bca729b 100644 --- a/scripts/api-sync.php +++ b/scripts/api-sync.php @@ -53,6 +53,76 @@ function parseArgs(array $argv): array return $opts; } +// --------------------------------------------------------------------------- +// Path validation -- every filesystem path this program touches that originates from a CLI +// argument or a config file on disk is resolved and validated here, once, right where it enters +// the program, rather than trusted implicitly at each later read/write call site. +// --------------------------------------------------------------------------- + +/** + * Canonicalize and validate a path this program is about to READ (--spec, and any file the + * tokenizer scans). Rejects NUL-byte injection and resolves `..`/symlinks via realpath() so every + * later use operates on one already-validated canonical path instead of re-trusting a raw string. + */ +function resolveReadablePath(string $path, string $argName): string +{ + if ($path === '' || str_contains($path, "\0")) { + fwrite(STDERR, "[api-sync] FAIL: invalid {$argName} path\n"); + exit(1); + } + $real = realpath($path); + if ($real === false || ! is_file($real)) { + fwrite(STDERR, "[api-sync] FAIL: {$argName} does not exist or is not a file: {$path}\n"); + exit(1); + } + + return $real; +} + +/** + * Canonicalize and validate a path this program is about to WRITE (--report). The parent + * directory must already exist -- this program never creates directories -- and resolving it via + * realpath() collapses `..`/symlinks so the eventual file_put_contents() target is unambiguous. + * Deliberately does NOT restrict the result to the repository root: --report is designed to write + * outside it (CI writes to /tmp; the determinism proof writes into scratch copies elsewhere on + * disk) -- that is required functionality, not a path-traversal vulnerability, since the value + * comes from a trusted CI workflow or an operator's own CLI invocation, never from request input. + */ +function resolveWritablePath(string $path, string $argName): string +{ + if ($path === '' || str_contains($path, "\0")) { + fwrite(STDERR, "[api-sync] FAIL: invalid {$argName} path\n"); + exit(1); + } + $dir = realpath(dirname($path)); + if ($dir === false || ! is_dir($dir)) { + fwrite(STDERR, "[api-sync] FAIL: directory for {$argName} does not exist: {$path}\n"); + exit(1); + } + + return $dir.DIRECTORY_SEPARATOR.basename($path); +} + +/** + * Resolve a file path declared in spec-map.json (or the hardcoded VERSION file) against $root, + * and refuse to touch anything outside it. spec-map.json is a committed, human-reviewed config + * file, not runtime request input, but every path built from it is still contained here: a + * corrupted or malicious entry (e.g. a `..` sequence) must never let --apply write outside the + * repository it was invoked on. + */ +function resolveWithinRoot(string $root, string $relativeFile, string $context): string +{ + $realRoot = realpath($root); + $candidate = rtrim($root, '/').'/'.$relativeFile; + $realDir = realpath(dirname($candidate)); + if ($realRoot === false || $realDir === false || ! str_starts_with($realDir.'/', $realRoot.'/')) { + fwrite(STDERR, "[api-sync] FAIL: {$context} resolves outside the repository root: {$relativeFile}\n"); + exit(1); + } + + return $realDir.'/'.basename($candidate); +} + // --------------------------------------------------------------------------- // JSON / spec helpers // --------------------------------------------------------------------------- @@ -139,21 +209,28 @@ function specEnumValues(array $spec, string $schema, string $propertyPath): ?arr /** Enum values for an inline (non-$ref) request-body property, addressed by "METHOD /path". */ function operationEnumValues(array $spec, string $operation, string $property): ?array { - [$method, $path] = explode(' ', $operation, 2); + // $urlPath is an OpenAPI paths-map KEY (e.g. "/v1/instances/{instance_id}/quotes/fx"), never a + // filesystem path -- named distinctly from every filesystem $path in this file. + [$method, $urlPath] = explode(' ', $operation, 2); $method = strtolower($method); - $node = $spec['paths'][$path][$method]['requestBody']['content']['application/json']['schema']['properties'][$property] ?? null; + $node = $spec['paths'][$urlPath][$method]['requestBody']['content']['application/json']['schema']['properties'][$property] ?? null; return $node['enum'] ?? null; } -/** Nested inline object property names, e.g. schemaProps but for a dotted nested path. */ -function nestedProps(array $spec, string $schema, string $path): ?array +/** + * Nested inline object property names, e.g. schemaProps but for a dotted nested path. + * $schemaPath is a dotted property path WITHIN a JSON schema (e.g. "tracking_payment"), never a + * filesystem path -- named distinctly from every filesystem $path in this file so a static + * analyzer's data-flow tracing has no name-based reason to conflate the two. + */ +function nestedProps(array $spec, string $schema, string $schemaPath): ?array { $node = $spec['components']['schemas'][$schema] ?? null; if ($node === null) { return null; } - foreach (explode('.', $path) as $part) { + foreach (explode('.', $schemaPath) as $part) { $node = $node['properties'][$part] ?? null; if ($node === null) { return null; @@ -706,7 +783,8 @@ function reconcileTypes(array $map, array $newSpec, array $reachable, array $cla foreach ($map['types'] as $entry) { $schemas = typeSpecSchemas($entry); - $path = $entry['path'] ?? null; + // A dotted schema property path (e.g. "tracking_payment"), never a filesystem path. + $schemaPath = $entry['path'] ?? null; // unmodeled.json records one entry per group (using the first schema as the canonical // name), not once per schema in a multi-schema entry like ["PayoutOut","PayoutOnEvmOut"]. $canonicalSchema = $schemas[0]; @@ -729,8 +807,8 @@ function reconcileTypes(array $map, array $newSpec, array $reachable, array $cla continue; // no longer reachable -- skip by construction } - $specProps = $path !== null - ? nestedProps($newSpec, $schemaName, $path) + $specProps = $schemaPath !== null + ? nestedProps($newSpec, $schemaName, $schemaPath) : schemaProps($newSpec, $schemaName); if ($specProps === null) { @@ -764,13 +842,13 @@ function reconcileTypes(array $map, array $newSpec, array $reachable, array $cla if ($phpType === null) { continue; // modeled via fromArray/toArray only (e.g. no promoted ctor prop) -- nothing to compare } - $propSchema = $path !== null - ? nestedPropSchema($newSpec, $schemaName, $path, $field) + $propSchema = $schemaPath !== null + ? nestedPropSchema($newSpec, $schemaName, $schemaPath, $field) : ($newSpec['components']['schemas'][$schemaName]['properties'][$field] ?? []); $specType = specTypeCategory($propSchema); $sdkType = phpTypeCategory($phpType); if (! categoriesCompatible($specType, $sdkType)) { - $label = $schemaName.($path !== null ? ".{$path}" : ''); + $label = $schemaName.($schemaPath !== null ? ".{$schemaPath}" : ''); $needsHuman[] = "NEEDS_HUMAN: type mismatch on {$label}.{$field}: spec declares " .($specType['category'] ?? 'unknown').($specType['nullable'] ? '|null' : '') .", SDK declares {$phpType} (mapped class(es): ".implode(', ', array_column($entry['sdk'], 'class')).')'; @@ -778,7 +856,7 @@ function reconcileTypes(array $map, array $newSpec, array $reachable, array $cla continue; } - $unmodeledKey = $canonicalSchema.'|'.($path ?? '').'|'.$field; + $unmodeledKey = $canonicalSchema.'|'.($schemaPath ?? '').'|'.$field; if (isset($unmodeledIndex[$unmodeledKey])) { continue; } @@ -790,14 +868,14 @@ function reconcileTypes(array $map, array $newSpec, array $reachable, array $cla $applicable[] = [ 'kind' => 'field-added', 'schema' => $schemaName, - 'path' => $path, + 'path' => $schemaPath, 'field' => $field, 'class' => $site['class'], 'file' => $site['file'], - 'propSchema' => $path !== null - ? (nestedPropSchema($newSpec, $schemaName, $path, $field)) + 'propSchema' => $schemaPath !== null + ? (nestedPropSchema($newSpec, $schemaName, $schemaPath, $field)) : ($newSpec['components']['schemas'][$schemaName]['properties'][$field] ?? []), - 'sortKey' => "{$schemaName}|".($path ?? '')."|{$field}|{$site['class']}", + 'sortKey' => "{$schemaName}|".($schemaPath ?? '')."|{$field}|{$site['class']}", ]; } } @@ -807,10 +885,11 @@ function reconcileTypes(array $map, array $newSpec, array $reachable, array $cla return [$applicable, $needsHuman]; } -function nestedPropSchema(array $spec, string $schema, string $path, string $field): array +// $schemaPath is a dotted schema property path, never a filesystem path -- see nestedProps(). +function nestedPropSchema(array $spec, string $schema, string $schemaPath, string $field): array { $node = $spec['components']['schemas'][$schema] ?? []; - foreach (explode('.', $path) as $part) { + foreach (explode('.', $schemaPath) as $part) { $node = $node['properties'][$part] ?? []; } @@ -902,7 +981,8 @@ function computeStructuralDiff(array $oldSpec, array $newSpec, array $reachableO } foreach ($map['types'] as $entry) { - $path = $entry['path'] ?? null; + // A dotted schema property path (e.g. "tracking_payment"), never a filesystem path. + $schemaPath = $entry['path'] ?? null; $schemas = typeSpecSchemas($entry); // See reconcileTypes(): a discriminator fan-out's flattened schema nullability doesn't // apply uniformly to every rail's class, so per-field type/nullability checks only make @@ -919,14 +999,14 @@ function computeStructuralDiff(array $oldSpec, array $newSpec, array $reachableO if (! isset($reachableOld[$schemaName]) || ! isset($reachableNew[$schemaName])) { continue; } - $oldProps = $path !== null ? nestedProps($oldSpec, $schemaName, $path) : schemaProps($oldSpec, $schemaName); - $newProps = $path !== null ? nestedProps($newSpec, $schemaName, $path) : schemaProps($newSpec, $schemaName); + $oldProps = $schemaPath !== null ? nestedProps($oldSpec, $schemaName, $schemaPath) : schemaProps($oldSpec, $schemaName); + $newProps = $schemaPath !== null ? nestedProps($newSpec, $schemaName, $schemaPath) : schemaProps($newSpec, $schemaName); if ($oldProps === null || $newProps === null) { continue; } $removed = array_diff($oldProps, $newProps); if (! empty($removed)) { - $label = $schemaName.($path !== null ? ".{$path}" : ''); + $label = $schemaName.($schemaPath !== null ? ".{$schemaPath}" : ''); $issues[] = 'NEEDS_HUMAN: propert'.(count($removed) === 1 ? 'y' : 'ies')." removed from {$label}: ".implode(', ', $removed).' (breaking, requires a deliberate major)'; } @@ -935,10 +1015,10 @@ function computeStructuralDiff(array $oldSpec, array $newSpec, array $reachableO if ($phpType === null) { continue; } - $oldPropSchema = $path !== null ? nestedPropSchema($oldSpec, $schemaName, $path, $field) : ($oldSpec['components']['schemas'][$schemaName]['properties'][$field] ?? []); - $newPropSchema = $path !== null ? nestedPropSchema($newSpec, $schemaName, $path, $field) : ($newSpec['components']['schemas'][$schemaName]['properties'][$field] ?? []); + $oldPropSchema = $schemaPath !== null ? nestedPropSchema($oldSpec, $schemaName, $schemaPath, $field) : ($oldSpec['components']['schemas'][$schemaName]['properties'][$field] ?? []); + $newPropSchema = $schemaPath !== null ? nestedPropSchema($newSpec, $schemaName, $schemaPath, $field) : ($newSpec['components']['schemas'][$schemaName]['properties'][$field] ?? []); if (typeNullabilityWidened($oldPropSchema, $newPropSchema, phpTypeCategory($phpType))) { - $label = $schemaName.($path !== null ? ".{$path}" : ''); + $label = $schemaName.($schemaPath !== null ? ".{$schemaPath}" : ''); $issues[] = "NEEDS_HUMAN: {$label}.{$field} newly allows null in the spec, but the SDK declares a non-nullable {$phpType} (mapped class: {$entry['sdk'][0]['class']})"; } } @@ -1001,11 +1081,12 @@ function auditTypes(array $map, array $spec, array $reachable, array $classIndex foreach ($map['types'] as $entry) { $schemas = typeSpecSchemas($entry); - $path = $entry['path'] ?? null; + // A dotted schema property path (e.g. "tracking_payment"), never a filesystem path. + $schemaPath = $entry['path'] ?? null; $canonicalSchema = $schemas[0]; if (count($entry['sdk']) > 1) { - $skippedFanOuts[] = $canonicalSchema.($path !== null ? ".{$path}" : ''); + $skippedFanOuts[] = $canonicalSchema.($schemaPath !== null ? ".{$schemaPath}" : ''); continue; } @@ -1020,7 +1101,7 @@ function auditTypes(array $map, array $spec, array $reachable, array $classIndex if (! isset($reachable[$schemaName])) { continue; } - $specProps = $path !== null ? nestedProps($spec, $schemaName, $path) : schemaProps($spec, $schemaName); + $specProps = $schemaPath !== null ? nestedProps($spec, $schemaName, $schemaPath) : schemaProps($spec, $schemaName); if ($specProps === null) { continue; } @@ -1031,8 +1112,8 @@ function auditTypes(array $map, array $spec, array $reachable, array $classIndex if ($phpType === null) { continue; // not modeled via a promoted constructor property -- nothing to compare } - $propSchema = $path !== null - ? nestedPropSchema($spec, $schemaName, $path, $field) + $propSchema = $schemaPath !== null + ? nestedPropSchema($spec, $schemaName, $schemaPath, $field) : ($spec['components']['schemas'][$schemaName]['properties'][$field] ?? []); $specType = specTypeCategory($propSchema); $sdkType = phpTypeCategory($phpType); @@ -1043,7 +1124,7 @@ function auditTypes(array $map, array $spec, array $reachable, array $classIndex continue; } - $label = $schemaName.($path !== null ? ".{$path}" : ''); + $label = $schemaName.($schemaPath !== null ? ".{$schemaPath}" : ''); $findings[] = [ 'field' => "{$label}.{$field}", 'class' => $entry['sdk'][0]['class'], @@ -1076,7 +1157,7 @@ function detectIndent(string $line): string function applyEnumCaseInsertion(string $root, string $file, string $className, string $value, array $newSpec, array $specValuesByEnum): void { - $path = "{$root}/{$file}"; + $path = resolveWithinRoot($root, $file, "enum class file for {$className}"); $lines = file($path, FILE_IGNORE_NEW_LINES); $classes = scanClassesDetailed($path); $info = $classes[$className]; @@ -1141,7 +1222,7 @@ function deriveEnumCaseName(string $className, string $value): string */ function applyFieldInsertion(string $root, string $file, string $className, string $field, array $propSchema): void { - $path = "{$root}/{$file}"; + $path = resolveWithinRoot($root, $file, "class file for {$className}"); $camel = snakeToCamel($field); $phpType = phpTypeFor($propSchema); @@ -1251,7 +1332,7 @@ function bumpVersionString(string $version, string $bump): string function bumpVersion(string $root, string $bump): string { - $file = "{$root}/src/BlindPay.php"; + $file = resolveWithinRoot($root, 'src/BlindPay.php', 'VERSION file'); $source = file_get_contents($file); if (! preg_match("/private const VERSION = '([0-9]+\\.[0-9]+\\.[0-9]+)';/", $source, $m)) { fwrite(STDERR, "[api-sync] FAIL: could not find VERSION const in src/BlindPay.php\n"); @@ -1285,8 +1366,10 @@ function runCli(array $argv, string $root): void { $opts = parseArgs($argv); $mode = $opts['apply'] ? 'apply' : 'check'; - $specPath = $opts['spec'] ?? ($root.'/.api-sync/spec-current.json'); - $reportPath = $opts['report'] ?? null; + // Resolved and validated once, right where they enter the program (see resolveReadablePath()/ + // resolveWritablePath() above), not re-trusted as raw strings at each later read/write. + $specPath = resolveReadablePath($opts['spec'] ?? ($root.'/.api-sync/spec-current.json'), '--spec'); + $reportPath = $opts['report'] !== null ? resolveWritablePath($opts['report'], '--report') : null; $map = loadJson($root.'/.api-sync/spec-map.json'); $unmodeled = loadJson($root.'/.api-sync/unmodeled.json'); @@ -1447,7 +1530,7 @@ function runCli(array $argv, string $root): void // Refresh the snapshot with the SOURCE SPEC FILE'S BYTES, verbatim -- never re-serialize via // json_decode/json_encode, which would silently reformat indentation, escaping and key order // and turn every future sync PR into an ~86k-line unreviewable snapshot diff. - copy($specPath, $root.'/.api-sync/spec-snapshot.json'); + copy($specPath, resolveWithinRoot($root, '.api-sync/spec-snapshot.json', 'spec snapshot')); $report['applied'] = $applicable; if ($reportPath !== null) { diff --git a/tests/ApiSync/ApiSyncTest.php b/tests/ApiSync/ApiSyncTest.php index abe7b2a..dfdf9ae 100644 --- a/tests/ApiSync/ApiSyncTest.php +++ b/tests/ApiSync/ApiSyncTest.php @@ -718,6 +718,146 @@ public function audit_types_cli_mode_always_exits_zero_even_with_findings(): voi $this->assertNotEmpty(array_filter($output, fn ($l) => str_contains($l, 'WidgetOut.name'))); } + // ---- path validation: every path from a CLI arg or spec-map.json is resolved and validated + // ---- before any read/write touches it, and writes derived from spec-map.json cannot escape + // ---- the repository root. + + #[Test] + public function resolve_readable_path_accepts_an_existing_file_and_returns_its_canonical_form(): void + { + $file = "{$this->fixtureRoot}/src/BlindPay.php"; + file_put_contents($file, "assertSame(realpath($file), $resolved); + } + + #[Test] + public function resolve_readable_path_rejects_a_nul_byte(): void + { + // A NUL byte cannot survive in a real argv element (execve() argv strings are + // NUL-terminated), so this is tested as a direct unit call, isolated in a subprocess since + // resolveReadablePath() calls exit(1) on rejection, which would otherwise kill the test + // runner. The `"\0"` is a PHP string escape evaluated at runtime by the subprocess, not a + // raw byte passed through shell argv. + [$output, $exitCode] = $this->runPhpSnippetInSubprocess( + 'resolveReadablePath("/tmp/whatever"."\0".".json", "--spec");' + ); + + $this->assertSame(1, $exitCode); + $this->assertNotEmpty(array_filter($output, fn ($l) => str_contains($l, 'invalid --spec path'))); + } + + #[Test] + public function resolve_readable_path_rejects_a_file_that_does_not_exist(): void + { + [$output, $exitCode] = $this->runCliSubprocess(['--check', '--spec='."{$this->fixtureRoot}/does-not-exist.json"]); + + $this->assertSame(1, $exitCode); + $this->assertNotEmpty(array_filter($output, fn ($l) => str_contains($l, 'does not exist'))); + } + + #[Test] + public function resolve_writable_path_rejects_a_parent_directory_that_does_not_exist(): void + { + [$output, $exitCode] = $this->runCliSubprocess([ + '--check', + '--spec='."{$this->fixtureRoot}/.api-sync/spec-snapshot.json", + '--report='."{$this->fixtureRoot}/no-such-dir/report.json", + ]); + + $this->assertSame(1, $exitCode); + $this->assertNotEmpty(array_filter($output, fn ($l) => str_contains($l, 'directory for --report does not exist'))); + } + + #[Test] + public function resolve_writable_path_accepts_a_destination_outside_the_repository_root(): void + { + // Required, legitimate functionality: CI writes --report to /tmp; the determinism proof + // writes into scratch copies elsewhere on disk. Writable paths are deliberately NOT + // restricted to the repository root (only spec-map.json-driven writes are, see below). + $outside = sys_get_temp_dir().'/blindpay-api-sync-report-'.bin2hex(random_bytes(6)).'.json'; + + $resolved = resolveWritablePath($outside, '--report'); + + $this->assertSame(realpath(dirname($outside)).'/'.basename($outside), $resolved); + } + + #[Test] + public function resolve_within_root_accepts_a_legitimate_spec_map_file(): void + { + $resolved = resolveWithinRoot($this->fixtureRoot, 'src/Resources/Widgets/Widgets.php', 'class file'); + + $this->assertSame(realpath("{$this->fixtureRoot}/src/Resources/Widgets/Widgets.php"), $resolved); + } + + #[Test] + public function resolve_within_root_refuses_a_path_that_escapes_the_repository_root(): void + { + // Simulates a corrupted or malicious spec-map.json entry trying to write outside the repo. + // Unit-tested directly against resolveWithinRoot(): going through the full CLI, validateMap() + // would independently catch this first (the named class isn't actually declared at the + // claimed file), which is good defense in depth but would mask the check this test targets. + $escapeDir = dirname($this->fixtureRoot).'/escape-target-'.basename($this->fixtureRoot); + mkdir($escapeDir, 0777, true); + + try { + [$output, $exitCode] = $this->runPhpSnippetInSubprocess(sprintf( + 'resolveWithinRoot(%s, %s, "class file");', + var_export($this->fixtureRoot, true), + var_export('../'.basename($escapeDir).'/evil.php', true) + )); + + $this->assertSame(1, $exitCode); + $this->assertNotEmpty(array_filter($output, fn ($l) => str_contains($l, 'resolves outside the repository root'))); + $this->assertFileDoesNotExist("{$escapeDir}/evil.php"); + } finally { + $this->removeDirectory($escapeDir); + } + } + + /** @return array{0: array, 1: int} */ + private function runCliSubprocess(array $args): array + { + mkdir("{$this->fixtureRoot}/.api-sync", 0777, true); + mkdir("{$this->fixtureRoot}/scripts", 0777, true); + copy(__DIR__.'/../../scripts/api-sync.php', "{$this->fixtureRoot}/scripts/api-sync.php"); + file_put_contents("{$this->fixtureRoot}/src/BlindPay.php", "fixtureRoot}/.api-sync/spec-map.json", json_encode($this->widgetMap())); + file_put_contents("{$this->fixtureRoot}/.api-sync/unmodeled.json", json_encode(['entries' => []])); + file_put_contents("{$this->fixtureRoot}/.api-sync/known-divergences.json", json_encode(['enumValues' => [], 'fields' => []])); + file_put_contents("{$this->fixtureRoot}/.api-sync/spec-snapshot.json", json_encode($this->baseSpec())); + + $cmd = 'php '.escapeshellarg("{$this->fixtureRoot}/scripts/api-sync.php"); + foreach ($args as $arg) { + $cmd .= ' '.escapeshellarg($arg); + } + exec($cmd.' 2>&1', $output, $exitCode); + + return [$output, $exitCode]; + } + + /** + * Runs one PHP statement in a fresh subprocess that has already `require_once`'d api-sync.php + * in lib-only mode, so a helper under test that calls exit() on failure doesn't kill the test + * runner. Used to unit-test resolveReadablePath()/resolveWithinRoot()'s exit(1) paths directly. + * + * @return array{0: array, 1: int} + */ + private function runPhpSnippetInSubprocess(string $statement): array + { + $script = $this->fixtureRoot.'/snippet.php'; + file_put_contents($script, sprintf( + "&1', $output, $exitCode); + + return [$output, $exitCode]; + } + // ---- snapshot refresh must copy bytes verbatim, never re-serialize ---- #[Test] From 9758c376af94869f057894fd37dac96c4d51f4d3 Mon Sep 17 00:00:00 2001 From: Eric Viana Date: Mon, 3 Aug 2026 21:30:56 -0300 Subject: [PATCH 4/5] fix(ci): keep the determinism proof's reports outside the diffed trees Regression from the previous commit's path hardening: --spec is now resolved to its canonical absolute form via resolveReadablePath() (realpath()) before use, which is correct for security, but that path is also echoed into the --report JSON's "spec" field. Writing report.json INSIDE each of the two scratch copies meant the reports differed by the copies' own absolute directory, failing the api-sync-check job's determinism diff even though the actually-applied source code was identical. Reports now write to sibling paths outside /tmp/api-sync-determinism/{a,b}, matching how this was exercised locally throughout development. The determinism guarantee is about the applied code, not an invocation-specific diagnostic that will always vary by which directory you happened to run in. Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs --- .github/workflows/main.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 03cd86b..1a7a2e2 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -65,8 +65,11 @@ jobs: mkdir -p /tmp/api-sync-determinism/a /tmp/api-sync-determinism/b cp -R . /tmp/api-sync-determinism/a cp -R . /tmp/api-sync-determinism/b - (cd /tmp/api-sync-determinism/a && php scripts/api-sync.php --apply --spec=.api-sync/spec-snapshot.json --report=report.json) - (cd /tmp/api-sync-determinism/b && php scripts/api-sync.php --apply --spec=.api-sync/spec-snapshot.json --report=report.json) + # Reports are written OUTSIDE the two trees being compared: --report's resolved path is + # an absolute, invocation-specific diagnostic (which directory you happened to run in), + # not part of the determinism guarantee, which is about the applied CODE being identical. + (cd /tmp/api-sync-determinism/a && php scripts/api-sync.php --apply --spec=.api-sync/spec-snapshot.json --report=/tmp/api-sync-determinism/report-a.json) + (cd /tmp/api-sync-determinism/b && php scripts/api-sync.php --apply --spec=.api-sync/spec-snapshot.json --report=/tmp/api-sync-determinism/report-b.json) diff -rq --exclude=.git /tmp/api-sync-determinism/a /tmp/api-sync-determinism/b - name: Coverage report (non-blocking -- spec operations/schemas with no SDK mapping) if: always() From 9f6d65dd39006612ac26c7bdad4f9478d7f689d0 Mon Sep 17 00:00:00 2001 From: Eric Viana Date: Mon, 3 Aug 2026 21:34:05 -0300 Subject: [PATCH 5/5] docs(api-sync): record CreateWalletIn.name as a live defect, not cosmetic name is REQUIRED on CreateWalletIn (required: [network, name]), but CreateCustodialWalletInput's constructor only accepts customerId and network. Every custodial-wallet-creation call through this SDK omits a required field and the API rejects it -- custodial wallet creation is completely broken through this SDK today, not a minor gap. node, go and swift all send name; python and php do not. Reworded the unmodeled.json entry to say so plainly. Not fixed here: adding a required (non-defaulted) constructor parameter is a breaking API-shape change to CreateCustodialWalletInput, not a mechanical optional-field add. Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs --- .api-sync/unmodeled.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.api-sync/unmodeled.json b/.api-sync/unmodeled.json index a4364c5..90dba38 100644 --- a/.api-sync/unmodeled.json +++ b/.api-sync/unmodeled.json @@ -513,7 +513,7 @@ { "schema": "CreateWalletIn", "field": "name", - "reason": "Wallet display name; not exposed as a constructor parameter on CreateCustodialWalletInput (only network is accepted).", + "reason": "LIVE DEFECT, not cosmetic: `name` is REQUIRED on CreateWalletIn (spec `required: [\"network\",\"name\"]`), but CreateCustodialWalletInput's constructor accepts only customerId and network. Every custodial-wallet-creation call through this SDK omits a required field and is rejected by the API -- custodial wallet creation is completely broken through this SDK today. node, go and swift all send `name`; python and php do not. Not fixed here: this needs a required (non-defaulted) constructor parameter, which is a breaking API-shape change to CreateCustodialWalletInput, not a mechanical optional-field add.", "owner": "eric@blindpay.com" }, {