From aee7cd3f025ee5a93805c72c8efec5e6d22b4484 Mon Sep 17 00:00:00 2001 From: Eric Viana Date: Tue, 4 Aug 2026 15:41:58 -0300 Subject: [PATCH] fix(api-sync): widen untyped body literals before conditional field assignment The generator's applyResourcesField always inserts "if (options.x !== undefined) body.field = options.x" after a function's existing body declaration. That only typechecks when body is already Record (createBankAccount, updateCustomer, ...); functions that still declare a plain "const body = {...}" literal (createQuote, createPayin, ...) get a closed object type, so the new assignment fails with TS2339. This is what broke the API Sync run that tried to add refund_wallet_address to createQuote's body: the field itself is live on the current QuoteIn schema and used by the payouts refund path, only the generator's patch was wrong. Widen the declaration to Record whenever the generator adds a conditional field to an untyped body literal, matching the convention already used by hand-written conditional-field functions. Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs --- scripts/api-sync/__tests__/apply.test.ts | 21 +++++++++++++++++++++ scripts/api-sync/apply.ts | 9 +++++++++ 2 files changed, 30 insertions(+) diff --git a/scripts/api-sync/__tests__/apply.test.ts b/scripts/api-sync/__tests__/apply.test.ts index a1f6237..59302e6 100644 --- a/scripts/api-sync/__tests__/apply.test.ts +++ b/scripts/api-sync/__tests__/apply.test.ts @@ -35,6 +35,27 @@ describe('applyResourcesField', () => { expect(patched).toContain('externalId?: string') expect(patched).toContain('if (options.externalId !== undefined) body.external_id = options.externalId') }) + + // Regression: createQuote's body starts as a plain object literal + // (`const body = {...}`), not `Record`. Assigning a new key + // to it after the fact doesn't typecheck (TS2339), which is exactly what + // broke CI when this generator added refund_wallet_address to it. + test('widens an untyped "const body = {" to Record so the new field assignment typechecks', () => { + const change: ApplicableChange = { resource: 'quotes', op: 'create', fn: 'createQuote', field: 'refund_wallet_address', type: 'string', required: false } + const patched = applyResourcesField(real('src/commands/resources.ts'), change) + const fnStart = patched.indexOf('export async function createQuote(') + const fnRegion = patched.slice(fnStart, patched.indexOf('export async function', fnStart + 1)) + expect(fnRegion).toContain('const body: Record = {') + expect(fnRegion).toContain('if (options.refundWalletAddress !== undefined) body.refund_wallet_address = options.refundWalletAddress') + }) + + test('leaves an already-typed "const body: Record =" untouched', () => { + const change: ApplicableChange = { resource: 'bank_accounts', op: 'create', fn: 'createBankAccount', field: 'clabe', type: 'string', required: false } + const patched = applyResourcesField(real('src/commands/resources.ts'), change) + const fnStart = patched.indexOf('export async function createBankAccount(') + const fnRegion = patched.slice(fnStart, patched.indexOf('export async function', fnStart + 1)) + expect(fnRegion).not.toContain('Record = Record') + }) }) describe('applyIndexOption', () => { diff --git a/scripts/api-sync/apply.ts b/scripts/api-sync/apply.ts index 8ab09c4..b4d4b56 100644 --- a/scripts/api-sync/apply.ts +++ b/scripts/api-sync/apply.ts @@ -76,6 +76,15 @@ export function applyResourcesField(src: string, change: ApplicableChange): stri const requestIndent = leadingWhitespace(lines[requestIdx]) lines.splice(requestIdx, 0, `${requestIndent}if (options.${fieldCamel} !== undefined) body.${change.field} = options.${fieldCamel}`) + // The pass-through line above assigns a key that isn't in body's initial + // literal, so body needs a permissive type or TS rejects the assignment + // (an untyped `const body = {...}` infers a closed object type). Widen it + // the same way hand-written conditional-field functions already do. + const bodyDeclRe = /^(\s*)const body = / + const bodyDeclIdx = lines.findIndex(l => bodyDeclRe.test(l)) + if (bodyDeclIdx !== -1) + lines[bodyDeclIdx] = lines[bodyDeclIdx].replace('const body = ', 'const body: Record = ') + const patchedFn = lines.join('\n') return src.slice(0, declIdx) + patchedFn + src.slice(fnEnd) }