diff --git a/.api-sync/spec-map.json b/.api-sync/spec-map.json new file mode 100644 index 0000000..7f1f379 --- /dev/null +++ b/.api-sync/spec-map.json @@ -0,0 +1,338 @@ +{ + "$schema": "curated mapping from spec constructs to SDK symbols; consumed by sync.py; every entry hand-verified against src/blindpay", + "notes": [ + "Coverage is computed over the REACHABLE schema set only (transitive $ref closure from paths + webhooks); schemas outside that closure (e.g. LedgerOperation) are skipped by construction, not by being hand-listed here.", + "The *WebhookOut envelope schemas (PayoutNewWebhookOut, PayinNewWebhookOut, etc.) appear in `types` ONLY via their nested tracking_* specPath entries, because those sub-objects are reused verbatim from the modeled GET/POST response flows. Their own webhook-envelope-level fields (type, timestamp, key, ...) are intentionally out of scope: the SDK does not parse webhook payloads at all (verify_webhook_signature() checks the signature only), so those root-level fields are covered by the blanket ignore.schemas entries for the same schema names where they are not also a tracking_* host." + ], + "enums": [ + { + "spec": { "schema": "WebhookEndpointIn", "property": "events", "items": true }, + "sdk": { "file": "src/blindpay/resources/webhooks/webhooks.py", "symbol": "WebhookEvents" } + }, + { + "spec": { "schema": "QuoteIn", "property": "network" }, + "sdk": { "file": "src/blindpay/types.py", "symbol": "Network" } + }, + { + "spec": { "schema": "QuoteIn", "property": "token" }, + "sdk": { "file": "src/blindpay/types.py", "symbol": "StablecoinToken" } + }, + { + "spec": { "schema": "PayoutOut", "property": "transaction_document_type" }, + "sdk": { "file": "src/blindpay/types.py", "symbol": "TransactionDocumentType" } + }, + { + "spec": { "schema": "CreateBankAccountIn", "property": "type" }, + "sdk": { "file": "src/blindpay/types.py", "symbol": "Rail" } + }, + { + "spec": { "schema": "QuoteIn", "property": "currency_type" }, + "sdk": { "file": "src/blindpay/types.py", "symbol": "CurrencyType" } + }, + { + "spec": { "schema": "CustomerOut", "property": "type" }, + "sdk": { "file": "src/blindpay/types.py", "symbol": "AccountClass" } + }, + { + "spec": { "schema": "PayoutOut", "property": "status" }, + "sdk": { "file": "src/blindpay/types.py", "symbol": "TransactionStatus" }, + "note": "spec is a strict subset per-schema (PayoutOut/PayinOut/TransferOut each expose 4-5 of the SDK's 6 values); SDK Literal already a superset, nothing to reconcile" + }, + { + "spec": { "schema": "CreateBankAccountIn", "property": "spei_protocol" }, + "sdk": { "file": "src/blindpay/types.py", "symbol": "SpeiProtocol" } + }, + { + "spec": { "schema": "CreateBankAccountIn", "property": "recipient_relationship" }, + "sdk": { "file": "src/blindpay/types.py", "symbol": "RecipientRelationship" } + }, + { + "spec": { "schema": "VirtualAccountOut", "property": "banking_partner" }, + "sdk": { "file": "src/blindpay/types.py", "symbol": "BankingPartner" } + }, + { + "spec": { "schema": "CreatePayinQuoteIn", "property": "payment_method" }, + "sdk": { "file": "src/blindpay/resources/payins/quotes.py", "symbol": "PaymentMethod" }, + "note": "the FIELD ACTUALLY USES this local symbol (CreatePayinQuoteInput.payment_method: PaymentMethod resolves to payins/quotes.py's own Literal, not the shared types.py one) -- mapping must point here, not at types.py, or a future member addition would land in a Literal this field never references. Confirmed against blindpay-v2 packages/reference/src/rails.ts (paymentMethod, 9 values) and the other 4 SDKs (node/go/php/swift all carry all 9; python was the only one stuck at 6). Discovered missing transfers/pse/international_swift during Phase A bootstrap; textbook APPLICABLE case (pure member additions, same risk class as BankingPartner/portage), applied in the Phase A drift-application commit. Follow-up: after this fix, payins/quotes.py's local PaymentMethod is a byte-identical duplicate of types.py's shared PaymentMethod -- deliberately NOT refactored away here (a patcher should not delete symbols); worth a dedicated follow-up to import the shared one instead." + }, + { + "spec": { "schema": "PayinOut", "property": "payment_method" }, + "sdk": { "file": "src/blindpay/types.py", "symbol": "PaymentMethod" }, + "note": "the shared, publicly-exported types.py PaymentMethod (9 values) is not referenced by any TypedDict field today (grep confirms it), but it is exported from blindpay/__init__.py and blindpay.resources.payins, so it is kept reconciled against a real 9-value anchor rather than left un-anchored. No gap: already matches exactly." + }, + { + "spec": { "schema": "RfiField", "property": "aiprise_document_type" }, + "sdk": { "file": "src/blindpay/types.py", "symbol": "AipriseDocumentType" }, + "note": "the Rfi family itself is in ignore.schemas (unmodeled resource), but the enum values are shared with the modeled onboarding flow so the Literal is kept reconciled" + }, + { + "spec": { "schema": "UploadAnalyzeOut", "property": "approval_rate" }, + "sdk": { "file": "src/blindpay/types.py", "symbol": "ApprovalRate" } + }, + { + "spec": { "schema": "BankAccountOut", "property": "account_type" }, + "sdk": { "file": "src/blindpay/types.py", "symbol": "BankAccountType" }, + "note": "KNOWN DIVERGENCE, kept mapped on purpose: spec enum is [checking, saving], SDK Literal is [checking, savings]. See unmodeled.json kind=enum entry; do not blindly append, this needs a coordinated rename PR (confirmed live defect, not just typing)." + }, + { + "spec": { "schema": "CustomerOut", "property": "kyc_status" }, + "sdk": { "file": "src/blindpay/resources/customers/customers.py", "symbol": "KycStatus" }, + "note": "KNOWN DIVERGENCE, kept mapped on purpose: spec has 8 members, SDK Literal has 2. See unmodeled.json kind=enum entry; needs a human decision on whether this Literal is even the right type for this field before adding members." + } + ], + "types": [ + { "spec": "AvailableBankDetails", "sdk": [{ "file": "src/blindpay/resources/available/available.py", "symbol": "BankDetail" }] }, + { "spec": "AvailableNaicsList", "sdk": [{ "file": "src/blindpay/resources/available/available.py", "symbol": "NaicsCode" }] }, + { "spec": "AvailableRails", "sdk": [{ "file": "src/blindpay/resources/available/available.py", "symbol": "RailInfo" }] }, + { "spec": ["SwiftCodeItem", "SwiftCodeResponse"], "sdk": [{ "file": "src/blindpay/resources/available/available.py", "symbol": "SwiftCodeBankDetail" }], + "note": "SwiftCodeResponse is the bare-array wrapper (List[SwiftCodeItem]) with no properties of its own; listed so it is not reported as an unaccounted schema" + }, + { "spec": "PaginationMetadata", "sdk": [{ "file": "src/blindpay/types.py", "symbol": "PaginationMetadata" }] }, + + { "spec": "BankAccountOut", "sdk": [ + { "file": "src/blindpay/resources/bank_accounts/bank_accounts.py", "symbol": "BankAccount" }, + { "file": "src/blindpay/resources/bank_accounts/bank_accounts.py", "symbol": "GetBankAccountResponse" } + ], + "note": "discriminator fan-out target for the response side; BankAccount is the list() shape, GetBankAccountResponse is the get() shape (already carries pre-existing allowlist.json divergences: account_holder_name/is_primary/swift_code/iban)" + }, + { "spec": "CreateBankAccountIn", "sdk": [ + { "file": "src/blindpay/resources/bank_accounts/bank_accounts.py", "symbol": "CreatePixInput" }, + { "file": "src/blindpay/resources/bank_accounts/bank_accounts.py", "symbol": "CreateArgentinaTransfersInput" }, + { "file": "src/blindpay/resources/bank_accounts/bank_accounts.py", "symbol": "CreateSpeiInput" }, + { "file": "src/blindpay/resources/bank_accounts/bank_accounts.py", "symbol": "CreateColombiaAchInput" }, + { "file": "src/blindpay/resources/bank_accounts/bank_accounts.py", "symbol": "CreateAchInput" }, + { "file": "src/blindpay/resources/bank_accounts/bank_accounts.py", "symbol": "CreateWireInput" }, + { "file": "src/blindpay/resources/bank_accounts/bank_accounts.py", "symbol": "CreateInternationalSwiftInput" }, + { "file": "src/blindpay/resources/bank_accounts/bank_accounts.py", "symbol": "CreateRtpInput" }, + { "file": "src/blindpay/resources/bank_accounts/bank_accounts.py", "symbol": "CreateSepaInput" }, + { "file": "src/blindpay/resources/bank_accounts/bank_accounts.py", "symbol": "CreatePixSafeInput" }, + { "file": "src/blindpay/resources/bank_accounts/bank_accounts.py", "symbol": "CreateTedInput" } + ], + "note": "the 11-rail discriminator fan-out on the request side (design doc's headline case); `type` is injected by each create_* method, never a field on these TypedDicts" + }, + + { "spec": "CreateBlockchainWalletIn", "sdk": [ + { "file": "src/blindpay/resources/wallets/blockchain.py", "symbol": "CreateBlockchainWalletWithAddressInput" }, + { "file": "src/blindpay/resources/wallets/blockchain.py", "symbol": "CreateBlockchainWalletWithHashInput" } + ], + "note": "discriminated by is_account_abstraction, which each create_with_* method injects rather than exposing as a field" + }, + { "spec": "BlockchainWalletOut", "sdk": [{ "file": "src/blindpay/resources/wallets/blockchain.py", "symbol": "BlockchainWallet" }] }, + { "spec": "BlockchainWalletMessageOut", "sdk": [{ "file": "src/blindpay/resources/wallets/blockchain.py", "symbol": "GetBlockchainWalletMessageResponse" }] }, + + { "spec": "CreateCustomerIn", "sdk": [ + { "file": "src/blindpay/resources/customers/customers.py", "symbol": "CreateIndividualWithStandardKYCInput" }, + { "file": "src/blindpay/resources/customers/customers.py", "symbol": "CreateIndividualWithEnhancedKYCInput" }, + { "file": "src/blindpay/resources/customers/customers.py", "symbol": "CreateBusinessWithStandardKYBInput" } + ], + "note": "the 3-way KYC discriminator fan-out on the request side; `type`/`kyc_type` are injected by each create_* method" + }, + { "spec": "CustomerOut", "sdk": [ + { "file": "src/blindpay/resources/customers/customers.py", "symbol": "IndividualWithStandardKYC" }, + { "file": "src/blindpay/resources/customers/customers.py", "symbol": "IndividualWithEnhancedKYC" }, + { "file": "src/blindpay/resources/customers/customers.py", "symbol": "BusinessWithStandardKYB" } + ], + "note": "the 3-way KYC discriminator fan-out on the response side (design doc's headline case)" + }, + { "spec": "UpdateCustomerIn", "sdk": [{ "file": "src/blindpay/resources/customers/customers.py", "symbol": "UpdateCustomerInput" }] }, + { "spec": "CustomerLimitIncreaseIn", "sdk": [{ "file": "src/blindpay/resources/customers/customers.py", "symbol": "RequestLimitIncreaseInput" }] }, + { "spec": "CustomerLimitIncreaseOut", "sdk": [{ "file": "src/blindpay/resources/customers/customers.py", "symbol": "RequestLimitIncreaseResponse" }] }, + { "spec": "GetCustomerLimitIncreaseOut", "sdk": [{ "file": "src/blindpay/resources/customers/customers.py", "symbol": "LimitIncreaseRequest" }] }, + { "spec": "GetCustomerLimitsOut", "sdk": [ + { "file": "src/blindpay/resources/customers/customers.py", "symbol": "GetCustomerLimitsResponse" }, + { "file": "src/blindpay/resources/customers/customers.py", "symbol": "Limits" }, + { "file": "src/blindpay/resources/customers/customers.py", "symbol": "PayinLimit" }, + { "file": "src/blindpay/resources/customers/customers.py", "symbol": "PayoutLimit" }, + { "file": "src/blindpay/resources/limits/limits.py", "symbol": "GetCustomerLimitsResponse" }, + { "file": "src/blindpay/resources/limits/limits.py", "symbol": "CustomerLimits" }, + { "file": "src/blindpay/resources/limits/limits.py", "symbol": "PayinLimit" }, + { "file": "src/blindpay/resources/limits/limits.py", "symbol": "PayoutLimit" } + ], + "note": "pre-existing duplicate modeling: customers.py and limits.py each declare their own near-identical copy of this shape (survey irregularity #4, out of scope for this project); both are listed so reconciliation checks either copy" + }, + + { "spec": "CreatePayinIn", "sdk": [{ "file": "src/blindpay/resources/payins/payins.py", "symbol": "CreatePayinInput" }], + "note": "CreatePayinInput is dead code (create_evm() takes a bare payin_quote_id str, per allowlist.json); mapped anyway so drift on this schema stays visible instead of silently skipped" }, + { "spec": "CreatePayinOut", "sdk": [{ "file": "src/blindpay/resources/payins/payins.py", "symbol": "CreateEvmPayinResponse" }] }, + { "spec": "PayinOut", "sdk": [ + { "file": "src/blindpay/resources/payins/payins.py", "symbol": "Payin" }, + { "file": "src/blindpay/resources/payins/payins.py", "symbol": "GetPayinTrackResponse" } + ] + }, + { "spec": "CreatePayinQuoteIn", "sdk": [{ "file": "src/blindpay/resources/payins/quotes.py", "symbol": "CreatePayinQuoteInput" }] }, + { "spec": "CreatePayinQuoteOut", "sdk": [{ "file": "src/blindpay/resources/payins/quotes.py", "symbol": "CreatePayinQuoteResponse" }] }, + + { "spec": "PayoutOut", "sdk": [{ "file": "src/blindpay/resources/payouts/payouts.py", "symbol": "Payout" }] }, + { "spec": "PayoutOnEvmIn", "sdk": [{ "file": "src/blindpay/resources/payouts/payouts.py", "symbol": "CreateEvmPayoutInput" }] }, + { "spec": "PayoutOnEvmOut", "sdk": [ + { "file": "src/blindpay/resources/payouts/payouts.py", "symbol": "CreateEvmPayoutResponse" }, + { "file": "src/blindpay/resources/payouts/payouts.py", "symbol": "CreateSolanaPayoutResponse" }, + { "file": "src/blindpay/resources/payouts/payouts.py", "symbol": "CreateStellarPayoutResponse" } + ], + "note": "the spec reuses this ONE schema as the 200 response for evm, solana AND stellar payout creation; the SDK models 3 separate response TypedDicts for it" + }, + { "spec": "PayoutOnSolanaIn", "sdk": [{ "file": "src/blindpay/resources/payouts/payouts.py", "symbol": "CreateSolanaPayoutInput" }] }, + { "spec": "PayoutOnStellarIn", "sdk": [{ "file": "src/blindpay/resources/payouts/payouts.py", "symbol": "CreateStellarPayoutInput" }] }, + { "spec": "PayoutOnStellarAuthorizeIn", "sdk": [{ "file": "src/blindpay/resources/payouts/payouts.py", "symbol": "AuthorizeStellarTokenInput" }] }, + { "spec": "SubmitPayoutDocumentsIn", "sdk": [{ "file": "src/blindpay/resources/payouts/payouts.py", "symbol": "SubmitPayoutDocumentsInput" }] }, + + { "spec": "QuoteIn", "sdk": [{ "file": "src/blindpay/resources/quotes/quotes.py", "symbol": "CreateQuoteInput" }], + "note": "refund_wallet_address lands here (Phase A delta field)" }, + { "spec": "QuoteOut", "sdk": [ + { "file": "src/blindpay/resources/quotes/quotes.py", "symbol": "CreateQuoteResponse" }, + { "file": "src/blindpay/resources/quotes/quotes.py", "symbol": "Contract" }, + { "file": "src/blindpay/resources/quotes/quotes.py", "symbol": "ContractNetwork" } + ] + }, + + { "spec": "CreateTransferQuoteIn", "sdk": [{ "file": "src/blindpay/resources/transfers/transfers.py", "symbol": "CreateTransferQuoteInput" }] }, + { "spec": "CreateTransferQuoteOut", "sdk": [{ "file": "src/blindpay/resources/transfers/transfers.py", "symbol": "CreateTransferQuoteResponse" }] }, + { "spec": "CreateTransferIn", "sdk": [{ "file": "src/blindpay/resources/transfers/transfers.py", "symbol": "CreateTransferInput" }] }, + { "spec": "CreateTransferOut", "sdk": [{ "file": "src/blindpay/resources/transfers/transfers.py", "symbol": "Transfer" }] }, + { "spec": "TransferOut", "sdk": [{ "file": "src/blindpay/resources/transfers/transfers.py", "symbol": "Transfer" }] }, + + { "spec": "CreateVirtualAccountIn", "sdk": [{ "file": "src/blindpay/resources/virtual_accounts/virtual_accounts.py", "symbol": "CreateVirtualAccountInput" }] }, + { "spec": "VirtualAccountOut", "sdk": [{ "file": "src/blindpay/resources/virtual_accounts/virtual_accounts.py", "symbol": "VirtualAccount" }] }, + { "spec": "UpdateVirtualAccountIn", "sdk": [{ "file": "src/blindpay/resources/virtual_accounts/virtual_accounts.py", "symbol": "UpdateVirtualAccountInput" }] }, + + { "spec": "CreateWalletIn", "sdk": [{ "file": "src/blindpay/resources/custodial_wallets/custodial_wallets.py", "symbol": "CreateCustodialWalletInput" }] }, + { "spec": "WalletOut", "sdk": [{ "file": "src/blindpay/resources/custodial_wallets/custodial_wallets.py", "symbol": "CustodialWallet" }] }, + { "spec": "WalletBalanceOut", "sdk": [{ "file": "src/blindpay/resources/custodial_wallets/custodial_wallets.py", "symbol": "CustodialWalletBalance" }] }, + { "spec": "WalletTokenOut", "sdk": [{ "file": "src/blindpay/resources/custodial_wallets/custodial_wallets.py", "symbol": "CustodialWalletBalanceToken" }] }, + + { "spec": "OfframpWallet", "sdk": [ + { "file": "src/blindpay/resources/bank_accounts/bank_accounts.py", "symbol": "OfframpWallet" }, + { "file": "src/blindpay/resources/wallets/offramp.py", "symbol": "OfframpWallet" } + ], + "note": "pre-existing duplicate modeling: two separate TypedDicts with the same name and shape in two files (survey irregularity, out of scope for this project); both listed" + }, + + { "spec": "WebhookEndpointIn", "sdk": [{ "file": "src/blindpay/resources/webhooks/webhooks.py", "symbol": "CreateWebhookEndpointInput" }] }, + { "spec": "WebhookEndpointOut", "sdk": [{ "file": "src/blindpay/resources/webhooks/webhooks.py", "symbol": "CreateWebhookEndpointResponse" }] }, + { "spec": "WebhookEndpoint", "sdk": [{ "file": "src/blindpay/resources/webhooks/webhooks.py", "symbol": "WebhookEndpoint" }] }, + { "spec": "PortalAccessOut", "sdk": [{ "file": "src/blindpay/resources/webhooks/webhooks.py", "symbol": "GetPortalAccessUrlResponse" }] }, + + { "spec": "InitiateTosIn", "sdk": [{ "file": "src/blindpay/resources/terms_of_service/terms_of_service.py", "symbol": "InitiateInput" }] }, + { "spec": "InitiateTosOut", "sdk": [{ "file": "src/blindpay/resources/terms_of_service/terms_of_service.py", "symbol": "InitiateResponse" }] }, + + { "spec": "UpdateInstanceIn", "sdk": [{ "file": "src/blindpay/resources/instances/instances.py", "symbol": "UpdateInstanceInput" }] }, + { "spec": "UpdateInstanceMemberIn", "sdk": [{ "file": "src/blindpay/resources/instances/instances.py", "symbol": "UpdateInstanceMemberRoleInput" }], + "note": "UpdateInstanceMemberRoleInput is dead code per allowlist.json (update_member_role takes member_id/role as loose args); mapped anyway so drift stays visible" }, + { "spec": "MigrateInstanceOwnershipIn", "sdk": [ + { "file": "src/blindpay/resources/instances/instances.py", "symbol": "MigrateInstanceOwnershipInput" }, + { "file": "src/blindpay/resources/ownership/ownership.py", "symbol": "MigrateInstanceOwnershipInput" } + ], + "note": "pre-existing duplicate: instances.py and ownership.py both declare this (survey irregularity #4); both listed" + }, + { "spec": "Success", "sdk": [ + { "file": "src/blindpay/resources/instances/instances.py", "symbol": "MigrateInstanceOwnershipResponse" }, + { "file": "src/blindpay/resources/ownership/ownership.py", "symbol": "MigrateInstanceOwnershipResponse" } + ] + }, + + { "spec": "FeeOptions", "sdk": [{ "file": "src/blindpay/resources/fees/fees.py", "symbol": "FeeOptions" }] }, + { "spec": "GetFeesOut", "sdk": [{ "file": "src/blindpay/resources/fees/fees.py", "symbol": "FeesResponse" }] }, + + { "spec": "UploadIn", "sdk": [{ "file": "src/blindpay/resources/upload/upload.py", "symbol": "UploadInput" }] }, + { "spec": "UploadOut", "sdk": [{ "file": "src/blindpay/resources/upload/upload.py", "symbol": "UploadResponse" }] }, + + { "spec": ["PayoutOut", "PayoutOnEvmOut", "PayoutNewWebhookOut", "PayoutCompleteWebhookOut", "PayoutUpdateWebhookOut", "PayoutPartnerFeeWebhookOut"], + "specPath": "tracking_payment", + "sdk": [{ "file": "src/blindpay/types.py", "symbol": "TrackingPayment" }], + "note": "inline sub-object duplicated across 6 payout-side schemas (never $ref'd, no shared spec component); SDK models one shared TypedDict used for both payout and payin. Payout wire shape has 20 properties, SDK models 6 -- see unmodeled.json" + }, + { "spec": ["PayoutOut", "PayoutOnEvmOut", "PayoutNewWebhookOut", "PayoutCompleteWebhookOut", "PayoutUpdateWebhookOut", "PayoutPartnerFeeWebhookOut"], + "specPath": "tracking_transaction", + "sdk": [{ "file": "src/blindpay/types.py", "symbol": "TrackingTransaction" }] + }, + { "spec": ["PayoutOut", "PayoutOnEvmOut", "PayoutNewWebhookOut", "PayoutCompleteWebhookOut", "PayoutUpdateWebhookOut", "PayoutPartnerFeeWebhookOut"], + "specPath": "tracking_complete", + "sdk": [{ "file": "src/blindpay/types.py", "symbol": "TrackingComplete" }] + }, + { "spec": ["PayoutOut", "PayoutOnEvmOut", "PayoutNewWebhookOut", "PayoutCompleteWebhookOut", "PayoutUpdateWebhookOut", "PayoutPartnerFeeWebhookOut"], + "specPath": "tracking_liquidity", + "sdk": [{ "file": "src/blindpay/types.py", "symbol": "TrackingLiquidity" }], + "note": "no gaps: SDK's 5-field TrackingLiquidity already matches this sub-object exactly" + }, + { "spec": ["PayinOut", "CreatePayinOut", "PayinNewWebhookOut", "PayinCompleteWebhookOut", "PayinUpdateWebhookOut", "PayinPartnerFeeWebhookOut"], + "specPath": "tracking_payment", + "sdk": [{ "file": "src/blindpay/types.py", "symbol": "TrackingPayment" }], + "note": "payin-side tracking_payment shape (6 properties) differs entirely from payout-side (20 properties) despite sharing the same SDK TypedDict; see unmodeled.json" + }, + { "spec": ["PayinOut", "CreatePayinOut", "PayinNewWebhookOut", "PayinCompleteWebhookOut", "PayinUpdateWebhookOut", "PayinPartnerFeeWebhookOut"], + "specPath": "tracking_transaction", + "sdk": [{ "file": "src/blindpay/types.py", "symbol": "TrackingTransaction" }], + "note": "payin-side wire shape has ~15 properties; SDK's generic 4-field TrackingTransaction covers only step/status/transaction_hash/completed_at. A fuller GetPayinTrackingTransaction TypedDict already exists in payins.py but is dead code (never referenced) -- see unmodeled.json" + }, + { "spec": ["PayinOut", "CreatePayinOut", "PayinNewWebhookOut", "PayinCompleteWebhookOut", "PayinUpdateWebhookOut", "PayinPartnerFeeWebhookOut"], + "specPath": "tracking_complete", + "sdk": [{ "file": "src/blindpay/types.py", "symbol": "TrackingComplete" }], + "note": "no gaps: payin-side tracking_complete (3 properties) is a subset of the SDK's 4-field TrackingComplete" + } + ], + "ignore": { + "schemas": [ + { + "schema": "LedgerOperation", + "reason": "unreferenced by any public operation (0 $ref anywhere in the filtered public spec) -- unreachable orphan, not modeled by any SDK. This is where funding_end_to_end_id and payer_tax_id live; both stay unmodeled because the schema itself is unreachable." + }, + { + "schema": "Rfi", + "reason": "the RFI (request-for-information) resource family is entirely unmodeled by this SDK: no resource file, no client method. GET /v1/instances/{instance_id}/rfi and GET .../customers/{customer_id}/rfi are real, reachable operations with zero SDK coverage. Adding it is additive, non-breaking, human-scoped work (Phase C per the design doc), not a field-level patch." + }, + { "schema": "RfiField", "reason": "nested under Rfi/InstanceRfi/RfiSection; same Rfi-family gap as Rfi." }, + { "schema": "RfiSection", "reason": "nested under Rfi/InstanceRfi; same Rfi-family gap as Rfi." }, + { "schema": "InstanceRfi", "reason": "instance-level counterpart of Rfi (GET /v1/instances/{instance_id}/rfi); same Rfi-family gap." }, + { + "schema": "CreateRfiBody", + "reason": "unreferenced by any public operation (0 $ref); the public POST .../rfi endpoints take an untyped object body, not this schema. Orphan, and also part of the unmodeled Rfi family." + }, + { + "schema": "CreateInstanceRfiBody", + "reason": "unreferenced by any public operation (0 $ref); same as CreateRfiBody -- orphan, and part of the unmodeled Rfi family." + }, + { + "schema": "UploadAnalyzeIn", + "reason": "reachable (POST /v1/upload/analyze) but the SDK's UploadAnalyzeInput/analyze() predates the current spec shape: `type` is required by the API and never sent by the SDK, so calling analyze() as currently typed does not produce a valid request. A known, real, broken integration needing dedicated human work (Phase C), not an incremental field-add." + }, + { + "schema": "UploadAnalyzeOut", + "reason": "response counterpart of UploadAnalyzeIn; same broken/incomplete-integration reasoning." + }, + { + "schema": "Error", + "reason": "the SDK does not model the API's error envelope as a typed object; BlindpayErrorResponse.error is a separate, much simpler ErrorResponse ({message: str}) shape unrelated to this schema." + }, + { + "schema": "AdditionalInfoItem", + "reason": "item shape of CustomerOut.additional_info, which is itself unmodeled by any of the 3 KYC variants (tracked in unmodeled.json); nothing to reconcile until additional_info itself is modeled." + }, + { + "schema": "BankAccountWebhookOut", + "reason": "webhook payload; the SDK does not parse webhook payloads at all, only verify_webhook_signature() (HMAC check). Callers deserialize the body themselves." + }, + { "schema": "BlockchainWalletWebhookOut", "reason": "webhook payload; same as BankAccountWebhookOut above." }, + { "schema": "CustomerNewWebhookOut", "reason": "webhook payload; same as BankAccountWebhookOut above." }, + { "schema": "CustomerUpdateWebhookOut", "reason": "webhook payload; same as BankAccountWebhookOut above." }, + { "schema": "CustomerDeleteWebhookOut", "reason": "webhook payload; same as BankAccountWebhookOut above." }, + { "schema": "LimitIncreaseNewWebhookOut", "reason": "webhook payload; same as BankAccountWebhookOut above." }, + { "schema": "LimitIncreaseUpdateWebhookOut", "reason": "webhook payload; same as BankAccountWebhookOut above." }, + { "schema": "TosAcceptWebhookOut", "reason": "webhook payload; same as BankAccountWebhookOut above." }, + { "schema": "WalletInboundSchemaOut", "reason": "webhook payload; same as BankAccountWebhookOut above." }, + { + "schema": "PayoutNewWebhookOut", + "reason": "webhook envelope; same as BankAccountWebhookOut above for its own top-level fields (type, timestamp, key, ...). Its nested tracking_payment/tracking_transaction/tracking_complete/tracking_liquidity sub-objects ARE separately reconciled via the specPath entries in `types` above, since those are reused verbatim from the modeled GET/POST payout responses." + }, + { "schema": "PayoutCompleteWebhookOut", "reason": "webhook envelope; same split treatment as PayoutNewWebhookOut above." }, + { "schema": "PayoutUpdateWebhookOut", "reason": "webhook envelope; same split treatment as PayoutNewWebhookOut above." }, + { "schema": "PayoutPartnerFeeWebhookOut", "reason": "webhook envelope; same split treatment as PayoutNewWebhookOut above." }, + { + "schema": "PayinNewWebhookOut", + "reason": "webhook envelope; same split treatment as PayoutNewWebhookOut above, but for the payin-side tracking_payment/tracking_transaction/tracking_complete specPath entries." + }, + { "schema": "PayinCompleteWebhookOut", "reason": "webhook envelope; same split treatment as PayinNewWebhookOut above." }, + { "schema": "PayinUpdateWebhookOut", "reason": "webhook envelope; same split treatment as PayinNewWebhookOut above." }, + { "schema": "PayinPartnerFeeWebhookOut", "reason": "webhook envelope; same split treatment as PayinNewWebhookOut above." } + ] + } +} 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/sync.py b/.api-sync/sync.py new file mode 100644 index 0000000..29c85a5 --- /dev/null +++ b/.api-sync/sync.py @@ -0,0 +1,1226 @@ +#!/usr/bin/env python3 +"""Deterministic spec -> SDK patcher. + +Run locally with: + python3 .api-sync/sync.py --check + python3 .api-sync/sync.py --apply [--spec PATH] [--report PATH] + python3 .api-sync/sync.py --validate-map + python3 .api-sync/sync.py --coverage [--spec PATH] + python3 .api-sync/sync.py --audit-types [--spec PATH] + +No third-party dependencies. Mirrors check_contract.py's style: ast for reading, +precise text splicing for writing. + +Design summary (see .api-sync/spec-map.json and .api-sync/unmodeled.json): + +- .api-sync/spec-map.json is the curated, hand-verified mapping from spec + constructs (enums, schemas, and named nested sub-objects via `specPath`) to + SDK symbols. Schemas outside the transitive $ref closure of paths/webhooks + are unreachable and are skipped by construction (never considered "unmapped" + or "coverage gaps" -- they simply do not exist for this tool's purposes). +- .api-sync/unmodeled.json is the honest ledger of currently-absent spec + properties and enum-member/value divergences on schemas/enums this SDK DOES + map, each with a reason and an owner. Entries there suppress both --check + failures and --apply auto-additions for that exact (schema[, path], field) + or (enum, missing value) -- they are a deliberate "not yet, and here is why" + record, not a silent skip. + +--check reconciles the current SDK source against .api-sync/spec-snapshot.json +(the last-synced baseline) and fails loudly on anything unaccounted for. It +never reads --spec. + +--apply reconciles against --spec (default .api-sync/spec-current.json, the +newly delivered spec), computes an old(snapshot)-vs-new(--spec) diff purely to +detect removals / required-ness changes / type changes / new operations / new +schemas (state alone cannot tell you these), applies whatever is APPLICABLE, +hard-fails on anything NEEDS_HUMAN, and on success refreshes spec-snapshot.json +to equal --spec. +""" + +from __future__ import annotations + +import argparse +import ast +import json +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +ROOT = Path(__file__).resolve().parent.parent +API_SYNC = Path(__file__).resolve().parent +SRC_ROOT = ROOT / "src" / "blindpay" + +SNAPSHOT_PATH = API_SYNC / "spec-snapshot.json" +DEFAULT_SPEC_PATH = API_SYNC / "spec-current.json" +MAP_PATH = API_SYNC / "spec-map.json" +UNMODELED_PATH = API_SYNC / "unmodeled.json" + +SCALAR_TYPE_MAP = {"string": "str", "integer": "int", "number": "float", "boolean": "bool"} + + +# --------------------------------------------------------------------------- # +# JSON loading +# --------------------------------------------------------------------------- # + + +def load_json(path: Path) -> Any: + with path.open() as f: + return json.load(f) + + +def load_map() -> dict: + return load_json(MAP_PATH) + + +def load_unmodeled() -> list[dict]: + if not UNMODELED_PATH.exists(): + return [] + data = load_json(UNMODELED_PATH) + if not isinstance(data, list): + raise SystemExit(f"{UNMODELED_PATH}: expected a JSON array") + for i, e in enumerate(data): + kind = e.get("kind") + if kind == "property": + required = {"schema", "field", "reason", "owner"} + elif kind == "enum": + required = {"enum", "missing_values", "reason", "owner"} + else: + raise SystemExit(f"{UNMODELED_PATH}[{i}]: unknown or missing 'kind' (expected 'property' or 'enum')") + missing = required - e.keys() + if missing: + raise SystemExit(f"{UNMODELED_PATH}[{i}]: missing required key(s) {sorted(missing)}") + return data + + +# --------------------------------------------------------------------------- # +# Spec introspection +# --------------------------------------------------------------------------- # + + +def find_refs(node: object, out: set[str]) -> None: + if isinstance(node, dict): + r = node.get("$ref") + if isinstance(r, str) and r.startswith("#/components/schemas/"): + out.add(r.split("/")[-1]) + for v in node.values(): + find_refs(v, out) + elif isinstance(node, list): + for v in node: + find_refs(v, out) + + +def compute_reachable_schemas(spec: dict) -> set[str]: + """Transitive $ref closure from paths + webhooks. Anything outside this + set does not exist for this tool: not mappable, not a coverage gap, not + reconciled -- skipped by construction, never by a hand-maintained list.""" + schemas = spec.get("components", {}).get("schemas", {}) + roots = {"paths": spec.get("paths", {}), "webhooks": spec.get("webhooks", {})} + reachable: set[str] = set() + find_refs(roots, reachable) + changed = True + while changed: + changed = False + for name in list(reachable): + s = schemas.get(name) + if s is None: + continue + found: set[str] = set() + find_refs(s, found) + for n in found: + if n not in reachable: + reachable.add(n) + changed = True + return reachable + + +def get_schema(spec: dict, name: str) -> Optional[dict]: + return spec.get("components", {}).get("schemas", {}).get(name) + + +def resolve_path(schema_obj: dict, path: Optional[str]) -> Optional[dict]: + node = schema_obj + if not path: + return node + for part in path.split("."): + props = node.get("properties", {}) if isinstance(node, dict) else {} + node = props.get(part) + if node is None: + return None + return node + + +def get_properties(node: Optional[dict]) -> dict[str, dict]: + if not node: + return {} + return node.get("properties", {}) + + +def get_required(node: Optional[dict]) -> set[str]: + if not node: + return set() + return set(node.get("required", [])) + + +def get_enum_values(spec: dict, locator: dict) -> Optional[set[str]]: + schema_obj = get_schema(spec, locator["schema"]) + if schema_obj is None: + return None + prop = resolve_path(schema_obj, None) + prop = get_properties(prop).get(locator["property"]) + if prop is None: + return None + if locator.get("items"): + prop = prop.get("items", {}) + enum = prop.get("enum") + if enum is None: + return None + return set(enum) + + +def coarse_type(prop_schema: dict) -> tuple[Optional[str], bool]: + """Returns (single non-null JSON type or None if ambiguous/multi-type, nullable).""" + t = prop_schema.get("type") + if isinstance(t, list): + types = [x for x in t if x != "null"] + nullable = "null" in t + elif isinstance(t, str): + types = [t] + nullable = False + else: + types = [] + nullable = False + if len(types) != 1: + return None, nullable + return types[0], nullable + + +# --------------------------------------------------------------------------- # +# SDK (Python source) introspection via ast +# --------------------------------------------------------------------------- # + + +@dataclass +class ClassInfo: + file: Path + node: ast.ClassDef + source: str + bases: list[str] = field(default_factory=list) + total_false: bool = False + own_fields: dict[str, ast.AnnAssign] = field(default_factory=dict) + + +@dataclass +class SdkIndex: + # every symbol name may legitimately have more than one definition across files + # (e.g. OfframpWallet is intentionally duplicated, PaymentMethod is shadowed + # locally in payins/quotes.py) -- always disambiguate by file, never by + # "first one found". + all_classes: dict[str, list[ClassInfo]] + all_literals: dict[str, list[tuple[Path, ast.Assign, str]]] + sources: dict[Path, str] + + def find_class(self, symbol: str, file_rel: str) -> Optional[ClassInfo]: + for info in self.all_classes.get(symbol, []): + if str(info.file.relative_to(ROOT)) == file_rel: + return info + return None + + def find_literal(self, symbol: str, file_rel: str) -> Optional[tuple[Path, ast.Assign, str]]: + for entry in self.all_literals.get(symbol, []): + if str(entry[0].relative_to(ROOT)) == file_rel: + return entry + return None + + +def _base_names(node: ast.ClassDef) -> list[str]: + names = [b.id for b in node.bases if isinstance(b, ast.Name)] + names += [b.attr for b in node.bases if isinstance(b, ast.Attribute)] + return names + + +def _is_total_false(node: ast.ClassDef) -> bool: + for kw in node.keywords: + if kw.arg == "total" and isinstance(kw.value, ast.Constant) and kw.value.value is False: + return True + return False + + +def build_sdk_index(src_root: Path = SRC_ROOT) -> SdkIndex: + py_files = sorted(src_root.rglob("*.py")) + trees: dict[Path, ast.Module] = {} + sources: dict[Path, str] = {} + for f in py_files: + text = f.read_text() + sources[f] = text + trees[f] = ast.parse(text, filename=str(f)) + + known_typeddict = {"TypedDict"} + for _ in range(5): + changed = False + for tree in trees.values(): + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef) or node.name in known_typeddict: + continue + if any(b in known_typeddict for b in _base_names(node)): + known_typeddict.add(node.name) + changed = True + if not changed: + break + + all_classes: dict[str, list[ClassInfo]] = {} + for f, tree in trees.items(): + for node in ast.walk(tree): + if not (isinstance(node, ast.ClassDef) and node.name in known_typeddict): + continue + own_fields = {} + for item in node.body: + if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name): + own_fields[item.target.id] = item + info = ClassInfo( + file=f, + node=node, + source=sources[f], + bases=_base_names(node), + total_false=_is_total_false(node), + own_fields=own_fields, + ) + all_classes.setdefault(node.name, []).append(info) + + all_literals: dict[str, list[tuple[Path, ast.Assign, str]]] = {} + for f, tree in trees.items(): + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + if len(node.targets) != 1 or not isinstance(node.targets[0], ast.Name): + continue + if not isinstance(node.value, ast.Subscript): + continue + base = node.value.value + base_name = ( + base.id if isinstance(base, ast.Name) else (base.attr if isinstance(base, ast.Attribute) else None) + ) + if base_name != "Literal": + continue + name = node.targets[0].id + all_literals.setdefault(name, []).append((f, node, sources[f])) + + return SdkIndex(all_classes=all_classes, all_literals=all_literals, sources=sources) + + +def _pick_unambiguous(file_rel: Optional[str], candidates: list) -> Optional[Any]: + if file_rel is not None: + for c in candidates: + if str(c[0].relative_to(ROOT) if isinstance(c, tuple) else c.file.relative_to(ROOT)) == file_rel: + return c + return None + return candidates[0] if len(candidates) == 1 else None + + +def resolve_typeddict_fields( + symbol: str, file_rel: Optional[str], index: SdkIndex, seen: Optional[set[str]] = None +) -> set[str]: + """Full field set, transitively resolving locally-defined TypedDict base classes + (the `_XRequired` / total=False two-part pattern). `file_rel` disambiguates + symbols legitimately defined in more than one file (e.g. OfframpWallet).""" + seen = seen or set() + key = f"{file_rel}::{symbol}" + if key in seen: + return set() + seen.add(key) + info = _pick_unambiguous(file_rel, index.all_classes.get(symbol, [])) + if info is None: + return set() + fields = set(info.own_fields.keys()) + for base in info.bases: + # base classes are always local to the same file + fields |= resolve_typeddict_fields(base, str(info.file.relative_to(ROOT)), index, seen) + return fields + + +def literal_values(symbol: str, file_rel: str, index: SdkIndex) -> set[str]: + entry = index.find_literal(symbol, file_rel) + if entry is None: + return set() + _, node, _ = entry + values: set[str] = set() + for const in ast.walk(node.value): + if isinstance(const, ast.Constant) and isinstance(const.value, str): + values.add(const.value) + return values + + +# --------------------------------------------------------------------------- # +# Map validity +# --------------------------------------------------------------------------- # + + +def validate_map(map_data: dict, index: SdkIndex) -> list[str]: + errors: list[str] = [] + + def check_sdk_site(site: dict, ctx: str) -> None: + rel = site["file"] + symbol = site["symbol"] + f = ROOT / rel + if not f.exists(): + errors.append(f"{ctx}: file not found: {rel}") + return + class_candidates = index.all_classes.get(symbol, []) + literal_candidates = index.all_literals.get(symbol, []) + if not class_candidates and not literal_candidates: + errors.append(f"{ctx}: symbol `{symbol}` not found anywhere under src/blindpay") + return + if index.find_class(symbol, rel) is None and index.find_literal(symbol, rel) is None: + found_in = sorted( + {str(c.file.relative_to(ROOT)) for c in class_candidates} + | {str(c[0].relative_to(ROOT)) for c in literal_candidates} + ) + errors.append(f"{ctx}: symbol `{symbol}` not found in {rel} (found in {found_in})") + + for i, e in enumerate(map_data.get("enums", [])): + ctx = f"enums[{i}] ({e['spec']['schema']}.{e['spec']['property']})" + check_sdk_site(e["sdk"], ctx) + + for i, e in enumerate(map_data.get("types", [])): + spec = e["spec"] + names = spec if isinstance(spec, list) else [spec] + ctx_base = "/".join(names) + (f"#{e['specPath']}" if e.get("specPath") else "") + for j, site in enumerate(e["sdk"]): + check_sdk_site(site, f"types[{i}] ({ctx_base})[{j}]") + + return errors + + +# --------------------------------------------------------------------------- # +# Reconciliation (state-based, against a single spec) +# --------------------------------------------------------------------------- # + + +@dataclass +class EnumGap: + symbol: str + file: str + locators: list[dict] + missing: list[str] # unaccounted (not in unmodeled.json) + + +@dataclass +class PropertyGap: + schema_names: list[str] + path: Optional[str] + sdk_sites: list[dict] + missing: dict[str, dict] # field name -> its spec property schema + + +def unmodeled_enum_allowed(unmodeled: list[dict], enum_symbol: str) -> set[str]: + allowed: set[str] = set() + for e in unmodeled: + if e["kind"] == "enum" and e["enum"] == enum_symbol: + allowed.update(e["missing_values"]) + return allowed + + +def unmodeled_property_allowed(unmodeled: list[dict], schema_names: list[str], path: Optional[str]) -> set[str]: + allowed: set[str] = set() + for e in unmodeled: + if e["kind"] != "property": + continue + if e["schema"] not in schema_names: + continue + if e.get("path") != path: + continue + allowed.add(e["field"]) + return allowed + + +def reconcile_enums(spec: dict, map_data: dict, unmodeled: list[dict], index: SdkIndex) -> list[EnumGap]: + gaps: list[EnumGap] = [] + # group by (file, symbol): the same symbol name can legitimately denote two + # different Literals in two different files (e.g. PaymentMethod), and must + # never be merged. + by_site: dict[tuple[str, str], list[dict]] = {} + for e in map_data.get("enums", []): + key = (e["sdk"]["file"], e["sdk"]["symbol"]) + by_site.setdefault(key, []).append(e) + + for (file_rel, symbol), entries in sorted(by_site.items()): + spec_members: set[str] = set() + for e in entries: + vals = get_enum_values(spec, e["spec"]) + if vals: + spec_members |= vals + sdk_members = literal_values(symbol, file_rel, index) + missing = spec_members - sdk_members + allowed = unmodeled_enum_allowed(unmodeled, symbol) + unaccounted = sorted(missing - allowed) + if unaccounted: + gaps.append( + EnumGap(symbol=symbol, file=file_rel, locators=[e["spec"] for e in entries], missing=unaccounted) + ) + return gaps + + +def reconcile_types(spec: dict, map_data: dict, unmodeled: list[dict], index: SdkIndex) -> list[PropertyGap]: + gaps: list[PropertyGap] = [] + reachable = compute_reachable_schemas(spec) + + for e in map_data.get("types", []): + spec_names = e["spec"] if isinstance(e["spec"], list) else [e["spec"]] + path = e.get("specPath") + spec_props: dict[str, dict] = {} + any_reachable = False + for name in spec_names: + schema_obj = get_schema(spec, name) + if schema_obj is None: + continue + if name not in reachable: + continue + any_reachable = True + node = resolve_path(schema_obj, path) + spec_props.update(get_properties(node)) + if not any_reachable: + continue + + sdk_fields: set[str] = set() + for site in e["sdk"]: + sdk_fields |= resolve_typeddict_fields(site["symbol"], site["file"], index) + + missing_names = set(spec_props.keys()) - sdk_fields + allowed = unmodeled_property_allowed(unmodeled, spec_names, path) + unaccounted = sorted(missing_names - allowed) + if unaccounted: + gaps.append( + PropertyGap( + schema_names=spec_names, + path=path, + sdk_sites=e["sdk"], + missing={n: spec_props[n] for n in unaccounted}, + ) + ) + return gaps + + +# --------------------------------------------------------------------------- # +# Old-vs-new diff (apply-only): removals, required/type changes, new operations/schemas +# --------------------------------------------------------------------------- # + + +@dataclass +class NeedsHuman: + kind: str + detail: str + + +def diff_removals_and_changes(old_spec: dict, new_spec: dict, map_data: dict, index: SdkIndex) -> list[NeedsHuman]: + problems: list[NeedsHuman] = [] + + # enums: sdk-modeled member disappearing from the new spec + by_site: dict[tuple[str, str], list[dict]] = {} + for e in map_data.get("enums", []): + by_site.setdefault((e["sdk"]["file"], e["sdk"]["symbol"]), []).append(e) + for (file_rel, symbol), entries in sorted(by_site.items()): + sdk_members = literal_values(symbol, file_rel, index) + new_members: set[str] = set() + for e in entries: + vals = get_enum_values(new_spec, e["spec"]) + if vals: + new_members |= vals + old_members: set[str] = set() + for e in entries: + vals = get_enum_values(old_spec, e["spec"]) + if vals: + old_members |= vals + removed = (sdk_members & old_members) - new_members + if removed: + problems.append(NeedsHuman("enum_member_removed", f"{symbol}: {sorted(removed)} no longer in the spec")) + + old_reachable = compute_reachable_schemas(old_spec) + new_reachable = compute_reachable_schemas(new_spec) + + for e in map_data.get("types", []): + spec_names = e["spec"] if isinstance(e["spec"], list) else [e["spec"]] + path = e.get("specPath") + sdk_fields: set[str] = set() + for site in e["sdk"]: + sdk_fields |= resolve_typeddict_fields(site["symbol"], site["file"], index) + + for name in spec_names: + if name in old_reachable and name not in new_reachable: + problems.append(NeedsHuman("schema_removed", f"{name} no longer reachable in the new spec")) + continue + old_obj = get_schema(old_spec, name) + new_obj = get_schema(new_spec, name) + if old_obj is None or new_obj is None: + continue + old_node = resolve_path(old_obj, path) + new_node = resolve_path(new_obj, path) + old_props = get_properties(old_node) + new_props = get_properties(new_node) + old_required = get_required(old_node) + new_required = get_required(new_node) + + removed_fields = (sdk_fields & set(old_props.keys())) - set(new_props.keys()) + for f in sorted(removed_fields): + problems.append( + NeedsHuman( + "property_removed", + f"{name}{'/' + path if path else ''}.{f} modeled by the SDK, no longer in the spec", + ) + ) + + for f in sorted(sdk_fields & set(old_props.keys()) & set(new_props.keys())): + was_required = f in old_required + is_required = f in new_required + if was_required != is_required: + problems.append( + NeedsHuman( + "required_change", + f"{name}{'/' + path if path else ''}.{f} required-ness changed " + f"({was_required} -> {is_required})", + ) + ) + continue + old_type, old_nullable = coarse_type(old_props[f]) + new_type, new_nullable = coarse_type(new_props[f]) + # Ambiguous on either side (multi-type, or no "type" key at all -- + # e.g. a property that only ever had "example"/"description") is + # deliberately treated as compatible, not needs-human: this is the + # real, observed, benign shape of this spec's own evolution + # (created_at/updated_at gaining an explicit + # {"type": ["string","null"], "format": "date-time"} where they + # previously had no "type" key at all). There is nothing to + # meaningfully compare when one side never declared a concrete type. + if old_type is not None and new_type is not None: + if old_type != new_type: + problems.append( + NeedsHuman( + "type_change", + f"{name}{'/' + path if path else ''}.{f} type changed ({old_type} -> {new_type})", + ) + ) + elif old_nullable != new_nullable: + problems.append( + NeedsHuman( + "type_change", + f"{name}{'/' + path if path else ''}.{f} nullability changed " + f"(nullable={old_nullable} -> nullable={new_nullable})", + ) + ) + + # new operations (paths present in new, absent from old) + old_paths = set(old_spec.get("paths", {}).keys()) + new_paths = set(new_spec.get("paths", {}).keys()) + for p in sorted(new_paths - old_paths): + problems.append(NeedsHuman("new_operation", f"new path: {p}")) + + # new reachable schemas not present before + mapped_or_ignored: set[str] = set() + for e in map_data.get("types", []): + spec_names = e["spec"] if isinstance(e["spec"], list) else [e["spec"]] + mapped_or_ignored.update(spec_names) + mapped_or_ignored.update(x["schema"] for x in map_data.get("ignore", {}).get("schemas", [])) + for name in sorted(new_reachable - old_reachable): + if name not in mapped_or_ignored: + problems.append(NeedsHuman("new_schema", f"new reachable schema: {name}")) + + # properties added on an entirely unmapped (and not ignored) reachable schema + schemas = new_spec.get("components", {}).get("schemas", {}) + for name in sorted(new_reachable): + if name in mapped_or_ignored: + continue + old_obj = get_schema(old_spec, name) + new_obj = schemas.get(name) + old_props = set(get_properties(old_obj).keys()) if old_obj else set() + new_props = set(get_properties(new_obj).keys()) + added = new_props - old_props + if added: + problems.append( + NeedsHuman("property_on_unmapped_schema", f"{name}: {sorted(added)} (schema has no spec-map entry)") + ) + + return problems + + +# --------------------------------------------------------------------------- # +# Applying changes (text splicing) +# --------------------------------------------------------------------------- # + + +@dataclass +class AppliedChange: + kind: str # "enum" | "property" + file: str + symbol: str + detail: str + + +def splice_literal_add_members(source: str, node: ast.Assign, new_values: list[str]) -> str: + lines = source.splitlines(keepends=True) + subscript = node.value + assert isinstance(subscript, ast.Subscript) + sl = subscript.slice + elts = sl.elts if isinstance(sl, ast.Tuple) else [sl] + last = elts[-1] + + close_lineno = subscript.end_lineno + last_end_line = last.end_lineno + last_end_col = last.end_col_offset + + if close_lineno == last_end_line: + line = lines[last_end_line - 1] + insertion = "".join(f', "{v}"' for v in new_values) + lines[last_end_line - 1] = line[:last_end_col] + insertion + line[last_end_col:] + else: + last_line = lines[last_end_line - 1] + stripped_no_nl = last_line[:-1] if last_line.endswith("\n") else last_line + indent = last_line[: len(last_line) - len(last_line.lstrip())] + if not stripped_no_nl.rstrip().endswith(","): + nl = "\n" if last_line.endswith("\n") else "" + lines[last_end_line - 1] = stripped_no_nl + "," + nl + insert_lines = [f'{indent}"{v}",\n' for v in new_values] + lines[last_end_line:last_end_line] = insert_lines + return "".join(lines) + + +def apply_enum_change(gap: EnumGap, index: SdkIndex) -> AppliedChange: + entry = index.find_literal(gap.symbol, gap.file) + if entry is None: + raise SystemExit(f"internal error: lost track of literal {gap.symbol} in {gap.file}") + file_path, node, source = entry + new_source = splice_literal_add_members(source, node, sorted(gap.missing)) + file_path.write_text(new_source) + return AppliedChange( + kind="enum", + file=str(file_path.relative_to(ROOT)), + symbol=gap.symbol, + detail=f"added member(s) {sorted(gap.missing)}", + ) + + +def infer_annotation(prop_schema: dict) -> Optional[str]: + if "enum" in prop_schema: + return None # would need a Literal/mapped enum symbol; not auto-resolved + if prop_schema.get("type") == "array" or ( + isinstance(prop_schema.get("type"), list) and "array" in prop_schema["type"] + ): + return None + t0, _nullable = coarse_type(prop_schema) + if t0 is None: + return None + return SCALAR_TYPE_MAP.get(t0) + + +def choose_field_annotation(info: ClassInfo, python_type: str, nullable: bool) -> str: + """A newly ADDED optional spec property must never turn into a required + dict key. Whether that needs an explicit NotRequired[...] wrapper depends + entirely on the target class's totality, not on sibling style: + - total=False (a class declared `total=False`, or the non-required half + of the `_XRequired` two-part pattern -- `info` is always the class we + insert into, which for that pattern already IS the total=False side): + every key is already optional, so a bare `T` / `Optional[T]` is correct + and sufficient. + - total=True (the default, e.g. `class CreateQuoteInput(TypedDict):`): + every key is structurally REQUIRED regardless of whether its value type + happens to be Optional[...]. Adding a bare `Optional[T]` field here + would make every existing caller that omits the new key fail pyright + and mypy -- a breaking change to an already-published TypedDict. This + always needs `NotRequired[...]`, independent of whether any sibling + field already uses that convention. + """ + inner = f"Optional[{python_type}]" if nullable else python_type + if info.total_false: + return inner + return f"NotRequired[{inner}]" + + +def splice_typeddict_add_field(source: str, class_node: ast.ClassDef, field_name: str, annotation_text: str) -> str: + lines = source.splitlines(keepends=True) + last_stmt = class_node.body[-1] + last_line_no = last_stmt.end_lineno + last_line = lines[last_line_no - 1] + indent = last_line[: len(last_line) - len(last_line.lstrip())] + new_line = f"{indent}{field_name}: {annotation_text}\n" + lines.insert(last_line_no, new_line) + return "".join(lines) + + +def ensure_name_imported( + source: str, name: str, candidate_modules: tuple[str, ...] = ("typing", "typing_extensions") +) -> str: + """Add `name` to whichever of `candidate_modules` this file already imports + TypedDict from (matching that file's own typing vs typing_extensions + convention), if it is not already imported from either. A no-op if `name` + is already present. Assumes a single-line, non-aliased import statement, + which is what every TypedDict-bearing file in this repo currently uses.""" + tree = ast.parse(source) + target: Optional[ast.ImportFrom] = None + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module in candidate_modules: + names = [a.name for a in node.names] + if name in names: + return source + if "TypedDict" in names: + target = node + if target is None: + return source + + names = sorted({a.name for a in target.names} | {name}) + new_line = f"from {target.module} import {', '.join(names)}" + lines = source.splitlines(keepends=True) + start, end = target.lineno, target.end_lineno + assert end is not None + trailing = "\n" if lines[end - 1].endswith("\n") else "" + lines[start - 1 : end] = [new_line + trailing] + return "".join(lines) + + +def apply_property_change(gap: PropertyGap, index: SdkIndex) -> tuple[list[AppliedChange], list[NeedsHuman]]: + applied: list[AppliedChange] = [] + needs_human: list[NeedsHuman] = [] + + if len(gap.sdk_sites) > 1: + ctx = "/".join(gap.schema_names) + (f"#{gap.path}" if gap.path else "") + needs_human.append( + NeedsHuman( + "fan_out_target_ambiguous", + f"{ctx}: new propert{'y' if len(gap.missing) == 1 else 'ies'} {sorted(gap.missing)} on a " + f"{len(gap.sdk_sites)}-variant fan-out; a human must decide which variant(s) legitimately " + f"carry it (or opt the map entry into uniform fan-out application).", + ) + ) + return applied, needs_human + + site = gap.sdk_sites[0] + symbol = site["symbol"] + info = index.find_class(symbol, site["file"]) + if info is None: + needs_human.append(NeedsHuman("unresolved_anchor", f"{symbol} not found in {site['file']}")) + return applied, needs_human + + for name in sorted(gap.missing): + prop_schema = gap.missing[name] + python_type = infer_annotation(prop_schema) + if python_type is None: + ctx = "/".join(gap.schema_names) + (f"#{gap.path}" if gap.path else "") + needs_human.append( + NeedsHuman( + "type_unresolvable", + f"{ctx}.{name}: cannot safely infer a Python type from the spec schema for {symbol}", + ) + ) + continue + _, nullable = coarse_type(prop_schema) + annotation = choose_field_annotation(info, python_type, nullable) + source = info.source + for required_name in ("NotRequired", "Optional"): + if f"{required_name}[" in annotation: + updated = ensure_name_imported(source, required_name) + if updated != source: + source = updated + info.file.write_text(source) + info.node = _reparse_class(info.file, symbol) + new_source = splice_typeddict_add_field(source, info.node, name, annotation) + info.file.write_text(new_source) + # re-read so subsequent insertions into the same class see updated positions + info.source = new_source + info.node = _reparse_class(info.file, symbol) + applied.append( + AppliedChange( + kind="property", + file=str(info.file.relative_to(ROOT)), + symbol=symbol, + detail=f"added field `{name}: {annotation}`", + ) + ) + return applied, needs_human + + +def _reparse_class(file_path: Path, symbol: str) -> ast.ClassDef: + tree = ast.parse(file_path.read_text(), filename=str(file_path)) + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and node.name == symbol: + return node + raise SystemExit(f"internal error: lost track of class {symbol} in {file_path} after edit") + + +# --------------------------------------------------------------------------- # +# Coverage report (non-blocking) +# --------------------------------------------------------------------------- # + + +def coverage_report(spec: dict, map_data: dict) -> list[dict]: + reachable = compute_reachable_schemas(spec) + ignore_reasons = {e["schema"]: e["reason"] for e in map_data.get("ignore", {}).get("schemas", [])} + mapped_names: set[str] = set() + for e in map_data.get("types", []): + spec_names = e["spec"] if isinstance(e["spec"], list) else [e["spec"]] + mapped_names.update(spec_names) + + gaps = [] + for p, item in sorted(spec.get("paths", {}).items()): + if not isinstance(item, dict): + continue + for method, op in sorted(item.items()): + if method not in ("get", "post", "put", "patch", "delete") or not isinstance(op, dict): + continue + # only the request body and SUCCESS (2xx) responses are relevant here; + # 4xx/5xx responses almost all reference the shared Error schema and + # would otherwise drown every operation in a false "gap". + names: set[str] = set() + rb = op.get("requestBody", {}).get("content", {}) + for c in rb.values(): + r = c.get("schema", {}).get("$ref") + if r: + names.add(r.split("/")[-1]) + responses = op.get("responses", {}) + for status, resp in responses.items(): + if not isinstance(resp, dict) or not status.startswith("2"): + continue + r = resp.get("content", {}).get("application/json", {}).get("schema", {}).get("$ref") + if r: + names.add(r.split("/")[-1]) + names &= reachable + ignored = sorted(n for n in names if n in ignore_reasons) + if ignored and not (names - set(ignore_reasons)): + gaps.append( + { + "method": method.upper(), + "path": p, + "schemas": ignored, + "reason": ignore_reasons[ignored[0]], + } + ) + return gaps + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # + + +def cmd_validate_map() -> int: + index = build_sdk_index(SRC_ROOT) + map_data = load_map() + errors = validate_map(map_data, index) + if errors: + print("Map validity -- FAILED:") + for e in sorted(errors): + print(f" {e}") + return 1 + print("Map validity: OK") + return 0 + + +def cmd_check(report_path: Optional[Path]) -> int: + index = build_sdk_index(SRC_ROOT) + map_data = load_map() + unmodeled = load_unmodeled() + + map_errors = validate_map(map_data, index) + spec = load_json(SNAPSHOT_PATH) + enum_gaps = reconcile_enums(spec, map_data, unmodeled, index) if not map_errors else [] + prop_gaps = reconcile_types(spec, map_data, unmodeled, index) if not map_errors else [] + + report = { + "mode": "check", + "map_errors": sorted(map_errors), + "enum_gaps": [{"symbol": g.symbol, "file": g.file, "missing": g.missing} for g in enum_gaps], + "property_gaps": [ + {"schema": g.schema_names, "path": g.path, "missing": sorted(g.missing.keys())} for g in prop_gaps + ], + } + if report_path: + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") + + if not (map_errors or enum_gaps or prop_gaps): + return 0 + + if map_errors: + print("spec-map.json validity -- FAILED:") + for e in sorted(map_errors): + print(f" {e}") + for g in enum_gaps: + print( + f"PENDING DRIFT (enum): {g.symbol} in {g.file} is missing {g.missing}; add to the Literal " + f"or record in .api-sync/unmodeled.json (kind=enum) with a reason and owner." + ) + for g in prop_gaps: + ctx = "/".join(g.schema_names) + (f"#{g.path}" if g.path else "") + print( + f"PENDING DRIFT (property): {ctx} is missing {sorted(g.missing.keys())} on " + f"{[s['symbol'] for s in g.sdk_sites]}; add the field(s) or record in " + f".api-sync/unmodeled.json (kind=property) with a reason and owner." + ) + return 1 + + +def cmd_apply(spec_path: Path, report_path: Optional[Path]) -> int: + index = build_sdk_index(SRC_ROOT) + map_data = load_map() + unmodeled = load_unmodeled() + + map_errors = validate_map(map_data, index) + if map_errors: + print("spec-map.json validity -- FAILED, refusing to apply:") + for e in sorted(map_errors): + print(f" {e}") + return 1 + + old_spec = load_json(SNAPSHOT_PATH) + new_spec = load_json(spec_path) + + needs_human = diff_removals_and_changes(old_spec, new_spec, map_data, index) + + enum_gaps = reconcile_enums(new_spec, map_data, unmodeled, index) + prop_gaps = reconcile_types(new_spec, map_data, unmodeled, index) + + applied: list[AppliedChange] = [] + + if not needs_human: + for g in sorted(enum_gaps, key=lambda g: g.symbol): + applied.append(apply_enum_change(g, index)) + # re-check property gaps against a fresh index is unnecessary: enum edits never touch TypedDicts + for g in sorted(prop_gaps, key=lambda g: ("/".join(g.schema_names), g.path or "")): + a, nh = apply_property_change(g, index) + applied.extend(a) + needs_human.extend(nh) + + bump: Optional[str] = None + if not needs_human: + if any(a.kind == "enum" for a in applied): + bump = "minor" + elif applied: + bump = "patch" + + report = { + "mode": "apply", + "spec": str(spec_path), + "applied": [{"kind": a.kind, "file": a.file, "symbol": a.symbol, "detail": a.detail} for a in applied], + "needs_human": [ + {"kind": n.kind, "detail": n.detail} for n in sorted(needs_human, key=lambda n: (n.kind, n.detail)) + ], + "bump": bump, + "coverage_gaps": coverage_report(new_spec, map_data), + } + if report_path: + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") + + if needs_human: + print("NEEDS_HUMAN -- refusing to apply:") + for n in report["needs_human"]: + print(f" [{n['kind']}] {n['detail']}") + return 1 + + if applied: + # Copy the delivered spec's exact bytes rather than round-tripping through + # json.dumps: re-serializing would reformat/reorder the *entire* file on + # every apply (noise unrelated to the actual drift) and is not needed for + # determinism -- the upstream filter already produces stable formatting. + SNAPSHOT_PATH.write_bytes(spec_path.read_bytes()) + print(f"Applied {len(applied)} change(s); bump={bump}") + for a in applied: + print(f" [{a.kind}] {a.file}: {a.detail}") + else: + print("No changes.") + return 0 + + +def cmd_coverage(spec_path: Path) -> int: + map_data = load_map() + spec = load_json(spec_path) if spec_path.exists() else load_json(SNAPSHOT_PATH) + gaps = coverage_report(spec, map_data) + if not gaps: + print("Coverage: no known gaps.") + return 0 + print(f"Coverage report ({len(gaps)} known gap(s), non-blocking):") + for g in gaps: + print(f" {g['method']} {g['path']} ({', '.join(g['schemas'])}): {g['reason']}") + return 0 + + +# --------------------------------------------------------------------------- # +# Type audit (non-blocking, informational): does the SDK's CURRENT annotation +# for every mapped property still match the spec's CURRENT declared type, +# independent of any old-vs-new diff? diff_removals_and_changes only ever +# catches drift going forward from a synced baseline; it cannot see a +# pre-existing latent mismatch that was already there when a field was first +# modeled. This reuses the same primitives (coarse_type, SCALAR_TYPE_MAP) in a +# state comparison instead of a diff, mirroring how reconcile_* replaced +# event-diffing with state reconciliation for presence. +# --------------------------------------------------------------------------- # + + +def collect_annotations( + symbol: str, file_rel: str, index: SdkIndex, seen: Optional[set[str]] = None +) -> dict[str, tuple[ast.expr, str]]: + """field name -> (annotation node, source text) across the class and any + locally-defined base (the `_XRequired` two-part pattern).""" + seen = seen or set() + key = f"{file_rel}::{symbol}" + if key in seen: + return {} + seen.add(key) + info = index.find_class(symbol, file_rel) + if info is None: + return {} + result: dict[str, tuple[ast.expr, str]] = {} + for base in info.bases: + result.update(collect_annotations(base, str(info.file.relative_to(ROOT)), index, seen)) + for name, node in info.own_fields.items(): + result[name] = (node.annotation, info.source) + return result + + +def _strip_wrappers(ann_text: str) -> tuple[str, bool]: + """Returns (innermost type text, was_optional).""" + text = ann_text.strip() + if text.startswith("NotRequired[") and text.endswith("]"): + text = text[len("NotRequired[") : -1].strip() + is_optional = False + if text.startswith("Optional[") and text.endswith("]"): + is_optional = True + text = text[len("Optional[") : -1].strip() + return text, is_optional + + +def audit_property_type(prop_schema: dict, ann_text: str) -> Optional[str]: + """A property with no direct SDK counterpart is not this function's + concern (that's reconcile_types'). Only flags cases where the SDK + annotation is NARROWER or otherwise structurally wrong versus the spec -- + a SDK type that is deliberately wider than necessary (e.g. Optional[str] + for a non-nullable property, or float for a spec integer) is a legitimate, + common modeling choice, not a bug, and is not reported.""" + inner, is_optional = _strip_wrappers(ann_text) + base_type, nullable = coarse_type(prop_schema) + + if nullable and not is_optional: + return f"spec is nullable but SDK annotation `{ann_text}` has no Optional[...]" + + if "enum" in prop_schema: + if inner in SCALAR_TYPE_MAP.values(): + return f"spec property is enum-constrained but SDK annotation is a bare `{inner}` (no Literal)" + return None # trust some Literal/mapped-symbol reference; membership is reconcile_enums's job + + if base_type is None: + return None # ambiguous on the spec side (multi-type, or no "type" key) -- nothing to compare + if base_type == "array": + return None if inner.startswith(("List[", "list[")) else f"spec type is array but SDK annotation is `{inner}`" + if base_type == "object": + if inner in SCALAR_TYPE_MAP.values(): + return f"spec type is object but SDK annotation is a bare scalar `{inner}`" + return None # assume a nested TypedDict reference; not verifying its own shape here + + expected = SCALAR_TYPE_MAP.get(base_type) + if expected is None or inner == expected: + return None + if base_type == "integer" and inner == "float": + return None # deliberately compatible widening + return f"spec type `{base_type}` (expected `{expected}`) but SDK annotation is `{inner}`" + + +def audit_types(spec: dict, map_data: dict) -> list[dict[str, str]]: + """One map entry can list several spec locators that are asserted to share + the same shape (e.g. tracking_payment duplicated inline across 6 payout + schemas) -- audit against a single representative locator, not once per + duplicate, or the same real finding is reported N times over.""" + index = build_sdk_index(SRC_ROOT) + reachable = compute_reachable_schemas(spec) + findings: list[dict[str, str]] = [] + + for e in map_data.get("types", []): + spec_names = e["spec"] if isinstance(e["spec"], list) else [e["spec"]] + path = e.get("specPath") + reachable_names = sorted(n for n in spec_names if n in reachable) + if not reachable_names: + continue + representative = reachable_names[0] + schema_obj = get_schema(spec, representative) + if schema_obj is None: + continue + schema_label = ( + representative + if len(reachable_names) == 1 + else f"{representative} (+{len(reachable_names) - 1} shared locator(s))" + ) + props = get_properties(resolve_path(schema_obj, path)) + for site in e["sdk"]: + annotations = collect_annotations(site["symbol"], site["file"], index) + for field_name in sorted(props.keys()): + if field_name not in annotations: + continue + ann_node, source = annotations[field_name] + ann_text = ast.get_source_segment(source, ann_node) or "" + note = audit_property_type(props[field_name], ann_text) + if note: + findings.append( + { + "schema": schema_label, + "path": path or "", + "field": field_name, + "sdk_file": site["file"], + "sdk_symbol": site["symbol"], + "sdk_annotation": ann_text, + "note": note, + } + ) + + # a single SDK field (same file+symbol+field+annotation) can legitimately + # get evaluated from more than one map entry -- e.g. TrackingPayment is + # the target of both a payout-side and a payin-side entry. Collapse those + # into one finding that names every schema it was seen from, rather than + # reporting what is really the same annotation issue multiple times. + merged: dict[tuple[str, str, str, str], dict[str, str]] = {} + for f in findings: + key = (f["sdk_file"], f["sdk_symbol"], f["field"], f["note"]) + if key in merged: + if f["schema"] not in merged[key]["schema"]: + merged[key]["schema"] += f", {f['schema']}" + else: + merged[key] = dict(f) + result = list(merged.values()) + result.sort(key=lambda f: (f["sdk_file"], f["sdk_symbol"], f["field"])) + return result + + +def cmd_audit_types(spec_path: Path) -> int: + map_data = load_map() + spec = load_json(spec_path) if spec_path.exists() else load_json(SNAPSHOT_PATH) + findings = audit_types(spec, map_data) + if not findings: + print("Type audit: no mismatches found.") + return 0 + print(f"Type audit ({len(findings)} finding(s), non-blocking, informational only):") + for f in findings: + ctx = f["schema"] + (f"/{f['path']}" if f["path"] else "") + site = f"{f['sdk_symbol']} in {f['sdk_file']}" + print(f" {ctx}.{f['field']} ({site}): {f['note']} [annotation: {f['sdk_annotation']}]") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--check", action="store_true") + mode.add_argument("--apply", action="store_true") + mode.add_argument("--validate-map", action="store_true") + mode.add_argument("--coverage", action="store_true") + mode.add_argument("--audit-types", action="store_true") + parser.add_argument("--spec", type=Path, default=DEFAULT_SPEC_PATH) + parser.add_argument("--report", type=Path, default=None) + args = parser.parse_args() + + if args.validate_map: + return cmd_validate_map() + if args.check: + return cmd_check(args.report) + if args.apply: + return cmd_apply(args.spec, args.report) + if args.coverage: + return cmd_coverage(args.spec) + if args.audit_types: + return cmd_audit_types(args.spec) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.api-sync/unmodeled.json b/.api-sync/unmodeled.json new file mode 100644 index 0000000..ca4ff5c --- /dev/null +++ b/.api-sync/unmodeled.json @@ -0,0 +1,1043 @@ +[ + { + "kind": "property", + "schema": "BankAccountOut", + "field": "business_industry", + "reason": "Same gap as BankAccountOut.recipient_relationship above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "date_of_birth", + "reason": "Same gap as BankAccountOut.recipient_relationship above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "phone_number", + "reason": "Same gap as BankAccountOut.recipient_relationship above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "pix_safe_bank_code", + "reason": "The general list()/get() bank-account shape does not expose Pix Safe rail fields; only CreatePixSafeResponse (a different, rail-specific SDK type outside this mapping) does.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "pix_safe_branch_code", + "reason": "Same gap as BankAccountOut.pix_safe_bank_code above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "pix_safe_cpf_cnpj", + "reason": "Same gap as BankAccountOut.pix_safe_bank_code above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "recipient_relationship", + "reason": "General metadata field not modeled on the list()/get() bank-account shape.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "sepa_beneficiary_address_line_1", + "reason": "Same gap as BankAccountOut.sepa_iban above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "sepa_beneficiary_address_line_2", + "reason": "Same gap as BankAccountOut.sepa_iban above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "sepa_beneficiary_city", + "reason": "Same gap as BankAccountOut.sepa_iban above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "sepa_beneficiary_country", + "reason": "Same gap as BankAccountOut.sepa_iban above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "sepa_beneficiary_legal_name", + "reason": "Same gap as BankAccountOut.sepa_iban above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "sepa_beneficiary_postal_code", + "reason": "Same gap as BankAccountOut.sepa_iban above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "sepa_beneficiary_state_province_region", + "reason": "Same gap as BankAccountOut.sepa_iban above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "sepa_iban", + "reason": "The general list()/get() bank-account shape does not expose SEPA rail fields; only CreateSepaResponse (a different, rail-specific SDK type outside this mapping) does.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "status", + "reason": "General metadata field (bank account approval status) not modeled on the list()/get() bank-account shape.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "swift_ifsc_branch_code", + "reason": "India IFSC routing field on the SWIFT rail; not modeled on either the general shape or CreateInternationalSwiftInput/Response.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "swift_payment_code", + "reason": "SWIFT rail field present on CreateInternationalSwiftInput but not surfaced on the general list()/get() bank-account shape.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "tax_id", + "reason": "Same gap as BankAccountOut.recipient_relationship above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateBankAccountIn", + "field": "checkbook_account_id", + "reason": "Legacy/migration-only field for accounts created through a since-replaced provider integration; not exposed on any current create_() method.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateBankAccountIn", + "field": "checkbook_user_key", + "reason": "Same as CreateBankAccountIn.checkbook_account_id above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateBankAccountIn", + "field": "force_cpf_cnpj", + "reason": "Internal override flag; not exposed on any current create_() method.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateBankAccountIn", + "field": "onemoney_external_account_id", + "reason": "Same migration-only pattern as checkbook_account_id, for a different provider; not exposed on any current create_() method.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateBankAccountIn", + "field": "status", + "reason": "Bank accounts are always created in the default review status; no create_() method exposes an override.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateBankAccountIn", + "field": "swift_ifsc_branch_code", + "reason": "Same India IFSC gap as BankAccountOut.swift_ifsc_branch_code above; CreateInternationalSwiftInput does not model it either.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateBankAccountIn", + "field": "type", + "reason": "Injected by each create_() method as a literal (e.g. payload[\"type\"] = \"pix\"); never a field the caller supplies.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateBlockchainWalletIn", + "field": "is_account_abstraction", + "reason": "Injected by create_with_address() (True) / create_with_hash() (False); never a field the caller supplies.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateCustomerIn", + "field": "additional_info", + "reason": "Free-form label/value list not modeled on any of the 3 create_*_kyc() input variants.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateCustomerIn", + "field": "kyc_type", + "reason": "Injected by each create_() method as a literal; never a field the caller supplies.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateCustomerIn", + "field": "latitude", + "reason": "Not modeled on any of the 3 create_*_kyc() input variants.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateCustomerIn", + "field": "longitude", + "reason": "Same gap as CreateCustomerIn.latitude above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateCustomerIn", + "field": "type", + "reason": "Injected by each create_() method as a literal; never a field the caller supplies.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreatePayinIn", + "field": "payin_quote_id", + "reason": "CreatePayinInput is dead code (allowlist.json: create_evm() takes a bare payin_quote_id str, never this TypedDict), so its one field is unmodeled by construction.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreatePayinOut", + "field": "billing_fee_amount", + "reason": "Not modeled on CreateEvmPayinResponse (Payin/GetPayinTrackResponse do model it).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreatePayinOut", + "field": "partner_fee", + "reason": "Modeled instead as separate scalar fields (partner_fee_id, partner_fee_amount) rather than this nested object; same pattern as the pre-existing tracking_partner_fee divergence in allowlist.json.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreatePayinOut", + "field": "payment_method", + "reason": "Not modeled on CreateEvmPayinResponse.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreatePayinOut", + "field": "sender_amount", + "reason": "Not modeled on CreateEvmPayinResponse.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreatePayinOut", + "field": "transaction_fee_amount", + "reason": "Not modeled on CreateEvmPayinResponse.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreatePayinQuoteOut", + "field": "billing_fee_amount", + "reason": "Not modeled on CreatePayinQuoteResponse.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreatePayinQuoteOut", + "field": "is_otc", + "reason": "Not modeled on CreatePayinQuoteResponse.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateWalletIn", + "field": "external_id", + "reason": "Optional caller-supplied external reference; not modeled on CreateCustodialWalletInput.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateWalletIn", + "field": "name", + "reason": "REQUIRED by the API (CreateWalletIn.required includes name) but CreateCustodialWalletInput (customer_id, network only) never sends it -- likely a functional bug, not just a documentation gap; flagging for priority triage.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CustomerOut", + "field": "additional_info", + "reason": "Response counterpart of the CreateCustomerIn.additional_info gap above; not modeled on any of the 3 KYC response variants.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CustomerOut", + "field": "customer_id", + "reason": "Not modeled on any of the 3 KYC response variants (they model the wire's `id` field only).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CustomerOut", + "field": "latitude", + "reason": "Response counterpart of the CreateCustomerIn.latitude gap above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CustomerOut", + "field": "longitude", + "reason": "Same gap as CustomerOut.latitude above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetCustomerLimitIncreaseOut", + "field": "approved_daily", + "reason": "The as-approved counterpart of the requested daily/monthly/per_transaction limits is not modeled on LimitIncreaseRequest.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetCustomerLimitIncreaseOut", + "field": "approved_monthly", + "reason": "Same gap as GetCustomerLimitIncreaseOut.approved_daily above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetCustomerLimitIncreaseOut", + "field": "approved_per_transaction", + "reason": "Same gap as GetCustomerLimitIncreaseOut.approved_daily above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetFeesOut", + "field": "ach_colombia", + "reason": "fees.py's FeesResponse models 5 of the ~18 rail/network fee blocks the wire returns (ach, domestic_wire, pix, solana, ted); the remaining rails/networks are not modeled.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetFeesOut", + "field": "arbitrum", + "reason": "Same gap as GetFeesOut.ach_colombia above (unmodeled rail/network fee block: arbitrum).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetFeesOut", + "field": "base", + "reason": "Same gap as GetFeesOut.ach_colombia above (unmodeled rail/network fee block: base).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetFeesOut", + "field": "created_at", + "reason": "Same gap as GetFeesOut.id above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetFeesOut", + "field": "ethereum", + "reason": "Same gap as GetFeesOut.ach_colombia above (unmodeled rail/network fee block: ethereum).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetFeesOut", + "field": "id", + "reason": "Record metadata (id/instance_id/created_at/updated_at) not modeled on FeesResponse.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetFeesOut", + "field": "instance_id", + "reason": "Same gap as GetFeesOut.id above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetFeesOut", + "field": "international_swift", + "reason": "Same gap as GetFeesOut.ach_colombia above (unmodeled rail/network fee block: international_swift).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetFeesOut", + "field": "pix_safe", + "reason": "Same gap as GetFeesOut.ach_colombia above (unmodeled rail/network fee block: pix_safe).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetFeesOut", + "field": "polygon", + "reason": "Same gap as GetFeesOut.ach_colombia above (unmodeled rail/network fee block: polygon).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetFeesOut", + "field": "rtp", + "reason": "Same gap as GetFeesOut.ach_colombia above (unmodeled rail/network fee block: rtp).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetFeesOut", + "field": "sepa", + "reason": "Same gap as GetFeesOut.ach_colombia above (unmodeled rail/network fee block: sepa).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetFeesOut", + "field": "spei", + "reason": "Same gap as GetFeesOut.ach_colombia above (unmodeled rail/network fee block: spei).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetFeesOut", + "field": "stellar", + "reason": "Same gap as GetFeesOut.ach_colombia above (unmodeled rail/network fee block: stellar).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetFeesOut", + "field": "transfers_3", + "reason": "Same gap as GetFeesOut.ach_colombia above; `transfers_3` looks like a spec/schema-generation artifact for the transfers rail (a naming collision suffix), worth a look when this is modeled rather than assumed correct as-is.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetFeesOut", + "field": "tron", + "reason": "Same gap as GetFeesOut.ach_colombia above (unmodeled rail/network fee block: tron).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetFeesOut", + "field": "updated_at", + "reason": "Same gap as GetFeesOut.id above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "OfframpWallet", + "field": "circle_wallet_id", + "reason": "Not modeled on either OfframpWallet declaration (bank_accounts.py or wallets/offramp.py).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "manual_concluded_at", + "reason": "Manual OTC-liquidity-toggle bookkeeping fields not modeled on Payin/GetPayinTrackResponse.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "manual_concluded_by", + "reason": "Same gap as PayinOut.manual_concluded_at above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "partner_fee", + "reason": "Same partner_fee-vs-partner_fee_id/partner_fee_amount divergence as CreatePayinOut.partner_fee above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "payer_rules", + "reason": "Echo of the payin-quote's payer_rules (e.g. pix_allowed_tax_ids) not modeled on Payin/GetPayinTrackResponse.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "transaction_fee_amount", + "reason": "Not modeled on Payin/GetPayinTrackResponse (billing_fee_amount is modeled, transaction_fee_amount is not).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "approved_risk_sources", + "reason": "Payin-side tracking_payment wire shape (manual-review metadata) differs entirely from the payout-side shape despite sharing the same SDK TypedDict; not modeled.", + "owner": "eric@blindpay.com", + "path": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "review_context", + "reason": "Same gap as PayinOut.approved_risk_sources above.", + "owner": "eric@blindpay.com", + "path": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "review_source", + "reason": "Same gap as PayinOut.approved_risk_sources above.", + "owner": "eric@blindpay.com", + "path": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "description", + "reason": "Payin-side tracking_transaction wire shape has ~15 fields; the generic 4-field TrackingTransaction covers only step/status/transaction_hash/completed_at. A fuller GetPayinTrackingTransaction TypedDict already exists in payins.py but is dead code: GetPayinTrackResponse still types tracking_transaction as the generic TrackingTransaction. Wiring the existing type up (or extending TrackingTransaction) is a deliberate follow-up, not a blind field-add.", + "owner": "eric@blindpay.com", + "path": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "end_to_end_id", + "reason": "Same payin-tracking_transaction gap as `description` above.", + "owner": "eric@blindpay.com", + "path": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "external_id", + "reason": "Same payin-tracking_transaction gap as `description` above.", + "owner": "eric@blindpay.com", + "path": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "provider_name", + "reason": "Same payin-tracking_transaction gap as `description` above.", + "owner": "eric@blindpay.com", + "path": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "provider_transaction_id", + "reason": "Same payin-tracking_transaction gap as `description` above.", + "owner": "eric@blindpay.com", + "path": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "pse_instruction", + "reason": "Same payin-tracking_transaction gap as `description` above.", + "owner": "eric@blindpay.com", + "path": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "sender_account_number", + "reason": "Same payin-tracking_transaction gap as `description` above.", + "owner": "eric@blindpay.com", + "path": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "sender_bank_code", + "reason": "Same payin-tracking_transaction gap as `description` above.", + "owner": "eric@blindpay.com", + "path": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "sender_bank_name", + "reason": "Same payin-tracking_transaction gap as `description` above.", + "owner": "eric@blindpay.com", + "path": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "sender_name", + "reason": "Same payin-tracking_transaction gap as `description` above.", + "owner": "eric@blindpay.com", + "path": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "sender_tax_id", + "reason": "Same payin-tracking_transaction gap as `description` above.", + "owner": "eric@blindpay.com", + "path": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "ted_instruction", + "reason": "Same payin-tracking_transaction gap as `description` above.", + "owner": "eric@blindpay.com", + "path": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "trace_number", + "reason": "Same payin-tracking_transaction gap as `description` above.", + "owner": "eric@blindpay.com", + "path": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "transaction_reference", + "reason": "Same payin-tracking_transaction gap as `description` above.", + "owner": "eric@blindpay.com", + "path": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "transfers_instruction", + "reason": "Same payin-tracking_transaction gap as `description` above.", + "owner": "eric@blindpay.com", + "path": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayoutOnEvmOut", + "field": "bank_account_id", + "reason": "Destination bank-account reference not modeled on CreateEvmPayoutResponse/CreateSolanaPayoutResponse/CreateStellarPayoutResponse.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOnEvmOut", + "field": "billing_fee_amount", + "reason": "Not modeled on the same 3 response types (Payout itself does model it).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOnEvmOut", + "field": "offramp_wallet_id", + "reason": "Destination offramp-wallet reference not modeled on the same 3 response types.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOnEvmOut", + "field": "partner_fee", + "reason": "Same partner_fee-vs-partner_fee_id/partner_fee_amount divergence as PayoutOut.partner_fee above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOnEvmOut", + "field": "tracking_documents", + "reason": "Same gap as PayoutOut.tracking_documents above, on the create-response shape.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOnEvmOut", + "field": "transaction_fee_amount", + "reason": "Not modeled on the same 3 response types.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOnSolanaIn", + "field": "signed_transaction", + "reason": "CreateSolanaPayoutInput (quote_id, sender_wallet_address) does not model this optional field, even though the sibling CreateStellarPayoutInput does.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "jpm_track_data", + "reason": "Provider(JPMorgan)-internal tracking payload; not exposed on Payout.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "partner_fee", + "reason": "Same partner_fee-vs-partner_fee_id/partner_fee_amount divergence as CreatePayinOut.partner_fee above, on the payout shape.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "pix_safe_bank_code", + "reason": "Same rail-specific gap as BankAccountOut.pix_safe_bank_code above, on the payout shape.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "pix_safe_branch_code", + "reason": "Same gap as PayoutOut.pix_safe_bank_code above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "pix_safe_cpf_cnpj", + "reason": "Same gap as PayoutOut.pix_safe_bank_code above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "tracking_documents", + "reason": "Document-upload tracking sub-object not modeled on Payout.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "transaction_fee_amount", + "reason": "Not modeled on Payout (billing_fee_amount is modeled, transaction_fee_amount is not).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "provider_transaction_id", + "reason": "Payout-side tracking_complete wire shape has 2 fields TrackingComplete does not model: provider_transaction_id, refund_reason.", + "owner": "eric@blindpay.com", + "path": "tracking_complete" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "refund_reason", + "reason": "Same gap as PayoutOut.provider_transaction_id above (tracking_complete sub-object).", + "owner": "eric@blindpay.com", + "path": "tracking_complete" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "coelsa_id", + "reason": "Same payout-tracking_payment gap as provider_reference above.", + "owner": "eric@blindpay.com", + "path": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "end_to_end_id", + "reason": "Same payout-tracking_payment gap as provider_reference above.", + "owner": "eric@blindpay.com", + "path": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "provider_clearing_system", + "reason": "Same payout-tracking_payment gap as provider_reference above.", + "owner": "eric@blindpay.com", + "path": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "provider_error_reason", + "reason": "Same payout-tracking_payment gap as provider_reference above.", + "owner": "eric@blindpay.com", + "path": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "provider_imad", + "reason": "Same payout-tracking_payment gap as provider_reference above.", + "owner": "eric@blindpay.com", + "path": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "provider_integration", + "reason": "Same payout-tracking_payment gap as provider_reference above.", + "owner": "eric@blindpay.com", + "path": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "provider_reference", + "reason": "SDK's shared TrackingPayment models a 6 of 20 field subset of the payout tracking_payment object; exposing the remaining payout-only fields is a deliberate additive change that needs one reviewed PR across all SDKs, not a blind per-field add.", + "owner": "eric@blindpay.com", + "path": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "provider_uetr", + "reason": "Same payout-tracking_payment gap as provider_reference above.", + "owner": "eric@blindpay.com", + "path": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "recipient_account_number", + "reason": "Same payout-tracking_payment gap as provider_reference above.", + "owner": "eric@blindpay.com", + "path": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "recipient_account_type", + "reason": "Same payout-tracking_payment gap as provider_reference above.", + "owner": "eric@blindpay.com", + "path": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "recipient_bank_code", + "reason": "Same payout-tracking_payment gap as provider_reference above.", + "owner": "eric@blindpay.com", + "path": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "recipient_branch_code", + "reason": "Same payout-tracking_payment gap as provider_reference above.", + "owner": "eric@blindpay.com", + "path": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "recipient_name", + "reason": "Same payout-tracking_payment gap as provider_reference above.", + "owner": "eric@blindpay.com", + "path": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "recipient_tax_id", + "reason": "Same payout-tracking_payment gap as provider_reference above.", + "owner": "eric@blindpay.com", + "path": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "ledger_in_transaction_id", + "reason": "Payout-side tracking_transaction wire shape has 3 fields TrackingTransaction (step/status/transaction_hash/completed_at) does not model: ledger_in_transaction_id, ledger_out_transaction_id, provider_error_reason.", + "owner": "eric@blindpay.com", + "path": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "ledger_out_transaction_id", + "reason": "Same gap as PayoutOut.ledger_in_transaction_id above.", + "owner": "eric@blindpay.com", + "path": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "provider_error_reason", + "reason": "Same gap as PayoutOut.ledger_in_transaction_id above.", + "owner": "eric@blindpay.com", + "path": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "provider_transaction_id", + "reason": "Same gap as PayoutOut.ledger_in_transaction_id above (tracking_transaction sub-object).", + "owner": "eric@blindpay.com", + "path": "tracking_transaction" + }, + { + "kind": "property", + "schema": "QuoteOut", + "field": "billing_fee_amount", + "reason": "Not modeled on CreateQuoteResponse (Payout/Payin do model billing_fee_amount).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "UpdateCustomerIn", + "field": "additional_info", + "reason": "Not modeled on UpdateCustomerInput.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "UpdateCustomerIn", + "field": "latitude", + "reason": "Not modeled on UpdateCustomerInput.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "UpdateCustomerIn", + "field": "longitude", + "reason": "Same gap as UpdateCustomerIn.latitude above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "UpdateInstanceIn", + "field": "compliance_emails", + "reason": "Not modeled on UpdateInstanceInput.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "UpdateInstanceIn", + "field": "customer_rfi_emails_enabled", + "reason": "Not modeled on UpdateInstanceInput; also connected to the unmodeled RFI resource family.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "UpdateInstanceMemberIn", + "field": "user_role", + "reason": "UpdateInstanceMemberRoleInput is dead code (allowlist.json: update_member_role() takes member_id/role as loose args and remaps role -> user_role itself), so its field is unmodeled by construction.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "UploadOut", + "field": "file_url", + "reason": "Modeled under a different name: UploadResponse uses `url` where the wire's UploadOut object uses `file_url` (the name `url` passes the existing global contract-check because it is used elsewhere in the spec, e.g. WebhookEndpoint.url).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "VirtualAccountOut", + "field": "customer_id", + "reason": "Not modeled on VirtualAccount.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "WalletBalanceOut", + "field": "USDB", + "reason": "Same case divergence as WalletBalanceOut.USDC above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "WalletBalanceOut", + "field": "USDC", + "reason": "Pre-existing case divergence, already tracked in .api-sync/allowlist.json (CustodialWalletBalance.usdc): the wire uses uppercase USDC/USDT/USDB, the SDK models lowercase keys.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "WalletBalanceOut", + "field": "USDT", + "reason": "Same case divergence as WalletBalanceOut.USDC above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "WalletOut", + "field": "external_id", + "reason": "Response counterpart of the CreateWalletIn.external_id gap above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "WalletOut", + "field": "name", + "reason": "Response counterpart of the CreateWalletIn.name gap above; CustodialWallet has no name field.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "WalletTokenOut", + "field": "id", + "reason": "The per-token balance entry id is not modeled on CustodialWalletBalanceToken (amount, token, address only).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "WalletTokenOut", + "field": "symbol", + "reason": "Modeled under a different name: CustodialWalletBalanceToken uses `token` where the wire's WalletTokenOut object uses `symbol`.", + "owner": "eric@blindpay.com" + }, + { + "kind": "enum", + "enum": "BankAccountType", + "missing_values": [ + "saving" + ], + "reason": "Live defect, not just typing: the wire's real enum is [checking, saving] (singular); the SDK Literal is [checking, savings] (plural). Confirmed against packages/api-contract in blindpay-v2 and the API's own payout code, which branches on `account_type === 'saving'`. Do not auto-append \"saving\" as a 3rd member; this needs a coordinated rename PR (node and go already use the correct singular spelling).", + "owner": "eric@blindpay.com" + }, + { + "kind": "enum", + "enum": "KycStatus", + "missing_values": [ + "verifying", + "approved", + "rejected", + "deprecated", + "pending_review", + "approved_rfi" + ], + "reason": "CustomerOut.kyc_status carries the full 8-value customer state machine on the wire; the SDK's KycStatus Literal used for that exact field only has 2 members (awaiting_contract, compliance_request). Needs a human decision on whether KycStatus should simply gain the 6 missing members, or whether this field was wired to the wrong Literal from the start (customers.py also declares an unrelated 5-value CustomerStatus used only for a list-filter param).", + "owner": "eric@blindpay.com" + } +] diff --git a/.github/workflows/api-sync-check.yaml b/.github/workflows/api-sync-check.yaml new file mode 100644 index 0000000..0af816f --- /dev/null +++ b/.github/workflows/api-sync-check.yaml @@ -0,0 +1,47 @@ +name: API Sync Check + +on: + workflow_call: + +jobs: + api-sync-check: + name: Run deterministic spec-sync patcher checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Map validity (every spec-map.json entry resolves) + run: python3 .api-sync/sync.py --validate-map + + - name: State reconciliation (--check) + run: python3 .api-sync/sync.py --check + + - name: Determinism proof + run: | + set -e + rm -rf /tmp/api-sync-proof-a /tmp/api-sync-proof-b + cp -r . /tmp/api-sync-proof-a + cp -r . /tmp/api-sync-proof-b + # Re-applying the already-committed snapshot against itself must be + # a deterministic no-op in any generic CI context (no externally + # delivered spec-current.json is available here). The stronger + # proof -- that applying genuinely NEW drift twice from the same + # starting state produces byte-identical results -- is covered by + # tests/test_api_sync.py::TestDeterminism. + (cd /tmp/api-sync-proof-a && python3 .api-sync/sync.py --apply --spec .api-sync/spec-snapshot.json) + (cd /tmp/api-sync-proof-b && python3 .api-sync/sync.py --apply --spec .api-sync/spec-snapshot.json) + diff -rq /tmp/api-sync-proof-a /tmp/api-sync-proof-b + diff -rq . /tmp/api-sync-proof-a + + - name: Coverage report (non-blocking) + continue-on-error: true + run: python3 .api-sync/sync.py --coverage --spec .api-sync/spec-snapshot.json + + - name: Type audit (non-blocking) + continue-on-error: true + run: python3 .api-sync/sync.py --audit-types --spec .api-sync/spec-snapshot.json diff --git a/.github/workflows/api-sync.yml b/.github/workflows/api-sync.yml index 96470d6..9af1525 100644 --- a/.github/workflows/api-sync.yml +++ b/.github/workflows/api-sync.yml @@ -11,34 +11,22 @@ permissions: jobs: sync: - name: Sync SDK with API changes + name: Run deterministic spec-sync patcher runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - 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 Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" - - name: Check for existing api-sync PR - id: check-pr - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Fetch the newly delivered 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 from main 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 +36,86 @@ 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 + continue-on-error: true + run: python3 .api-sync/sync.py --apply --report /tmp/api-sync-report.json + + - name: Show report + if: always() + run: | + if [ -f /tmp/api-sync-report.json ]; then + cat /tmp/api-sync-report.json + else + echo "(no report written -- the patcher failed before producing one, see the 'Run the patcher' step)" + fi + + - name: Print coverage report (non-blocking) + if: always() + run: python3 .api-sync/sync.py --coverage || true + + - name: Fail loudly if the patcher could not apply cleanly + if: steps.patch.outcome == 'failure' + run: | + echo "::error::api-sync patcher could not apply the new spec cleanly. This needs a human: see the 'Run the patcher' and 'Show report' step output above for the exact NEEDS_HUMAN reason(s)." + exit 1 + + - name: Determine whether there is anything to commit + id: bump + run: | + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json + + with open("/tmp/api-sync-report.json") as f: + report = json.load(f) + applied = report.get("applied", []) + bump = report.get("bump") + prefix = {"minor": "feat", "patch": "fix"}.get(bump, "chore") + print(f"has_changes={'true' if applied else 'false'}") + print(f"bump={bump or ''}") + print(f"prefix={prefix}") + PY - name: Commit and push - id: commit + if: steps.bump.outputs.has_changes == 'true' run: | git remote set-url origin "https://x-access-token:${{ secrets.SDK_SYNC_PAT }}@github.com/${{ github.repository }}.git" + # Defense in depth: the patcher's own spec-map.json never points at + # .github/workflows, but never let the automated flow commit changes + # there even if something upstream of this step somehow produced one. 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.prefix }}: sync SDK with API changes" git push --force-with-lease origin api-sync - - name: Create or update PR - if: steps.commit.outputs.has_changes == 'true' + - name: Check for existing api-sync PR + if: steps.bump.outputs.has_changes == 'true' + id: check-pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER=$(gh pr list --head api-sync --json number --jq '.[0].number // empty') + echo "existing_pr=$PR_NUMBER" >> "$GITHUB_OUTPUT" + + - name: Create or update PR and enable auto-merge + if: steps.bump.outputs.has_changes == 'true' env: GH_TOKEN: ${{ secrets.SDK_SYNC_PAT }} run: | + set -e EXISTING_PR="${{ steps.check-pr.outputs.existing_pr }}" + TITLE="${{ steps.bump.outputs.prefix }}: sync SDK with API changes" + BODY="Automated, deterministic SDK sync (.api-sync/sync.py --apply). Bump: ${{ steps.bump.outputs.bump }}. See the workflow run's 'Show report' step for the exact applied changes." if [ -n "$EXISTING_PR" ]; then - echo "Updating existing PR #$EXISTING_PR" - gh pr comment "$EXISTING_PR" --body "Updated with latest API changes." + gh pr edit "$EXISTING_PR" --title "$TITLE" --body "$BODY" + PR_NUMBER="$EXISTING_PR" else - gh pr create \ - --title "feat: sync SDK with API changes" \ - --body "Automated SDK update from API changes." \ - --base main \ - --head api-sync \ - --label api-sync + gh pr create --title "$TITLE" --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 3b61edd..40de4e7 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -31,6 +31,11 @@ jobs: needs: [lint, typecheck] uses: ./.github/workflows/contract-check.yaml + api-sync-check: + name: API Sync Check + needs: [lint, typecheck] + uses: ./.github/workflows/api-sync-check.yaml + snyk: name: Snyk needs: [lint, typecheck, tests] diff --git a/.github/workflows/release-auto-merge.yaml b/.github/workflows/release-auto-merge.yaml new file mode 100644 index 0000000..8bece1a --- /dev/null +++ b/.github/workflows/release-auto-merge.yaml @@ -0,0 +1,40 @@ +name: Release Auto-Merge + +# Makes the release itself zero-touch: once a merged api-sync (or any other) +# PR lands a conventional-commit change on main, release-please-action (in +# publish.yaml) opens/updates its own "chore(main): release X.Y.Z" PR. That PR +# still has to be merged by a human today. This workflow enables GitHub's +# native auto-merge on it instead, so it merges itself as soon as the +# existing required checks (lint, typecheck, tests, contract-check, +# api-sync-check, snyk) pass -- nothing here bypasses those checks. +# +# Gating is intentionally strict and narrow: only a PR opened by the +# release-please bot itself (github-actions[bot]), carrying release-please's +# own "autorelease: pending" label. Both conditions must hold, so this never +# touches a human-authored PR or a release-please PR that already failed +# something (release-please clears the label / the PR gets closed in that +# case). Enabling auto-merge does not merge anything immediately; GitHub +# still waits for every required check to succeed first. + +on: + pull_request: + types: [opened, labeled, synchronize, reopened] + branches: [main] + +permissions: + pull-requests: write + contents: write + +jobs: + auto-merge-release-pr: + name: Enable auto-merge on the release-please PR + runs-on: ubuntu-latest + if: > + github.event.pull_request.user.login == 'github-actions[bot]' && + contains(github.event.pull_request.labels.*.name, 'autorelease: pending') + steps: + - name: Enable auto-merge + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr merge "${{ github.event.pull_request.number }}" --auto --squash --repo "${{ github.repository }}" diff --git a/.gitignore b/.gitignore index 4a08c01..0e8a106 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,7 @@ cython_debug/ # Snyk Security Extension - AI Rules (auto-generated) .cursor/rules/snyk_rules.mdc + +# api-sync: the newly-delivered spec is force-pushed to the api-sync-data branch +# and read by the workflow at run time; it is never committed to main. +.api-sync/spec-current.json diff --git a/pyproject.toml b/pyproject.toml index 83ecad4..479fb12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ requires = ["hatchling==1.26.3", "hatch-fancy-pypi-readme"] build-backend = "hatchling.build" [tool.hatch.version] -path = "src/blindpay/__init__.py" +path = "src/blindpay/_version.py" [tool.hatch.build] include = ["src/*", "README.md", "LICENSE", "py.typed"] diff --git a/release-please-config.json b/release-please-config.json index f357f6a..3fe2013 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -10,7 +10,7 @@ "include-v-in-tag": true, "package-name": "blindpay", "extra-files": [ - "src/blindpay/__init__.py" + "src/blindpay/_version.py" ] } } diff --git a/src/blindpay/__init__.py b/src/blindpay/__init__.py index 699b046..d412506 100644 --- a/src/blindpay/__init__.py +++ b/src/blindpay/__init__.py @@ -1,6 +1,5 @@ -__version__ = "3.0.0" - from ._internal.exceptions import BlindPayError +from ._version import __version__ as __version__ from .client import BlindPay, BlindPaySync from .types import ( AccountClass, diff --git a/src/blindpay/_version.py b/src/blindpay/_version.py new file mode 100644 index 0000000..528787c --- /dev/null +++ b/src/blindpay/_version.py @@ -0,0 +1 @@ +__version__ = "3.0.0" diff --git a/src/blindpay/client.py b/src/blindpay/client.py index 8322fc7..7fc3c76 100644 --- a/src/blindpay/client.py +++ b/src/blindpay/client.py @@ -7,6 +7,7 @@ import httpx from ._internal.exceptions import BlindPayError +from ._version import __version__ from .types import BlindpayApiResponse if TYPE_CHECKING: @@ -40,8 +41,6 @@ from blindpay.resources.wallets.offramp import OfframpWalletsResource, OfframpWalletsResourceSync from blindpay.resources.webhooks.webhooks import WebhookEndpointsResource, WebhookEndpointsResourceSync -__version__ = "2.3.0" - T = TypeVar("T") diff --git a/src/blindpay/resources/payins/quotes.py b/src/blindpay/resources/payins/quotes.py index df67a64..9cc2dd7 100644 --- a/src/blindpay/resources/payins/quotes.py +++ b/src/blindpay/resources/payins/quotes.py @@ -10,7 +10,7 @@ StablecoinToken, ) -PaymentMethod = Literal["ach", "wire", "pix", "spei", "rtp", "ted"] +PaymentMethod = Literal["ach", "wire", "pix", "spei", "rtp", "ted", "international_swift", "pse", "transfers"] class PayerRules(TypedDict, total=False): diff --git a/src/blindpay/resources/quotes/quotes.py b/src/blindpay/resources/quotes/quotes.py index f4ea7ca..a468250 100644 --- a/src/blindpay/resources/quotes/quotes.py +++ b/src/blindpay/resources/quotes/quotes.py @@ -1,6 +1,6 @@ from typing import Any, Dict, List, Optional -from typing_extensions import TypedDict +from typing_extensions import NotRequired, TypedDict from ..._internal.api_client import InternalApiClient, InternalApiClientSync from ...types import ( @@ -39,6 +39,7 @@ class CreateQuoteInput(TypedDict): transaction_document_file: Optional[str] transaction_document_id: Optional[str] transaction_document_type: TransactionDocumentType + refund_wallet_address: NotRequired[Optional[str]] class CreateQuoteResponse(TypedDict): diff --git a/src/blindpay/types.py b/src/blindpay/types.py index 0dfcb40..5110269 100644 --- a/src/blindpay/types.py +++ b/src/blindpay/types.py @@ -388,7 +388,7 @@ class TrackingPartnerFee(TypedDict): "other", ] -BankingPartner = Literal["cfsb", "citi", "hsbc", "jpmorgan"] +BankingPartner = Literal["cfsb", "citi", "hsbc", "jpmorgan", "portage"] PaymentMethod = Literal["ach", "wire", "pix", "spei", "transfers", "pse", "international_swift", "rtp", "ted"] diff --git a/tests/test_api_sync.py b/tests/test_api_sync.py new file mode 100644 index 0000000..e97a3a5 --- /dev/null +++ b/tests/test_api_sync.py @@ -0,0 +1,1097 @@ +"""Unit tests for .api-sync/sync.py, the deterministic spec -> SDK patcher. + +sync.py lives outside the `blindpay` package (it is a standalone script next to +check_contract.py), so each test loads a fresh copy of the module via +importlib and repoints its path constants at an isolated tmp_path "repo" -- +never at the real src/blindpay tree. +""" + +import ast +import importlib.util +import json +import sys +import types +from pathlib import Path +from typing import Any, Optional + +import pytest + +SYNC_PATH = Path(__file__).parent.parent / ".api-sync" / "sync.py" + + +_load_counter = 0 + + +def load_sync(root: Path) -> types.ModuleType: + """Import a fresh instance of sync.py with its path constants repointed at `root`.""" + global _load_counter + _load_counter += 1 + module_name = f"sync_under_test_{_load_counter}" + spec = importlib.util.spec_from_file_location(module_name, SYNC_PATH) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + # @dataclass needs the module registered in sys.modules to resolve annotations + sys.modules[module_name] = module + spec.loader.exec_module(module) + module.ROOT = root # type: ignore[attr-defined] + module.API_SYNC = root / ".api-sync" # type: ignore[attr-defined] + module.SRC_ROOT = root / "src" / "blindpay" # type: ignore[attr-defined] + module.SNAPSHOT_PATH = module.API_SYNC / "spec-snapshot.json" # type: ignore[attr-defined] + module.DEFAULT_SPEC_PATH = module.API_SYNC / "spec-current.json" # type: ignore[attr-defined] + module.MAP_PATH = module.API_SYNC / "spec-map.json" # type: ignore[attr-defined] + module.UNMODELED_PATH = module.API_SYNC / "unmodeled.json" # type: ignore[attr-defined] + return module + + +def write(root: Path, rel: str, content: str) -> Path: + p = root / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content) + return p + + +def base_schema(schemas: dict[str, Any], paths: Optional[dict[str, Any]] = None) -> dict[str, Any]: + return {"paths": paths or {}, "components": {"schemas": schemas}} + + +def op(ref_in: Optional[str] = None, ref_out: Optional[str] = None) -> dict[str, Any]: + o: dict[str, Any] = {"x-sdk": True} + if ref_in: + o["requestBody"] = {"content": {"application/json": {"schema": {"$ref": f"#/components/schemas/{ref_in}"}}}} + if ref_out: + o["responses"] = { + "200": {"content": {"application/json": {"schema": {"$ref": f"#/components/schemas/{ref_out}"}}}} + } + else: + o["responses"] = {"200": {"content": {}}} + return o + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + write(tmp_path, "src/blindpay/__init__.py", "") + return tmp_path + + +# --------------------------------------------------------------------------- # +# Literal (enum) splicing +# --------------------------------------------------------------------------- # + + +class TestLiteralSplicing: + def test_single_line_literal_add_member(self): + source = 'Color = Literal["red", "blue"]\n' + tree = ast.parse(source) + node = next(n for n in ast.walk(tree) if isinstance(n, ast.Assign)) + sync = load_sync(Path("/nonexistent")) + result = sync.splice_literal_add_members(source, node, ["green"]) + assert result == 'Color = Literal["red", "blue", "green"]\n' + + def test_single_line_literal_add_multiple_members_sorted_input(self): + source = 'Color = Literal["red"]\n' + tree = ast.parse(source) + node = next(n for n in ast.walk(tree) if isinstance(n, ast.Assign)) + sync = load_sync(Path("/nonexistent")) + result = sync.splice_literal_add_members(source, node, ["blue", "green"]) + assert result == 'Color = Literal["red", "blue", "green"]\n' + + def test_multiline_literal_add_member_preserves_indentation_and_trailing_comma(self): + source = 'Status = Literal[\n "a",\n "b",\n]\n' + tree = ast.parse(source) + node = next(n for n in ast.walk(tree) if isinstance(n, ast.Assign)) + sync = load_sync(Path("/nonexistent")) + result = sync.splice_literal_add_members(source, node, ["c"]) + assert result == 'Status = Literal[\n "a",\n "b",\n "c",\n]\n' + ast.parse(result) # still valid Python + + def test_multiline_literal_without_trailing_comma_gets_one_added(self): + source = 'Status = Literal[\n "a",\n "b"\n]\n' + tree = ast.parse(source) + node = next(n for n in ast.walk(tree) if isinstance(n, ast.Assign)) + sync = load_sync(Path("/nonexistent")) + result = sync.splice_literal_add_members(source, node, ["c"]) + assert result == 'Status = Literal[\n "a",\n "b",\n "c",\n]\n' + + +# --------------------------------------------------------------------------- # +# TypedDict field splicing / annotation choice +# --------------------------------------------------------------------------- # + + +class TestTypedDictFieldInsertion: + def _class_info(self, sync: types.ModuleType, source: str, name: str): + tree = ast.parse(source) + node = next(n for n in ast.walk(tree) if isinstance(n, ast.ClassDef) and n.name == name) + own_fields = { + item.target.id: item + for item in node.body + if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name) + } + return sync.ClassInfo( + file=Path("/fake.py"), + node=node, + source=source, + bases=sync._base_names(node), + total_false=sync._is_total_false(node), + own_fields=own_fields, + ) + + def test_total_true_class_always_wraps_new_field_in_notrequired(self): + """A total=True class (the default -- no `total=False` anywhere) makes + every declared key structurally required. A newly added field must be + NotRequired regardless of whether any sibling field already uses that + convention or just uses a bare Optional[...] for its value type -- + otherwise every existing caller that omits the new key breaks pyright + and mypy. This is the exact bug a real PR review caught: `class + CreateQuoteInput(TypedDict):` has siblings typed as plain + `Optional[str]`, but those were part of the original, already-required + shape -- that convention does not extend to fields added later.""" + sync = load_sync(Path("/nonexistent")) + source = "class Plain(TypedDict):\n x: str\n y: Optional[str]\n" + info = self._class_info(sync, source, "Plain") + assert info.total_false is False + assert sync.choose_field_annotation(info, "str", nullable=True) == "NotRequired[Optional[str]]" + assert sync.choose_field_annotation(info, "str", nullable=False) == "NotRequired[str]" + + def test_total_true_class_with_existing_notrequired_sibling_still_wraps(self): + sync = load_sync(Path("/nonexistent")) + source = "class WithNR(TypedDict):\n x: str\n y: NotRequired[str]\n" + info = self._class_info(sync, source, "WithNR") + assert sync.choose_field_annotation(info, "str", nullable=True) == "NotRequired[Optional[str]]" + assert sync.choose_field_annotation(info, "str", nullable=False) == "NotRequired[str]" + + def test_total_false_class_uses_bare_type_when_not_nullable(self): + sync = load_sync(Path("/nonexistent")) + source = "class Foo(_FooRequired, total=False):\n b: str\n" + info = self._class_info(sync, source, "Foo") + assert info.total_false is True + assert sync.choose_field_annotation(info, "str", nullable=False) == "str" + assert sync.choose_field_annotation(info, "str", nullable=True) == "Optional[str]" + + def test_splice_typeddict_add_field_appends_after_last_statement(self): + sync = load_sync(Path("/nonexistent")) + source = "class Plain(TypedDict):\n x: str\n y: Optional[str]\n" + tree = ast.parse(source) + node = next(n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)) + result = sync.splice_typeddict_add_field(source, node, "z", "Optional[str]") + expected = "class Plain(TypedDict):\n x: str\n y: Optional[str]\n z: Optional[str]\n" + assert result == expected + ast.parse(result) + + +# --------------------------------------------------------------------------- # +# Import management: NotRequired/Optional must be added when a class needs +# them for the first time (this is the exact real-world case: quotes.py +# already imports Optional from typing but not NotRequired from anywhere) +# --------------------------------------------------------------------------- # + + +class TestImportManagement: + def test_adds_notrequired_to_existing_typing_extensions_import(self): + sync = load_sync(Path("/nonexistent")) + source = "from typing_extensions import TypedDict\n\n\nclass Plain(TypedDict):\n x: str\n" + result = sync.ensure_name_imported(source, "NotRequired") + assert result.startswith("from typing_extensions import NotRequired, TypedDict\n") + ast.parse(result) + + def test_adds_notrequired_to_existing_typing_import_when_that_is_where_typeddict_lives(self): + """Matches payins.py's own style: `from typing import ... TypedDict`, + no typing_extensions import at all.""" + sync = load_sync(Path("/nonexistent")) + source = "from typing import List, Optional, TypedDict\n\n\nclass Plain(TypedDict):\n x: str\n" + result = sync.ensure_name_imported(source, "NotRequired") + assert result.startswith("from typing import List, NotRequired, Optional, TypedDict\n") + ast.parse(result) + + def test_noop_when_already_imported(self): + sync = load_sync(Path("/nonexistent")) + source = "from typing_extensions import NotRequired, TypedDict\n\n\nclass Plain(TypedDict):\n x: str\n" + result = sync.ensure_name_imported(source, "NotRequired") + assert result == source + + def test_end_to_end_apply_adds_import_and_wraps_field_for_total_true_class_lacking_notrequired(self, repo: Path): + """The exact real-world scenario a PR review caught: a total=True + class in a file that imports Optional from `typing` but has never + needed NotRequired before. Both the import and the field annotation + must come out right in the same apply.""" + write(repo, "src/blindpay/__init__.py", "") + write( + repo, + "src/blindpay/resources/quotes_like.py", + "from typing import Optional\n\nfrom typing_extensions import TypedDict\n\n\n" + "class CreateThingInput(TypedDict):\n bank_account_id: str\n description: Optional[str]\n", + ) + map_data: dict[str, Any] = { + "enums": [], + "types": [ + { + "spec": "ThingIn", + "sdk": [{"file": "src/blindpay/resources/quotes_like.py", "symbol": "CreateThingInput"}], + } + ], + "ignore": {"schemas": []}, + } + _write_map_and_unmodeled(repo, map_data) + old_spec = base_schema( + { + "ThingIn": { + "properties": {"bank_account_id": {"type": "string"}, "description": {"type": ["string", "null"]}} + } + }, + {"/t": {"post": op(ref_in="ThingIn")}}, + ) + write(repo, ".api-sync/spec-snapshot.json", json.dumps(old_spec)) + new_spec = json.loads(json.dumps(old_spec)) + new_spec["components"]["schemas"]["ThingIn"]["properties"]["refund_wallet_address"] = { + "type": ["string", "null"] + } + write(repo, ".api-sync/spec-current.json", json.dumps(new_spec)) + + sync = load_sync(repo) + assert sync.cmd_apply(sync.DEFAULT_SPEC_PATH, None) == 0 + + result = (repo / "src/blindpay/resources/quotes_like.py").read_text() + assert "from typing_extensions import NotRequired, TypedDict" in result + assert "refund_wallet_address: NotRequired[Optional[str]]" in result + ast.parse(result) + + +# --------------------------------------------------------------------------- # +# Reconciliation against a fixture repo (enums, properties, nested specPath) +# --------------------------------------------------------------------------- # + + +FIXTURE_TYPES_PY = """from typing import Literal + +Color = Literal["red", "blue"] +""" + +FIXTURE_RESOURCE_PY = """from typing import Optional +from typing_extensions import Literal, NotRequired, TypedDict + +Status = Literal[ + "a", + "b", +] + + +class Plain(TypedDict): + id: str + name: Optional[str] + + +class _FooRequired(TypedDict): + id: str + + +class Foo(_FooRequired, total=False): + label: str +""" + + +def _write_fixture_repo(root: Path) -> None: + write(root, "src/blindpay/__init__.py", "") + write(root, "src/blindpay/types.py", FIXTURE_TYPES_PY) + write(root, "src/blindpay/resources/sample.py", FIXTURE_RESOURCE_PY) + + +def _write_map_and_unmodeled(root: Path, map_data: dict[str, Any], unmodeled: Optional[list[Any]] = None) -> None: + write(root, ".api-sync/spec-map.json", json.dumps(map_data)) + write(root, ".api-sync/unmodeled.json", json.dumps(unmodeled or [])) + + +class TestReconciliation: + def test_enum_gap_detected_when_spec_has_extra_member(self, repo: Path): + _write_fixture_repo(repo) + map_data: dict[str, Any] = { + "enums": [ + { + "spec": {"schema": "ColorOut", "property": "color"}, + "sdk": {"file": "src/blindpay/types.py", "symbol": "Color"}, + } + ], + "types": [], + "ignore": {"schemas": []}, + } + _write_map_and_unmodeled(repo, map_data) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + spec = base_schema( + {"ColorOut": {"properties": {"color": {"type": "string", "enum": ["red", "blue", "green"]}}}}, + {"/x": {"get": op(ref_out="ColorOut")}}, + ) + gaps = sync.reconcile_enums(spec, map_data, [], index) + assert len(gaps) == 1 + assert gaps[0].symbol == "Color" + assert gaps[0].missing == ["green"] + + def test_enum_gap_suppressed_by_unmodeled_entry(self, repo: Path): + _write_fixture_repo(repo) + map_data: dict[str, Any] = { + "enums": [ + { + "spec": {"schema": "ColorOut", "property": "color"}, + "sdk": {"file": "src/blindpay/types.py", "symbol": "Color"}, + } + ], + "types": [], + "ignore": {"schemas": []}, + } + unmodeled = [ + { + "kind": "enum", + "enum": "Color", + "missing_values": ["green"], + "reason": "test", + "owner": "eric@blindpay.com", + } + ] + _write_map_and_unmodeled(repo, map_data, unmodeled) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + spec = base_schema( + {"ColorOut": {"properties": {"color": {"type": "string", "enum": ["red", "blue", "green"]}}}}, + {"/x": {"get": op(ref_out="ColorOut")}}, + ) + gaps = sync.reconcile_enums(spec, map_data, unmodeled, index) + assert gaps == [] + + def test_property_gap_detected(self, repo: Path): + _write_fixture_repo(repo) + map_data: dict[str, Any] = { + "enums": [], + "types": [{"spec": "PlainOut", "sdk": [{"file": "src/blindpay/resources/sample.py", "symbol": "Plain"}]}], + "ignore": {"schemas": []}, + } + _write_map_and_unmodeled(repo, map_data) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + spec = base_schema( + { + "PlainOut": { + "properties": { + "id": {"type": "string"}, + "name": {"type": ["string", "null"]}, + "extra": {"type": "string"}, + } + } + }, + {"/x": {"get": op(ref_out="PlainOut")}}, + ) + gaps = sync.reconcile_types(spec, map_data, [], index) + assert len(gaps) == 1 + assert gaps[0].schema_names == ["PlainOut"] + assert list(gaps[0].missing.keys()) == ["extra"] + + def test_two_part_total_false_pattern_field_resolved_via_base(self, repo: Path): + _write_fixture_repo(repo) + map_data: dict[str, Any] = { + "enums": [], + "types": [{"spec": "FooOut", "sdk": [{"file": "src/blindpay/resources/sample.py", "symbol": "Foo"}]}], + "ignore": {"schemas": []}, + } + _write_map_and_unmodeled(repo, map_data) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + # `id` lives on _FooRequired (the base class); resolve_typeddict_fields must find it + fields = sync.resolve_typeddict_fields("Foo", "src/blindpay/resources/sample.py", index) + assert fields == {"id", "label"} + + def test_nested_specpath_sub_object_reconciled_independently_of_root(self, repo: Path): + """The blind spot that hid provider_reference in tracking_payment: a + property can live in an inline sub-object that is never $ref'd. The + reconciler must walk into `specPath`, not just the schema's own + top-level properties.""" + _write_fixture_repo(repo) + map_data: dict[str, Any] = { + "enums": [], + "types": [ + { + "spec": ["ParentOut"], + "specPath": "detail", + "sdk": [{"file": "src/blindpay/resources/sample.py", "symbol": "Plain"}], + } + ], + "ignore": {"schemas": []}, + } + _write_map_and_unmodeled(repo, map_data) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + spec = base_schema( + { + "ParentOut": { + "properties": { + "unrelated_top_level_field": {"type": "string"}, # NOT reconciled (out of scope for this entry) + "detail": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "name": {"type": ["string", "null"]}, + "newly_added": {"type": "string"}, + }, + }, + } + } + }, + {"/x": {"get": op(ref_out="ParentOut")}}, + ) + gaps = sync.reconcile_types(spec, map_data, [], index) + assert len(gaps) == 1 + assert gaps[0].path == "detail" + assert list(gaps[0].missing.keys()) == ["newly_added"] + # the root-level field is untouched by this specPath-scoped entry + assert "unrelated_top_level_field" not in gaps[0].missing + + +# --------------------------------------------------------------------------- # +# Map validity +# --------------------------------------------------------------------------- # + + +class TestMapValidity: + def test_valid_map_passes(self, repo: Path): + _write_fixture_repo(repo) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + map_data: dict[str, Any] = { + "enums": [ + {"spec": {"schema": "X", "property": "y"}, "sdk": {"file": "src/blindpay/types.py", "symbol": "Color"}} + ], + "types": [{"spec": "PlainOut", "sdk": [{"file": "src/blindpay/resources/sample.py", "symbol": "Plain"}]}], + "ignore": {"schemas": []}, + } + assert sync.validate_map(map_data, index) == [] + + def test_missing_file_is_an_error(self, repo: Path): + _write_fixture_repo(repo) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + map_data: dict[str, Any] = { + "enums": [], + "types": [{"spec": "X", "sdk": [{"file": "src/blindpay/nope.py", "symbol": "Plain"}]}], + "ignore": {"schemas": []}, + } + errors = sync.validate_map(map_data, index) + assert len(errors) == 1 + assert "file not found" in errors[0] + + def test_symbol_not_found_is_an_error(self, repo: Path): + _write_fixture_repo(repo) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + map_data: dict[str, Any] = { + "enums": [], + "types": [{"spec": "X", "sdk": [{"file": "src/blindpay/resources/sample.py", "symbol": "DoesNotExist"}]}], + "ignore": {"schemas": []}, + } + errors = sync.validate_map(map_data, index) + assert len(errors) == 1 + assert "not found anywhere" in errors[0] + + def test_symbol_found_but_in_a_different_file_is_an_error(self, repo: Path): + """This is the exact bug class the audit caught for real: PaymentMethod + exists, just not in the file the map claims.""" + _write_fixture_repo(repo) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + map_data: dict[str, Any] = { + "enums": [ + { + "spec": {"schema": "X", "property": "y"}, + "sdk": {"file": "src/blindpay/resources/sample.py", "symbol": "Color"}, + } + ], + "types": [], + "ignore": {"schemas": []}, + } + errors = sync.validate_map(map_data, index) + assert len(errors) == 1 + assert "not found in src/blindpay/resources/sample.py" in errors[0] + + +# --------------------------------------------------------------------------- # +# NEEDS_HUMAN classification (old-vs-new diff) +# --------------------------------------------------------------------------- # + + +class TestNeedsHuman: + def _map(self) -> dict[str, Any]: + return { + "enums": [ + { + "spec": {"schema": "ColorOut", "property": "color"}, + "sdk": {"file": "src/blindpay/types.py", "symbol": "Color"}, + } + ], + "types": [{"spec": "PlainOut", "sdk": [{"file": "src/blindpay/resources/sample.py", "symbol": "Plain"}]}], + "ignore": {"schemas": []}, + } + + def test_enum_member_removed_is_needs_human(self, repo: Path): + _write_fixture_repo(repo) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + old = base_schema( + {"ColorOut": {"properties": {"color": {"type": "string", "enum": ["red", "blue"]}}}}, + {"/x": {"get": op(ref_out="ColorOut")}}, + ) + new = base_schema( + {"ColorOut": {"properties": {"color": {"type": "string", "enum": ["red"]}}}}, + {"/x": {"get": op(ref_out="ColorOut")}}, + ) + problems = sync.diff_removals_and_changes(old, new, self._map(), index) + assert any(p.kind == "enum_member_removed" for p in problems) + + def test_property_removed_is_needs_human(self, repo: Path): + _write_fixture_repo(repo) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + old = base_schema( + {"PlainOut": {"properties": {"id": {"type": "string"}, "name": {"type": "string"}}}}, + {"/x": {"get": op(ref_out="PlainOut")}}, + ) + new = base_schema( + {"PlainOut": {"properties": {"id": {"type": "string"}}}}, {"/x": {"get": op(ref_out="PlainOut")}} + ) + problems = sync.diff_removals_and_changes(old, new, self._map(), index) + assert any(p.kind == "property_removed" for p in problems) + + def test_schema_removed_is_needs_human(self, repo: Path): + _write_fixture_repo(repo) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + old = base_schema( + {"PlainOut": {"properties": {"id": {"type": "string"}}}}, {"/x": {"get": op(ref_out="PlainOut")}} + ) + new = base_schema({}, {}) + problems = sync.diff_removals_and_changes(old, new, self._map(), index) + assert any(p.kind == "schema_removed" for p in problems) + + def test_required_ness_change_is_needs_human(self, repo: Path): + _write_fixture_repo(repo) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + old = base_schema( + {"PlainOut": {"properties": {"id": {"type": "string"}}, "required": []}}, + {"/x": {"get": op(ref_out="PlainOut")}}, + ) + new = base_schema( + {"PlainOut": {"properties": {"id": {"type": "string"}}, "required": ["id"]}}, + {"/x": {"get": op(ref_out="PlainOut")}}, + ) + problems = sync.diff_removals_and_changes(old, new, self._map(), index) + assert any(p.kind == "required_change" for p in problems) + + def test_type_change_is_needs_human(self, repo: Path): + """string -> integer on a plain field.""" + _write_fixture_repo(repo) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + old = base_schema( + {"PlainOut": {"properties": {"id": {"type": "string"}}}}, {"/x": {"get": op(ref_out="PlainOut")}} + ) + new = base_schema( + {"PlainOut": {"properties": {"id": {"type": "integer"}}}}, {"/x": {"get": op(ref_out="PlainOut")}} + ) + problems = sync.diff_removals_and_changes(old, new, self._map(), index) + assert any(p.kind == "type_change" for p in problems) + + def test_nullable_to_non_nullable_is_needs_human(self, repo: Path): + """Same base JSON type, nullability narrows -- the SDK's existing + Optional[...] (or lack of it) would silently stop matching the wire.""" + _write_fixture_repo(repo) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + old = base_schema( + {"PlainOut": {"properties": {"id": {"type": ["string", "null"]}}}}, + {"/x": {"get": op(ref_out="PlainOut")}}, + ) + new = base_schema( + {"PlainOut": {"properties": {"id": {"type": "string"}}}}, {"/x": {"get": op(ref_out="PlainOut")}} + ) + problems = sync.diff_removals_and_changes(old, new, self._map(), index) + assert any(p.kind == "type_change" and "nullability" in p.detail for p in problems) + + def test_non_nullable_to_nullable_is_also_needs_human(self, repo: Path): + """The reverse direction is flagged too -- symmetric with required_change, + which does not privilege either direction either.""" + _write_fixture_repo(repo) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + old = base_schema( + {"PlainOut": {"properties": {"id": {"type": "string"}}}}, {"/x": {"get": op(ref_out="PlainOut")}} + ) + new = base_schema( + {"PlainOut": {"properties": {"id": {"type": ["string", "null"]}}}}, + {"/x": {"get": op(ref_out="PlainOut")}}, + ) + problems = sync.diff_removals_and_changes(old, new, self._map(), index) + assert any(p.kind == "type_change" and "nullability" in p.detail for p in problems) + + def test_enum_property_degrading_to_bare_string_is_needs_human(self, repo: Path): + """A mapped enum losing its `enum` array entirely (API stops constraining + the field) must not silently pass: every value the SDK currently models + shows up as no longer present in the (now enum-less) new spec, and the + existing enum_member_removed path already hard-fails on that.""" + _write_fixture_repo(repo) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + old = base_schema( + {"ColorOut": {"properties": {"color": {"type": "string", "enum": ["red", "blue"]}}}}, + {"/x": {"get": op(ref_out="ColorOut")}}, + ) + new = base_schema( + {"ColorOut": {"properties": {"color": {"type": "string"}}}}, # enum removed entirely + {"/x": {"get": op(ref_out="ColorOut")}}, + ) + problems = sync.diff_removals_and_changes(old, new, self._map(), index) + assert any(p.kind == "enum_member_removed" for p in problems) + + def test_ambiguous_type_metadata_only_change_is_deliberately_compatible(self, repo: Path): + """The one case treated as compatible on purpose: a property that had no + "type" key at all (only "example"/"description") gaining an explicit + type is not a type change to react to -- there is nothing on the old + side to compare against. This is the exact, real, benign shape this + spec's own created_at/updated_at fields went through (gaining + {"type": ["string","null"], "format": "date-time"} where they + previously had none).""" + _write_fixture_repo(repo) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + old = base_schema( + {"PlainOut": {"properties": {"id": {"example": "abc"}}}}, {"/x": {"get": op(ref_out="PlainOut")}} + ) + new = base_schema( + {"PlainOut": {"properties": {"id": {"type": ["string", "null"], "format": "date-time", "example": "abc"}}}}, + {"/x": {"get": op(ref_out="PlainOut")}}, + ) + problems = sync.diff_removals_and_changes(old, new, self._map(), index) + assert problems == [] + + def test_new_operation_is_needs_human(self, repo: Path): + _write_fixture_repo(repo) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + old = base_schema({}, {}) + new = base_schema({}, {"/new-path": {"get": op()}}) + problems = sync.diff_removals_and_changes(old, new, self._map(), index) + assert any(p.kind == "new_operation" and "/new-path" in p.detail for p in problems) + + def test_new_unmapped_schema_is_needs_human(self, repo: Path): + _write_fixture_repo(repo) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + old = base_schema({}, {}) + new = base_schema( + {"BrandNewOut": {"properties": {"a": {"type": "string"}}}}, {"/x": {"get": op(ref_out="BrandNewOut")}} + ) + problems = sync.diff_removals_and_changes(old, new, self._map(), index) + assert any(p.kind == "new_schema" for p in problems) + + def test_property_added_on_unmapped_schema_is_needs_human(self, repo: Path): + _write_fixture_repo(repo) + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + # UnmappedOut is reachable in both old and new (not "new_schema"), but gains a field + old = base_schema( + {"UnmappedOut": {"properties": {"a": {"type": "string"}}}}, {"/u": {"get": op(ref_out="UnmappedOut")}} + ) + new = base_schema( + {"UnmappedOut": {"properties": {"a": {"type": "string"}, "b": {"type": "string"}}}}, + {"/u": {"get": op(ref_out="UnmappedOut")}}, + ) + problems = sync.diff_removals_and_changes(old, new, self._map(), index) + assert any(p.kind == "property_on_unmapped_schema" for p in problems) + + def test_fan_out_ambiguous_target_is_needs_human(self, repo: Path): + _write_fixture_repo(repo) + write( + repo, + "src/blindpay/resources/other.py", + "from typing_extensions import TypedDict\n\n\nclass OtherPlain(TypedDict):\n id: str\n", + ) + map_data: dict[str, Any] = { + "enums": [], + "types": [ + { + "spec": "FanOut", + "sdk": [ + {"file": "src/blindpay/resources/sample.py", "symbol": "Plain"}, + {"file": "src/blindpay/resources/other.py", "symbol": "OtherPlain"}, + ], + } + ], + "ignore": {"schemas": []}, + } + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + gap = sync.PropertyGap( + schema_names=["FanOut"], + path=None, + sdk_sites=map_data["types"][0]["sdk"], + missing={"new_field": {"type": "string"}}, + ) + applied, needs_human = sync.apply_property_change(gap, index) + assert applied == [] + assert any(n.kind == "fan_out_target_ambiguous" for n in needs_human) + + def test_unresolvable_type_is_needs_human(self, repo: Path): + _write_fixture_repo(repo) + map_data: dict[str, Any] = { + "enums": [], + "types": [{"spec": "PlainOut", "sdk": [{"file": "src/blindpay/resources/sample.py", "symbol": "Plain"}]}], + "ignore": {"schemas": []}, + } + sync = load_sync(repo) + index = sync.build_sdk_index(sync.SRC_ROOT) + gap = sync.PropertyGap( + schema_names=["PlainOut"], + path=None, + sdk_sites=map_data["types"][0]["sdk"], + missing={"weird": {"type": "object", "properties": {}}}, + ) + applied, needs_human = sync.apply_property_change(gap, index) + assert applied == [] + assert any(n.kind == "type_unresolvable" for n in needs_human) + + +# --------------------------------------------------------------------------- # +# Full apply flow: bump classification, idempotency, determinism +# --------------------------------------------------------------------------- # + + +class TestApplyFlow: + def _setup(self, repo: Path): + _write_fixture_repo(repo) + map_data: dict[str, Any] = { + "enums": [ + { + "spec": {"schema": "ColorOut", "property": "color"}, + "sdk": {"file": "src/blindpay/types.py", "symbol": "Color"}, + } + ], + "types": [{"spec": "PlainOut", "sdk": [{"file": "src/blindpay/resources/sample.py", "symbol": "Plain"}]}], + "ignore": {"schemas": []}, + } + _write_map_and_unmodeled(repo, map_data) + old_spec = base_schema( + { + "ColorOut": {"properties": {"color": {"type": "string", "enum": ["red", "blue"]}}}, + "PlainOut": {"properties": {"id": {"type": "string"}, "name": {"type": ["string", "null"]}}}, + }, + {"/c": {"get": op(ref_out="ColorOut")}, "/p": {"get": op(ref_out="PlainOut")}}, + ) + write(repo, ".api-sync/spec-snapshot.json", json.dumps(old_spec)) + return map_data, old_spec + + def test_enum_only_change_bumps_minor(self, repo: Path): + self._setup(repo) + new_spec = json.loads((repo / ".api-sync/spec-snapshot.json").read_text()) + new_spec["components"]["schemas"]["ColorOut"]["properties"]["color"]["enum"].append("green") + write(repo, ".api-sync/spec-current.json", json.dumps(new_spec)) + + sync = load_sync(repo) + exit_code = sync.cmd_apply(sync.DEFAULT_SPEC_PATH, None) + assert exit_code == 0 + + color_src = (repo / "src/blindpay/types.py").read_text() + assert "green" in color_src + + def test_snapshot_refresh_copies_raw_bytes_not_a_reformatted_json_dump(self, repo: Path): + """Regression test: the patcher must never json.load then json.dump the + delivered spec to refresh the snapshot. Even when the parsed content is + semantically identical, re-serializing changes indentation, separators, + key order and unicode escaping -- which would make every future sync PR + carry a diff of the entire multi-thousand-line file instead of just the + lines that changed, and would make the committed snapshot stop matching + the exact bytes blindpay-v2 ships as spec-current.json.""" + self._setup(repo) + new_spec = json.loads((repo / ".api-sync/spec-snapshot.json").read_text()) + new_spec["components"]["schemas"]["ColorOut"]["properties"]["color"]["enum"].append("green") + new_spec["components"]["schemas"]["ColorOut"]["description"] = "café" # non-ascii, must not get \u-escaped + # deliberately unusual (but still valid) formatting -- compact separators, + # no trailing newline -- that a naive json.dumps of the parsed object + # would normalize away + weird_bytes = json.dumps(new_spec, indent=None, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + spec_path = repo / ".api-sync/spec-current.json" + spec_path.write_bytes(weird_bytes) + + sync = load_sync(repo) + assert sync.cmd_apply(sync.DEFAULT_SPEC_PATH, None) == 0 + + snapshot_bytes = (repo / ".api-sync/spec-snapshot.json").read_bytes() + assert snapshot_bytes == weird_bytes + + def test_property_only_change_bumps_patch(self, repo: Path): + self._setup(repo) + new_spec = json.loads((repo / ".api-sync/spec-snapshot.json").read_text()) + new_spec["components"]["schemas"]["PlainOut"]["properties"]["extra"] = {"type": "string"} + write(repo, ".api-sync/spec-current.json", json.dumps(new_spec)) + + report_path = repo / "report.json" + sync = load_sync(repo) + exit_code = sync.cmd_apply(sync.DEFAULT_SPEC_PATH, report_path) + assert exit_code == 0 + report = json.loads(report_path.read_text()) + assert report["bump"] == "patch" + + # Plain is total=True, so the newly added field must be NotRequired -- + # a bare "extra: str" would make it a required key and break every + # existing caller that omits it. + sample_src = (repo / "src/blindpay/resources/sample.py").read_text() + assert "extra: NotRequired[str]" in sample_src + + def test_apply_twice_is_idempotent(self, repo: Path): + self._setup(repo) + new_spec = json.loads((repo / ".api-sync/spec-snapshot.json").read_text()) + new_spec["components"]["schemas"]["ColorOut"]["properties"]["color"]["enum"].append("green") + new_spec["components"]["schemas"]["PlainOut"]["properties"]["extra"] = {"type": "string"} + write(repo, ".api-sync/spec-current.json", json.dumps(new_spec)) + + sync = load_sync(repo) + first_report = repo / "first.json" + assert sync.cmd_apply(sync.DEFAULT_SPEC_PATH, first_report) == 0 + first = json.loads(first_report.read_text()) + assert len(first["applied"]) == 2 + + second_report = repo / "second.json" + assert sync.cmd_apply(sync.DEFAULT_SPEC_PATH, second_report) == 0 + second = json.loads(second_report.read_text()) + assert second["applied"] == [] + assert second["bump"] is None + + def test_check_is_green_after_apply(self, repo: Path): + """--check reconciles against spec-snapshot.json directly (state, not a + diff): pending drift is whatever the already-committed baseline says is + true but the code has not caught up to, exactly like the real + BankingPartner/portage gap this project found (already in the + committed spec-snapshot.json, no new spec delivery needed to see it).""" + self._setup(repo) + # Simulate drift baked into the baseline itself: the snapshot already + # knows about "green", the code does not. + snapshot = json.loads((repo / ".api-sync/spec-snapshot.json").read_text()) + snapshot["components"]["schemas"]["ColorOut"]["properties"]["color"]["enum"].append("green") + write(repo, ".api-sync/spec-snapshot.json", json.dumps(snapshot)) + write(repo, ".api-sync/spec-current.json", json.dumps(snapshot)) + + sync = load_sync(repo) + assert sync.cmd_check(None) == 1 # pending drift before apply + assert sync.cmd_apply(sync.DEFAULT_SPEC_PATH, None) == 0 + assert sync.cmd_check(None) == 0 # green after apply + + +class TestDeterminism: + def test_two_independent_applies_produce_byte_identical_trees(self, tmp_path: Path): + def build(root: Path) -> None: + _write_fixture_repo(root) + map_data: dict[str, Any] = { + "enums": [ + { + "spec": {"schema": "ColorOut", "property": "color"}, + "sdk": {"file": "src/blindpay/types.py", "symbol": "Color"}, + } + ], + "types": [ + {"spec": "PlainOut", "sdk": [{"file": "src/blindpay/resources/sample.py", "symbol": "Plain"}]} + ], + "ignore": {"schemas": []}, + } + _write_map_and_unmodeled(root, map_data) + old_spec = base_schema( + { + "ColorOut": {"properties": {"color": {"type": "string", "enum": ["red", "blue"]}}}, + "PlainOut": {"properties": {"id": {"type": "string"}}}, + }, + {"/c": {"get": op(ref_out="ColorOut")}, "/p": {"get": op(ref_out="PlainOut")}}, + ) + write(root, ".api-sync/spec-snapshot.json", json.dumps(old_spec)) + new_spec = json.loads(json.dumps(old_spec)) + new_spec["components"]["schemas"]["ColorOut"]["properties"]["color"]["enum"].append("green") + new_spec["components"]["schemas"]["PlainOut"]["properties"]["extra"] = {"type": "string"} + write(root, ".api-sync/spec-current.json", json.dumps(new_spec)) + + root_a = tmp_path / "a" + root_b = tmp_path / "b" + build(root_a) + build(root_b) + + sync_a = load_sync(root_a) + sync_b = load_sync(root_b) + assert sync_a.cmd_apply(sync_a.DEFAULT_SPEC_PATH, None) == 0 + assert sync_b.cmd_apply(sync_b.DEFAULT_SPEC_PATH, None) == 0 + + for rel in ["src/blindpay/types.py", "src/blindpay/resources/sample.py", ".api-sync/spec-snapshot.json"]: + assert (root_a / rel).read_bytes() == (root_b / rel).read_bytes(), rel + + +# --------------------------------------------------------------------------- # +# unmodeled.json loading/validation +# --------------------------------------------------------------------------- # + + +class TestUnmodeledLoading: + def test_property_entry_requires_all_keys(self, repo: Path): + write(repo, ".api-sync/unmodeled.json", json.dumps([{"kind": "property", "schema": "X", "field": "y"}])) + sync = load_sync(repo) + with pytest.raises(SystemExit): + sync.load_unmodeled() + + def test_enum_entry_requires_all_keys(self, repo: Path): + write(repo, ".api-sync/unmodeled.json", json.dumps([{"kind": "enum", "enum": "X"}])) + sync = load_sync(repo) + with pytest.raises(SystemExit): + sync.load_unmodeled() + + def test_unknown_kind_rejected(self, repo: Path): + write(repo, ".api-sync/unmodeled.json", json.dumps([{"kind": "bogus"}])) + sync = load_sync(repo) + with pytest.raises(SystemExit): + sync.load_unmodeled() + + def test_valid_entries_load(self, repo: Path): + write( + repo, + ".api-sync/unmodeled.json", + json.dumps( + [ + {"kind": "property", "schema": "X", "field": "y", "reason": "r", "owner": "eric@blindpay.com"}, + {"kind": "enum", "enum": "X", "missing_values": ["a"], "reason": "r", "owner": "eric@blindpay.com"}, + ] + ), + ) + sync = load_sync(repo) + assert len(sync.load_unmodeled()) == 2 + + +# --------------------------------------------------------------------------- # +# Reachability +# --------------------------------------------------------------------------- # + + +class TestReachability: + def test_orphan_schema_excluded_by_construction(self): + sync = load_sync(Path("/nonexistent")) + spec = base_schema( + { + "Reachable": {"properties": {"a": {"type": "string"}}}, + "Orphan": {"properties": {"b": {"type": "string"}}}, + }, + {"/x": {"get": op(ref_out="Reachable")}}, + ) + reachable = sync.compute_reachable_schemas(spec) + assert reachable == {"Reachable"} + + def test_nested_ref_transitively_reachable(self): + sync = load_sync(Path("/nonexistent")) + spec = base_schema( + { + "Reachable": {"properties": {"child": {"$ref": "#/components/schemas/Child"}}}, + "Child": {"properties": {"a": {"type": "string"}}}, + }, + {"/x": {"get": op(ref_out="Reachable")}}, + ) + reachable = sync.compute_reachable_schemas(spec) + assert reachable == {"Reachable", "Child"} + + +# --------------------------------------------------------------------------- # +# --audit-types: full state comparison of every mapped property's spec type +# against the SDK's CURRENT annotation, independent of any old-vs-new diff. +# --------------------------------------------------------------------------- # + + +class TestAuditPropertyType: + def test_nullable_spec_without_optional_sdk_is_flagged(self): + sync = load_sync(Path("/nonexistent")) + note = sync.audit_property_type({"type": ["string", "null"]}, "str") + assert note is not None and "Optional" in note + + def test_nullable_spec_with_optional_sdk_is_not_flagged(self): + sync = load_sync(Path("/nonexistent")) + assert sync.audit_property_type({"type": ["string", "null"]}, "Optional[str]") is None + + def test_non_nullable_spec_with_optional_sdk_is_not_flagged(self): + """SDK wider than necessary (Optional where spec never sends null) is a + legitimate, common, harmless modeling choice -- not reported.""" + sync = load_sync(Path("/nonexistent")) + assert sync.audit_property_type({"type": "string"}, "Optional[str]") is None + + def test_enum_property_with_bare_scalar_sdk_is_flagged(self): + sync = load_sync(Path("/nonexistent")) + note = sync.audit_property_type({"type": "string", "enum": ["a", "b"]}, "str") + assert note is not None and "enum-constrained" in note + + def test_enum_property_with_literal_reference_is_not_flagged(self): + sync = load_sync(Path("/nonexistent")) + assert sync.audit_property_type({"type": "string", "enum": ["a", "b"]}, "MyLiteral") is None + + def test_scalar_type_mismatch_is_flagged(self): + """The one real finding this audit turned up in this repo: + PayinOut.billing_fee_amount is `number` on the wire but `Optional[str]` + in the SDK.""" + sync = load_sync(Path("/nonexistent")) + note = sync.audit_property_type({"type": "number"}, "Optional[str]") + assert note is not None and "`number`" in note and "`str`" in note + + def test_integer_widened_to_float_is_deliberately_compatible(self): + sync = load_sync(Path("/nonexistent")) + assert sync.audit_property_type({"type": "integer"}, "float") is None + + def test_ambiguous_spec_type_is_not_flagged(self): + sync = load_sync(Path("/nonexistent")) + assert sync.audit_property_type({"example": "abc"}, "str") is None + + def test_array_type_mismatch_is_flagged(self): + sync = load_sync(Path("/nonexistent")) + note = sync.audit_property_type({"type": "array"}, "str") + assert note is not None and "array" in note + + def test_array_type_match_is_not_flagged(self): + sync = load_sync(Path("/nonexistent")) + assert sync.audit_property_type({"type": "array"}, "List[str]") is None + + +class TestAuditTypes: + def test_finds_scalar_mismatch_end_to_end(self, repo: Path): + _write_fixture_repo(repo) + map_data: dict[str, Any] = { + "enums": [], + "types": [{"spec": "PlainOut", "sdk": [{"file": "src/blindpay/resources/sample.py", "symbol": "Plain"}]}], + "ignore": {"schemas": []}, + } + _write_map_and_unmodeled(repo, map_data) + sync = load_sync(repo) + # Plain.name: Optional[str] in the SDK; make the spec say it's really a number + spec = base_schema( + {"PlainOut": {"properties": {"id": {"type": "string"}, "name": {"type": "number"}}}}, + {"/x": {"get": op(ref_out="PlainOut")}}, + ) + findings = sync.audit_types(spec, map_data) + assert any(f["field"] == "name" and "number" in f["note"] for f in findings) + + def test_shared_locators_are_merged_into_one_finding(self, repo: Path): + """The exact duplication bug this audit's own development caught: a + map entry with several spec locators asserted to share one shape must + not multiply the same real finding once per locator.""" + _write_fixture_repo(repo) + map_data: dict[str, Any] = { + "enums": [], + "types": [ + { + "spec": ["ParentOut", "SiblingOut"], + "sdk": [{"file": "src/blindpay/resources/sample.py", "symbol": "Plain"}], + } + ], + "ignore": {"schemas": []}, + } + _write_map_and_unmodeled(repo, map_data) + sync = load_sync(repo) + shape = {"id": {"type": "number"}, "name": {"type": ["string", "null"]}} + spec = base_schema( + {"ParentOut": {"properties": shape}, "SiblingOut": {"properties": shape}}, + {"/x": {"get": op(ref_out="ParentOut")}, "/y": {"get": op(ref_out="SiblingOut")}}, + ) + findings = sync.audit_types(spec, map_data) + id_findings = [f for f in findings if f["field"] == "id"] + assert len(id_findings) == 1 + assert "ParentOut" in id_findings[0]["schema"] diff --git a/uv.lock b/uv.lock index eb46cea..7a8c709 100644 --- a/uv.lock +++ b/uv.lock @@ -27,7 +27,7 @@ wheels = [ [[package]] name = "blindpay" -version = "2.5.0" +version = "3.0.0" source = { editable = "." } dependencies = [ { name = "httpx" },