From 12c659ff996a1efc75e2681d0845937dee7126ab Mon Sep 17 00:00:00 2001 From: Timothy Lowrimore Date: Fri, 22 May 2026 15:35:43 -0600 Subject: [PATCH 1/3] chore: add SDK migration codemod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a ts-morph–based codemod that mechanically migrates a single oclif command from raw this.heroku.(path) calls to @heroku/sdk platform resource methods. The codemod reverse-looks-up each (verb, path) pair against @heroku/types/3.sdk/routes (the SDK's generated route metadata), so the mapping table stays in sync with the SDK without hand curation. Cases the codemod cannot safely transform are flagged with TODO(sdk-migration) markers and the original call is preserved. Run with --dry-run to preview, then again without to apply in place. Adds ts-morph and tsx as devDependencies. --- cspell-dictionary.txt | 3 + package-lock.json | 581 ++++++++++++++++++ package.json | 2 + scripts/codemods/sdk-migration/README.md | 59 ++ .../codemods/sdk-migration/migrate-command.ts | 116 ++++ .../codemods/sdk-migration/routes-index.ts | 80 +++ scripts/codemods/sdk-migration/transform.ts | 329 ++++++++++ 7 files changed, 1170 insertions(+) create mode 100644 scripts/codemods/sdk-migration/README.md create mode 100644 scripts/codemods/sdk-migration/migrate-command.ts create mode 100644 scripts/codemods/sdk-migration/routes-index.ts create mode 100644 scripts/codemods/sdk-migration/transform.ts diff --git a/cspell-dictionary.txt b/cspell-dictionary.txt index adce52de71..dce64fbda6 100644 --- a/cspell-dictionary.txt +++ b/cspell-dictionary.txt @@ -49,6 +49,9 @@ ckey clearsign clientsecret cnames +codemod +codemodparam +codemods collab commandsstop compadd diff --git a/package-lock.json b/package-lock.json index a1901c98aa..80143044c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -140,7 +140,9 @@ "rimraf": "5.0.5", "sinon": "^21.0.2", "source-map-support": "^0.5.21", + "ts-morph": "^28.0.0", "ts-node": "^10.9.2", + "tsx": "^4.22.3", "typescript": "5.9.3" }, "engines": { @@ -1938,6 +1940,448 @@ "node": ">=18" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -7636,6 +8080,57 @@ "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "license": "MIT" }, + "node_modules/@ts-morph/common": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.29.0.tgz", + "integrity": "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.14" + } + }, + "node_modules/@ts-morph/common/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@ts-morph/common/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -10406,6 +10901,13 @@ "node": ">=0.8" } }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "dev": true, + "license": "MIT" + }, "node_modules/code-excerpt": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", @@ -12125,6 +12627,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -20570,6 +21114,13 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, "node_modules/path-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz", @@ -23183,6 +23734,17 @@ "typescript": ">=4.0.0" } }, + "node_modules/ts-morph": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-28.0.0.tgz", + "integrity": "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.29.0", + "code-block-writer": "^13.0.3" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -23283,6 +23845,25 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", diff --git a/package.json b/package.json index 287808bca9..499f44ba44 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,9 @@ "rimraf": "5.0.5", "sinon": "^21.0.2", "source-map-support": "^0.5.21", + "ts-morph": "^28.0.0", "ts-node": "^10.9.2", + "tsx": "^4.22.3", "typescript": "5.9.3" }, "oclif": { diff --git a/scripts/codemods/sdk-migration/README.md b/scripts/codemods/sdk-migration/README.md new file mode 100644 index 0000000000..9d5e770fe5 --- /dev/null +++ b/scripts/codemods/sdk-migration/README.md @@ -0,0 +1,59 @@ +# SDK Migration Codemod + +Mechanically migrates a single oclif command file from raw `this.heroku.(path)` calls to `@heroku/sdk` platform resource methods. Used as Task 1 of the SDK command migration playbook. + +## Usage + +```bash +# Preview changes +npx tsx scripts/codemods/sdk-migration/migrate-command.ts \ + --dry-run src/commands/apps/info.ts + +# Apply in-place +npx tsx scripts/codemods/sdk-migration/migrate-command.ts \ + src/commands/apps/info.ts +``` + +Run on **one file at a time** for clean review. Passing multiple paths is supported but each file is migrated independently. + +## What it does + +For each `this.heroku.(...)` call: + +1. Looks up the `(verb, path)` pair in `@heroku/types/3.sdk/routes` to find the matching SDK resource and method. +2. Replaces the call with `platform..(...)`, mapping path placeholders to positional arguments in declaration order. +3. Drops the surrounding `{body: x}` destructure (the SDK returns the body directly). +4. Adds `import {HerokuSDK} from '@heroku/sdk'` and inserts `const {platform} = new HerokuSDK()` at the top of `run()` if missing. +5. Removes `import * as Heroku from '@heroku-cli/schema'` if no remaining references. + +## What it flags (does not auto-fix) + +The codemod inserts a `// TODO(sdk-migration): ` comment above call sites it cannot safely transform, and exits non-zero. Reasons include: + +- The path argument is not a string literal or template literal (e.g., a variable computed elsewhere). +- No SDK route maps to the `(verb, path)` pair. +- The call passes a second argument (request options) the SDK method does not accept. +- The number of template placeholders does not match the SDK route's path placeholders. + +For these, the original call is left in place. Resolve manually before continuing the migration. + +## What it does NOT do + +Out of scope by design — these belong to the agent executing the playbook: + +- **Type cast adjustments.** Expressions like `as App[]` or `as unknown as App[]` at call sites. +- **Helper signature tightening.** When a helper parameter was typed as `Heroku.X` to mean "an array of X", honest retyping plus call-site fixes (destructuring tuples from `_.partition`, etc.) is manual. +- **Test rewrites.** The companion task in the playbook stubs the SDK directly via `sinon.stub(HerokuSDK.prototype, 'platform').get(...)`. The codemod only touches `src/commands/`. +- **Compositions.** Files importing from `@heroku/sdk/compositions/*` were broken by the 0.4 SDK release and need a separate migration. + +## How the route index is built + +At startup, the codemod imports `@heroku/types/3.sdk/routes` (a generated metadata file shipped with the SDK types) and indexes every route by `(httpVerb, pathRegex)`. A concrete path like `/apps/foo/dynos` matches against the regex form of `/apps/{appIdentity}/dynos`. The matching route's `resource` and `method` come from the export name and key. + +The index detects collisions (two routes with the same verb+path) at load time and aborts. None exist today, but a future SDK schema change could introduce one. + +## Extending + +If a needed route has no SDK mapping, the right fix is in the SDK package, not here. The codemod is intentionally a pass-through over `@heroku/types/3.sdk/routes` — adding hand-curated mappings would create drift. + +If a calling pattern in the CLI doesn't match the codemod's recognizers (e.g., `this.heroku.get(...)` aliased through a helper), update `transform.ts` to recognize the new shape rather than working around it in the source. diff --git a/scripts/codemods/sdk-migration/migrate-command.ts b/scripts/codemods/sdk-migration/migrate-command.ts new file mode 100644 index 0000000000..43c71c4dd0 --- /dev/null +++ b/scripts/codemods/sdk-migration/migrate-command.ts @@ -0,0 +1,116 @@ +#!/usr/bin/env -S npx tsx +import {readFileSync} from 'node:fs' +import {resolve} from 'node:path' +import {IndentationText, NewLineKind, Project, QuoteKind} from 'ts-morph' + +import {RouteIndex} from './routes-index.js' +import {transform, type TransformResult} from './transform.js' + +type CliOptions = { + dryRun: boolean + files: string[] +} + +function parseArgs(argv: string[]): CliOptions { + const opts: CliOptions = {dryRun: false, files: []} + for (const arg of argv) { + if (arg === '--dry-run' || arg === '-n') opts.dryRun = true + else if (arg === '--help' || arg === '-h') { + printHelp() + process.exit(0) + } else if (arg.startsWith('-')) { + console.error(`unknown flag: ${arg}`) + process.exit(2) + } else { + opts.files.push(arg) + } + } + + if (opts.files.length === 0) { + printHelp() + process.exit(2) + } + + return opts +} + +function printHelp(): void { + console.log(`Usage: migrate-command [--dry-run] [...more] + +Migrates a single oclif command file from raw this.heroku.(path) calls +to @heroku/sdk platform resource methods. + +Flags: + --dry-run, -n Print the proposed diff without writing the file. + --help, -h Show this help. + +Notes: + - Pass exactly one command source file per invocation for clean review. + - Multiple files in one invocation are supported but each is migrated independently. + - Calls the codemod cannot resolve are flagged with TODO comments and the file + is still written; the agent must resolve those manually. +`) +} + +async function main(): Promise { + const opts = parseArgs(process.argv.slice(2)) + const index = RouteIndex.load() + + const project = new Project({ + manipulationSettings: { + indentationText: IndentationText.TwoSpaces, + newLineKind: NewLineKind.LineFeed, + quoteKind: QuoteKind.Single, + useTrailingCommas: true, + }, + skipAddingFilesFromTsConfig: true, + tsConfigFilePath: resolve(process.cwd(), 'tsconfig.json'), + }) + + let totalUnmatched = 0 + for (const file of opts.files) { + const absPath = resolve(file) + const before = readFileSync(absPath, 'utf8') + const sourceFile = project.addSourceFileAtPath(absPath) + const result = transform(sourceFile, index) + reportFile(absPath, result, before, sourceFile.getFullText(), opts.dryRun) + totalUnmatched += result.unmatched + if (!opts.dryRun && result.changed) await sourceFile.save() + } + + if (totalUnmatched > 0) { + console.error(`\n${totalUnmatched} call site(s) could not be migrated automatically. See TODO(sdk-migration) markers.`) + process.exit(1) + } +} + +function reportFile(path: string, result: TransformResult, before: string, after: string, dryRun: boolean): void { + const verb = dryRun ? 'would update' : 'updated' + if (!result.changed) { + console.log(`no change: ${path}`) + return + } + + console.log(`${verb}: ${path}`) + if (result.flags.length > 0) { + console.log(' flagged call sites:') + for (const f of result.flags) console.log(` - ${f}`) + } + + if (result.warnings.length > 0) { + console.log(' warnings:') + for (const w of result.warnings) console.log(` - ${w}`) + } + + if (dryRun) { + console.log('--- before ---') + console.log(before) + console.log('--- after ---') + console.log(after) + } +} + +main().catch(error => { + console.error(error) + process.exit(1) +}) diff --git a/scripts/codemods/sdk-migration/routes-index.ts b/scripts/codemods/sdk-migration/routes-index.ts new file mode 100644 index 0000000000..33651a9beb --- /dev/null +++ b/scripts/codemods/sdk-migration/routes-index.ts @@ -0,0 +1,80 @@ +import * as routes from '@heroku/types/3.sdk/routes' + +export type HttpVerb = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT' + +export type RouteEntry = { + hasRequestBody: boolean + method: string + path: string + pathRegex: RegExp + placeholders: string[] + resource: string + verb: HttpVerb +} + +export type RouteLookupResult = { + args: string[] + entry: RouteEntry +} + +export class RouteIndex { + private readonly byVerb: Map + + constructor(entries: RouteEntry[]) { + this.byVerb = new Map() + for (const entry of entries) { + const list = this.byVerb.get(entry.verb) ?? [] + list.push(entry) + this.byVerb.set(entry.verb, list) + } + } + + static load(): RouteIndex { + const entries: RouteEntry[] = [] + for (const [resource, methods] of Object.entries(routes)) { + if (resource === 'default' || typeof methods !== 'object' || methods === null) continue + for (const [method, def] of Object.entries(methods as Record)) { + entries.push(buildEntry(resource, method, def)) + } + } + + return new RouteIndex(entries) + } + + lookup(verb: HttpVerb, concretePath: string): null | RouteLookupResult { + const candidates = this.byVerb.get(verb) ?? [] + const matches: RouteLookupResult[] = [] + for (const entry of candidates) { + const match = concretePath.match(entry.pathRegex) + if (match) matches.push({args: match.slice(1), entry}) + } + + if (matches.length === 0) return null + if (matches.length > 1) { + throw new Error( + `ambiguous route resolution for ${verb} ${concretePath}: ` + + matches.map(m => `${m.entry.resource}.${m.entry.method}`).join(', '), + ) + } + + return matches[0] + } +} + +function buildEntry(resource: string, method: string, def: {hasRequestBody?: boolean; method: string; path: string}): RouteEntry { + const placeholders = [...def.path.matchAll(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g)].map(m => m[1]) + return { + hasRequestBody: Boolean(def.hasRequestBody), + method, + path: def.path, + pathRegex: pathToRegex(def.path), + placeholders, + resource, + verb: def.method as HttpVerb, + } +} + +function pathToRegex(path: string): RegExp { + const escaped = path.replace(/[.+*?^$()|[\]\\]/g, '\\$&').replace(/\{[a-zA-Z][a-zA-Z0-9]*\}/g, '([^/]+)') + return new RegExp(`^${escaped}$`) +} diff --git a/scripts/codemods/sdk-migration/transform.ts b/scripts/codemods/sdk-migration/transform.ts new file mode 100644 index 0000000000..364e0f3a95 --- /dev/null +++ b/scripts/codemods/sdk-migration/transform.ts @@ -0,0 +1,329 @@ +import { + type AwaitExpression, + type CallExpression, + Node, + type SourceFile, + SyntaxKind, + type TemplateExpression, + type VariableDeclaration, +} from 'ts-morph' + +import {type HttpVerb, RouteIndex} from './routes-index.js' + +export type TransformResult = { + changed: boolean + flags: string[] + unmatched: number + warnings: string[] +} + +const VERBS: Record = { + delete: 'DELETE', + get: 'GET', + patch: 'PATCH', + post: 'POST', + put: 'PUT', +} + +const HEROKU_SCHEMA_IMPORT = '@heroku-cli/schema' +const HEROKU_SDK_IMPORT = '@heroku/sdk' + +export function transform(sourceFile: SourceFile, index: RouteIndex): TransformResult { + const result: TransformResult = {changed: false, flags: [], unmatched: 0, warnings: []} + + const calls = collectHerokuCalls(sourceFile) + if (calls.length === 0) return result + + // Process from bottom-up so earlier edits don't invalidate later node references. + calls.sort((a, b) => b.call.getStart() - a.call.getStart()) + + for (const ctx of calls) { + if (ctx.call.wasForgotten()) continue + const replaced = replaceCall(ctx, index, result) + if (replaced) result.changed = true + } + + if (result.changed) { + ensureSdkSetup(sourceFile, result) + pruneUnusedSchemaImport(sourceFile) + } + + return result +} + +type CallContext = { + call: CallExpression + verb: HttpVerb + verbName: string +} + +function collectHerokuCalls(sourceFile: SourceFile): CallContext[] { + const out: CallContext[] = [] + sourceFile.forEachDescendant(node => { + if (!Node.isCallExpression(node)) return + const expr = node.getExpression() + if (!Node.isPropertyAccessExpression(expr)) return + const verbName = expr.getName() + const verb = VERBS[verbName] + if (!verb) return + const receiver = expr.getExpression() + if (!Node.isPropertyAccessExpression(receiver)) return + if (receiver.getName() !== 'heroku') return + if (receiver.getExpression().getKind() !== SyntaxKind.ThisKeyword) return + out.push({call: node, verb, verbName}) + }) + return out +} + +function replaceCall(ctx: CallContext, index: RouteIndex, result: TransformResult): boolean { + const {call, verb, verbName} = ctx + const args = call.getArguments() + if (args.length === 0) { + flagCall(call, `no path argument for this.heroku.${verbName}`, result) + return false + } + + const pathArg = args[0] + const pathInfo = extractPath(pathArg) + if (!pathInfo) { + flagCall(call, `cannot statically extract path from this.heroku.${verbName}(...)`, result) + result.unmatched++ + return false + } + + let lookup + try { + lookup = index.lookup(verb, pathInfo.concretePath) + } catch (error: unknown) { + flagCall(call, (error as Error).message, result) + result.unmatched++ + return false + } + + if (!lookup) { + flagCall(call, `no SDK route maps to ${verb} ${pathInfo.concretePath}`, result) + result.unmatched++ + return false + } + + const {entry} = lookup + const placeholderSlots = entry.path.match(/\{[a-zA-Z][a-zA-Z0-9]*\}/g) ?? [] + if (placeholderSlots.length !== pathInfo.params.length) { + flagCall( + call, + `placeholder count mismatch: SDK route ${entry.path} has ${placeholderSlots.length} but call provided ${pathInfo.params.length}`, + result, + ) + result.unmatched++ + return false + } + + const sdkArgs = [...pathInfo.params] + if (entry.hasRequestBody) { + if (args.length < 2) { + flagCall(call, `SDK method platform.${entry.resource}.${entry.method} requires a request body`, result) + result.unmatched++ + return false + } + + const bodyArg = args[1] + const bodyText = unwrapHttpCallBody(bodyArg) + if (bodyText === null) { + flagCall( + call, + `cannot determine SDK request body shape for platform.${entry.resource}.${entry.method}; review the second argument manually`, + result, + ) + result.unmatched++ + return false + } + + sdkArgs.push(bodyText) + } else if (args.length > 1) { + // Don't replace this call — too risky to silently drop a request-options arg. + flagCall( + call, + `this.heroku.${verbName}(...) has an extra argument (request options?) that platform.${entry.resource}.${entry.method} does not accept; review manually`, + result, + ) + result.unmatched++ + return false + } + + const replacement = `platform.${entry.resource}.${entry.method}(${sdkArgs.join(', ')})` + + const wrapping = findEnclosingAwaitOrBindingContext(call) + rewriteCallSite(call, replacement, wrapping, result) + return true +} + +type PathInfo = { + /** Path with placeholders replaced by a non-slash sentinel so route regexes match. */ + concretePath: string + /** Source-form expressions for each placeholder, in order. */ + params: string[] +} + +const PARAM_SENTINEL = 'codemodparam' + +function extractPath(node: Node): null | PathInfo { + if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) { + return {concretePath: node.getLiteralValue(), params: []} + } + + if (Node.isTemplateExpression(node)) { + return extractFromTemplate(node) + } + + return null +} + +function unwrapHttpCallBody(node: Node): null | string { + // The CLI's http-call wraps requests as `{body: , ...}`. The SDK takes the body directly. + // If the second arg is an object literal with a single `body` property, return its value. + if (!Node.isObjectLiteralExpression(node)) { + // Could be a variable holding the body; pass it through as-is. + return node.getText() + } + + const properties = node.getProperties() + if (properties.length !== 1) return null + + const property = properties[0] + if (!Node.isPropertyAssignment(property)) return null + if (property.getName() !== 'body') return null + + return property.getInitializer()?.getText() ?? null +} + +function extractFromTemplate(template: TemplateExpression): PathInfo { + const params: string[] = [] + let concretePath = template.getHead().getLiteralText() + + for (const span of template.getTemplateSpans()) { + params.push(span.getExpression().getText()) + concretePath += PARAM_SENTINEL + concretePath += span.getLiteral().getLiteralText() + } + + return {concretePath, params} +} + +function rewriteCallSite( + call: CallExpression, + replacement: string, + wrapping: WrappingContext, + result: TransformResult, +): void { + if (wrapping.kind === 'await-with-body-destructure') { + const decl = wrapping.declaration + const newName = wrapping.bodyAlias + decl.replaceWithText(`${newName} = await ${replacement}`) + return + } + + if (wrapping.kind === 'await') { + call.replaceWithText(replacement) + return + } + + if (wrapping.kind === 'bare-promise') { + call.replaceWithText(replacement) + return + } + + result.warnings.push(`unrecognized call wrapping for replacement at line ${call.getStartLineNumber()}`) + call.replaceWithText(replacement) +} + +type WrappingContext = + | {await: AwaitExpression; bodyAlias: string; declaration: VariableDeclaration; kind: 'await-with-body-destructure'} + | {await: AwaitExpression; kind: 'await'} + | {kind: 'bare-promise'} + +function findEnclosingAwaitOrBindingContext(call: CallExpression): WrappingContext { + const parent = call.getParent() + if (!parent || !Node.isAwaitExpression(parent)) { + return {kind: 'bare-promise'} + } + + const awaitNode = parent + const declaration = awaitNode.getParentIfKind(SyntaxKind.VariableDeclaration) + if (!declaration) return {await: awaitNode, kind: 'await'} + + const nameNode = declaration.getNameNode() + if (!Node.isObjectBindingPattern(nameNode)) return {await: awaitNode, kind: 'await'} + + const elements = nameNode.getElements() + if (elements.length !== 1) return {await: awaitNode, kind: 'await'} + + const element = elements[0] + const propertyNode = element.getPropertyNameNode() + const propertyName = propertyNode ? propertyNode.getText() : element.getName() + if (propertyName !== 'body') return {await: awaitNode, kind: 'await'} + + return { + await: awaitNode, + bodyAlias: element.getName(), + declaration, + kind: 'await-with-body-destructure', + } +} + +function flagCall(call: CallExpression, message: string, result: TransformResult): void { + result.flags.push(message) + const stmt = call.getFirstAncestorByKind(SyntaxKind.ExpressionStatement) ?? call.getFirstAncestorByKind(SyntaxKind.VariableStatement) + const target = stmt ?? call + const sourceFile = target.getSourceFile() + const fullText = sourceFile.getFullText() + const lineStart = fullText.lastIndexOf('\n', target.getStart() - 1) + 1 + const indent = fullText.slice(lineStart, target.getStart()).match(/^[\t ]*/)?.[0] ?? '' + target.replaceWithText(`// TODO(sdk-migration): ${message}\n${indent}${target.getText()}`) +} + +function ensureSdkSetup(sourceFile: SourceFile, result: TransformResult): void { + const existing = sourceFile.getImportDeclaration(d => d.getModuleSpecifierValue() === HEROKU_SDK_IMPORT) + if (!existing) { + // Insert at the top of the imports block; the project formats named imports without inner-brace spaces. + const firstStatement = sourceFile.getStatementsWithComments()[0] + const insertPos = firstStatement?.getStart() ?? 0 + sourceFile.insertText(insertPos, `import {HerokuSDK} from '${HEROKU_SDK_IMPORT}'\n`) + } else { + const named = existing.getNamedImports().map(n => n.getName()) + if (!named.includes('HerokuSDK')) existing.addNamedImport('HerokuSDK') + } + + const usesPlatform = sourceFile.getText().includes('platform.') + if (!usesPlatform) return + + const runMethod = sourceFile + .getClasses() + .flatMap(c => c.getInstanceMethods()) + .find(m => m.getName() === 'run') + + if (!runMethod) { + result.warnings.push('could not locate run() method to insert SDK construction') + return + } + + const body = runMethod.getBodyText() ?? '' + if (body.includes('new HerokuSDK')) return + + runMethod.insertStatements(0, 'const {platform} = new HerokuSDK()') +} + +function pruneUnusedSchemaImport(sourceFile: SourceFile): void { + const decl = sourceFile.getImportDeclaration(d => d.getModuleSpecifierValue() === HEROKU_SCHEMA_IMPORT) + if (!decl) return + + const namespace = decl.getNamespaceImport() + if (!namespace) return + + const name = namespace.getText() + const stillUsed = sourceFile + .getDescendantsOfKind(SyntaxKind.Identifier) + .filter(id => id.getText() === name && id !== namespace) + .length > 0 + + if (!stillUsed) decl.remove() +} From 9cca6019ffb3ece8366be7879c0cdc355272e4ee Mon Sep 17 00:00:00 2001 From: Timothy Lowrimore Date: Fri, 22 May 2026 15:37:22 -0600 Subject: [PATCH 2/3] chore: add sdk-command-migration skill Adds a project-local Claude skill that orchestrates the SDK migration playbook for a single oclif command: pre-flight checks, source migration via the codemod (chore: previous commit), TODO-marker resolution, type-check, and test rewrite to stub @heroku/sdk directly instead of intercepting HTTP via nock. Adjusts .gitignore so that .claude/skills/ is tracked while the rest of .claude/ (settings.local.json, etc.) remains ignored. --- .claude/skills/sdk-command-migration/SKILL.md | 289 ++++++++++++++++++ .gitignore | 4 +- 2 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/sdk-command-migration/SKILL.md diff --git a/.claude/skills/sdk-command-migration/SKILL.md b/.claude/skills/sdk-command-migration/SKILL.md new file mode 100644 index 0000000000..e2feb9de14 --- /dev/null +++ b/.claude/skills/sdk-command-migration/SKILL.md @@ -0,0 +1,289 @@ +--- +name: sdk-command-migration +description: > + Migrate a single oclif command in src/commands/ from raw this.heroku.(path) + Platform API calls to @heroku/sdk resource methods, then rewrite that command's + unit tests to stub the SDK directly instead of intercepting HTTP via nock. + Triggers: migrate to HerokuSDK, convert this command to @heroku/sdk, + apply SDK migration playbook, /sdk-command-migration. +--- + +# SDK Command Migration + +Apply once per command in `src/commands/`. Each application produces one PR-ready unit of work containing two commits: the source migration and the test rewrite. + +## When to Use + +**Invoke when:** +- The user asks to migrate a specific command (e.g., "migrate apps:create", "convert apps/info.ts to HerokuSDK"). +- The user asks you to apply the SDK migration playbook to a command. +- The user invokes `/sdk-command-migration` directly. + +**Do NOT invoke for:** +- Multi-command refactors — each command gets its own application. +- Commands that import from `@heroku/sdk/compositions/*` (the subpath was removed in 0.4 — needs separate migration). +- The `data` service (`sdk.data.*`) — this skill assumes Platform calls only. +- Helpers/libraries shared by multiple commands — migrate the helper in its own commit and link from the command commits. + +## Tech Stack + +- TypeScript with NodeNext ESM, `module: "NodeNext"`, target `ES2022`. +- oclif 4 command framework. +- `@heroku/sdk` (current branch tracks GitHub `main`); the bare entry exports `HerokuSDK` and `HerokuSDKOptions`. +- Tests use `mocha` + `chai` + `chai-as-promised` + `sinon`. Existing tests use `nock` to intercept HTTP; the rewrite drops `nock` in favor of direct SDK stubbing. + +## Process + +``` +Pre-flight ── Task 1 (codemod + manual cleanup) ── Task 2 (test rewrite) ── Verify ── Open PR +``` + +Each step is required. Do not skip. + +--- + +## Pre-flight + +Run these once per command, before any code change. They prevent the most common surprises. + +### Step P1: Confirm working tree is clean and SDK is on disk + +```bash +git status -sb +node -e "console.log(require('@heroku/sdk/package.json').version)" +node -e "const {HerokuSDK} = require('@heroku/sdk'); console.log(typeof HerokuSDK)" +``` + +Expected: no unmerged paths (`UU`), `HerokuSDK` is `function`. If the working tree is dirty, resolve before proceeding (commit, stash, or restore — depending on what's there). If `HerokuSDK` is `undefined`, the SDK is on the wrong version and the rest of this skill will not apply cleanly. + +### Step P2: Capture baseline of pre-existing failures + +```bash +npx tsc --noEmit -p tsconfig.json 2>&1 | tee /tmp/tsc-baseline.txt | tail -20 +npx mocha 'test/unit/commands/.unit.test.ts' --reporter min 2>&1 | tail -5 +``` + +Save the `tsc` baseline. Any errors present here are NOT your responsibility — your goal is "no *new* errors after migration." For the test file, capture pass/fail status; if it was already failing, stop and ask the user before continuing. + +### Step P3: Verify the command's call surface + +Read the target command file. Confirm: +- The command uses raw `this.heroku.(path)` calls (otherwise this skill doesn't apply). +- No call site streams a response, uses raw fetch, or sets custom auth (the codemod flags these but they may indicate this command needs manual migration). + +--- + +## Task 1: Migrate the command source + +Two stages: run the codemod, then resolve any TODO markers it left. + +### Step 1.1: Run the codemod (dry-run first) + +```bash +npx tsx scripts/codemods/sdk-migration/migrate-command.ts \ + --dry-run src/commands/.ts +``` + +The codemod handles the deterministic 80%: +- Replaces every recognized `this.heroku.(path)` with `platform..(...)` by reverse-lookup against `@heroku/types/3.sdk/routes`. +- Drops `{body: x}` destructure at call sites (the SDK returns the body directly). +- Unwraps the http-call options shape (`{body: {...}}`) to pass the bare body to SDK methods that take a request body. +- Adds `import {HerokuSDK} from '@heroku/sdk'` and inserts `const {platform} = new HerokuSDK()` at the top of `run()`. +- Removes `import * as Heroku from '@heroku-cli/schema'` if no remaining references. + +Inspect the diff. If it looks correct, re-run without `--dry-run` to write the file in place: + +```bash +npx tsx scripts/codemods/sdk-migration/migrate-command.ts \ + src/commands/.ts +``` + +The codemod exits non-zero if it left any `// TODO(sdk-migration): ...` markers. That's a signal, not a failure. + +### Step 1.2: Resolve TODO(sdk-migration) markers + +The codemod flags but does not auto-fix the following cases. Address each by hand: + +**"no SDK route maps to "** +The CLI calls an endpoint the SDK does not expose. Stop and escalate — silently re-introducing a raw HTTP call defeats the migration's purpose. + +**"ambiguous route resolution for : A, B, ..."** +Multiple SDK methods share the same `(verb, path)` and disambiguate via request body shape (e.g., `release.create` vs `release.rollback`, both POST `/apps/{id}/releases`). Pick the right one based on the calling code's intent and replace the raw call manually. + +**"cannot statically extract path from this.heroku.(...)"** +The path is a variable rather than a string/template literal. Trace the variable's value, then replace manually. + +**"cannot determine SDK request body shape ..."** +The second argument to a write-method call wasn't a recognizable `{body: ...}` wrapper. Inspect the argument and pass the SDK its expected body shape directly. + +**"this.heroku.(...) has an extra argument (request options?) ..."** +The CLI passed http-call options (e.g., `{hostname: 'telex.heroku.com'}`) that the SDK doesn't accept. If the call is hitting a non-default host, escalate — the SDK assumes the standard Platform host. Otherwise, drop the options and replace manually. + +### Step 1.3: Type-check + +```bash +npx tsc --noEmit -p tsconfig.json 2>&1 | grep -v -F -f /tmp/tsc-baseline.txt | tail -20 +``` + +Expected: empty output (no new errors). If new errors appear, they typically fall into: + +- **Local-type incompatibility** → use a single-step cast (`as App[]`, not `as unknown as App[]`) at the call site. Reach for `as unknown as X` only if the single-step is rejected. +- **Helper signature mismatch** → if a helper parameter was typed as `Heroku.X` to mean an array, fix the helper to `App[]` honestly. Anticipate that `lodash` operations like `_.partition` return tuples — destructure: `const [a, b] = _.partition(...)`. +- **Optional-field access** → SDK return types use `team?.name` patterns. If the calling code stored the result in a variable typed `null | string`, coerce with `?? null`. +- **Method-doesn't-exist** → stop and escalate. + +Do NOT modify type files in `src/lib/types/` to satisfy a cast in a command file. Those types exist for a reason; the cast is the right tool here. + +### Step 1.4: Run the existing tests + +```bash +npx mocha 'test/unit/commands/.unit.test.ts' --reporter min 2>&1 | tail -5 +``` + +Expected: same pass count as Pre-flight Step P2. + +If tests fail, the most likely causes: +- **The SDK call returns a slightly different shape** than the raw HTTP response. Adjust the post-call code (often: drop the `.body` destructuring) until the data flow matches what the test expects. +- **An assertion checks specific URL shape**. Skip ahead to Task 2 — the rewrite will replace the assertion with an SDK-stub assertion anyway. +- **The output formatting changed** because the SDK normalized a field. Verify against the original output format in version control before changing the assertion. + +### Step 1.5: Lint and commit Task 1 + +```bash +npx eslint src/commands/.ts +git add src/commands/.ts +git commit -m "refactor: use @heroku/sdk for command" +``` + +Lint warnings that pre-existed are acceptable. Do NOT introduce new violations; if eslint flags something, fix it (or re-run with `--fix` for stylistic-only issues, then verify the autofix didn't change semantics). + +--- + +## Task 2: Rewrite tests to stub the SDK + +This task is required, not optional. `nock` keeps intercepting after Task 1 because `@heroku/sdk` issues the same HTTP calls under the hood — but that means the existing tests are still asserting on URL shape rather than the SDK contract the migrated code now depends on. Leaving nock in place would let a future SDK change (different endpoint, batched call, retry policy) break production while tests stay green. + +The reference implementation is at `test/unit/commands/apps/index.unit.test.ts`. + +### Step 2.1: Build a `FakePlatform` shape + +List only the resources used by the migrated command: + +```ts +import {HerokuSDK} from '@heroku/sdk' +import * as sinon from 'sinon' + +type FakePlatform = { + app: {info: sinon.SinonStub; delete: sinon.SinonStub} + // ...add only the resources/methods this command actually calls +} + +function buildFakePlatform(): FakePlatform { + return { + app: {info: sinon.stub(), delete: sinon.stub()}, + } +} +``` + +### Step 2.2: Stub the `platform` getter on `HerokuSDK.prototype` + +```ts +let fakePlatform: FakePlatform + +beforeEach(function () { + fakePlatform = buildFakePlatform() + sinon.stub(HerokuSDK.prototype, 'platform').get(() => fakePlatform) +}) + +afterEach(function () { + sinon.restore() +}) +``` + +This works because `HerokuSDK.platform` is a class-prototype getter (configurable by default in ES). `sinon.stub(...).get(...)` swaps it for the duration of the test; `sinon.restore()` restores it. + +If you encounter `TypeError: Descriptor for property platform is non-configurable and non-writable`, escalate — the SDK's class shape changed and this skill needs updating. + +### Step 2.3: Wire individual stubs per test, drop `nock` interceptors + +```ts +it('does the thing', async function () { + fakePlatform.app.info.resolves({name: 'example', /* ... */}) + + const {stdout} = await runCommand(Cmd, ['--app', 'example']) + + expect(stdout).to.equal('expected output\n') + expect(fakePlatform.app.info.calledOnceWithExactly('example')).to.equal(true) +}) +``` + +Add `.calledOnceWithExactly(...)` assertions on tests where the flag-to-method routing is the unit under test. Skip them on tests that are about output formatting only — they add noise. + +### Step 2.4: Use non-mutating spreads for fixture variants + +If the old test used `Object.assign(baseFixture, {region: {name: 'eu'}})`, replace with `{...baseFixture, region: {name: 'eu'}}`. Shared mutable fixtures are a latent test-pollution bug; the rewrite is a chance to fix it. + +### Step 2.5: Run, lint, commit Task 2 + +```bash +npx mocha 'test/unit/commands/.unit.test.ts' --reporter min 2>&1 | tail -5 +npx eslint test/unit/commands/.unit.test.ts +git add test/unit/commands/.unit.test.ts +git commit -m "test(): stub @heroku/sdk directly, drop nock" +``` + +Expected: same test count, all passing. + +--- + +## Verify and finish + +### Step V1: Targeted test run + +```bash +npx mocha 'test/unit/commands//**/*.unit.test.ts' --reporter min 2>&1 | tail -5 +``` + +Use the parent directory of the migrated command. Expected: all passing. If a sibling command's tests broke, your change leaked outside the migrated file — investigate before continuing. + +### Step V2: Type-check delta + +```bash +npx tsc --noEmit -p tsconfig.json 2>&1 | grep -v -F -f /tmp/tsc-baseline.txt +``` + +Expected: empty. New errors mean the migration introduced a regression. + +### Step V3: Push and open PR + +The PR contains exactly two commits per command: +- `refactor: use @heroku/sdk for command` +- `test(): stub @heroku/sdk directly, drop nock` + +Each PR migrates exactly one command. Don't bundle multiple command migrations — review surface stays small and bisect remains useful if a regression slips through. + +--- + +## Self-Review Checklist + +Before opening the PR: + +- [ ] No `this.heroku.` calls remain in the migrated file. +- [ ] No `// TODO(sdk-migration):` markers remain. +- [ ] No `import * as Heroku from '@heroku-cli/schema'` if no longer used. +- [ ] No `as unknown as X` cast where `as X` would suffice. +- [ ] No new `tsc` errors (verify against the Pre-flight P2 baseline). +- [ ] Tests rewritten per Task 2: `nock` removed, SDK stubbed via `HerokuSDK.prototype.platform`. +- [ ] Lint clean on changed files. +- [ ] One file changed per source commit; commit messages follow the convention. +- [ ] No incidental edits to unrelated files (lockfile, type defs, sibling commands). + +--- + +## Glossary + +- **Platform service:** `sdk.platform.*` — methods covering Apps, Spaces, Teams, Account, Pipelines, etc. +- **Data service:** `sdk.data.*` — methods covering Postgres / data-stores. Out of scope for this skill. +- **Bare entry:** `import {HerokuSDK} from '@heroku/sdk'` — the canonical import. Do not use `@heroku/sdk/sdk` (removed in 0.4) or deep relative imports. +- **Pre-flight baseline:** the snapshot of `tsc`/test state captured before any migration work, used to filter pre-existing noise out of post-migration verification. +- **Codemod:** `scripts/codemods/sdk-migration/migrate-command.ts` — the deterministic transform run in Task 1, Step 1.1. diff --git a/.gitignore b/.gitignore index 7adf8286a8..da40f1b593 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,9 @@ node_modules .idea .vscode .zed -.claude +.claude/* +!.claude/skills/ +.claude/skills/*.local.* npm-shrinkwrap.json # Ignore .yarn directory from previous yarn setup From cb3bff124475915b89b4db0c5b45212f27a4e016 Mon Sep 17 00:00:00 2001 From: Timothy Lowrimore Date: Fri, 22 May 2026 16:41:18 -0600 Subject: [PATCH 3/3] feat(sdk-migration): support data routes and detect service-name shadowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codemod now indexes both @heroku/types/3.sdk/routes (Platform) and @heroku/types/data/routes (Postgres data API). Each route is tagged with its service so the emitted call uses platform.* or data.* as appropriate, and the SDK destructure at the top of run() only includes services the file actually uses (e.g., {data, platform} when a command spans both). For data routes, the codemod silently drops a sole {hostname: ...} options arg and strips hostname from {body, hostname} payloads — the SDK provides the data hostname automatically. Anything else in the options object is still flagged. Adds a non-blocking warning when a local variable in run() would shadow a destructured SDK service name (e.g., a local `const data = ...` colliding with the `data` service). Migration proceeds; the agent must rename the local before merging. Updates the README and the sdk-command-migration skill to document data-route support and the new shadowing warning. --- .claude/skills/sdk-command-migration/SKILL.md | 13 ++- scripts/codemods/sdk-migration/README.md | 23 ++-- .../codemods/sdk-migration/routes-index.ts | 32 +++-- scripts/codemods/sdk-migration/transform.ts | 110 ++++++++++++++---- 4 files changed, 130 insertions(+), 48 deletions(-) diff --git a/.claude/skills/sdk-command-migration/SKILL.md b/.claude/skills/sdk-command-migration/SKILL.md index e2feb9de14..24d05b8bb7 100644 --- a/.claude/skills/sdk-command-migration/SKILL.md +++ b/.claude/skills/sdk-command-migration/SKILL.md @@ -22,7 +22,6 @@ Apply once per command in `src/commands/`. Each application produces one PR-read **Do NOT invoke for:** - Multi-command refactors — each command gets its own application. - Commands that import from `@heroku/sdk/compositions/*` (the subpath was removed in 0.4 — needs separate migration). -- The `data` service (`sdk.data.*`) — this skill assumes Platform calls only. - Helpers/libraries shared by multiple commands — migrate the helper in its own commit and link from the command commits. ## Tech Stack @@ -85,10 +84,11 @@ npx tsx scripts/codemods/sdk-migration/migrate-command.ts \ ``` The codemod handles the deterministic 80%: -- Replaces every recognized `this.heroku.(path)` with `platform..(...)` by reverse-lookup against `@heroku/types/3.sdk/routes`. +- Replaces every recognized `this.heroku.(path)` with `..(...)` (`platform` for the Platform API, `data` for Postgres) by reverse-lookup against the SDK route metadata. - Drops `{body: x}` destructure at call sites (the SDK returns the body directly). - Unwraps the http-call options shape (`{body: {...}}`) to pass the bare body to SDK methods that take a request body. -- Adds `import {HerokuSDK} from '@heroku/sdk'` and inserts `const {platform} = new HerokuSDK()` at the top of `run()`. +- For data routes, silently strips the `{hostname: utils.pg.host()}` options arg — the SDK provides the data hostname automatically. +- Adds `import {HerokuSDK} from '@heroku/sdk'` and inserts `const {} = new HerokuSDK()` at the top of `run()`, destructuring only the services actually used (e.g., `{platform}`, `{data}`, or `{data, platform}`). - Removes `import * as Heroku from '@heroku-cli/schema'` if no remaining references. Inspect the diff. If it looks correct, re-run without `--dry-run` to write the file in place: @@ -117,7 +117,10 @@ The path is a variable rather than a string/template literal. Trace the variable The second argument to a write-method call wasn't a recognizable `{body: ...}` wrapper. Inspect the argument and pass the SDK its expected body shape directly. **"this.heroku.(...) has an extra argument (request options?) ..."** -The CLI passed http-call options (e.g., `{hostname: 'telex.heroku.com'}`) that the SDK doesn't accept. If the call is hitting a non-default host, escalate — the SDK assumes the standard Platform host. Otherwise, drop the options and replace manually. +The CLI passed http-call options the SDK doesn't accept. For data routes, a lone `{hostname: utils.pg.host()}` is dropped silently and won't appear here — anything that reaches this flag has additional unrecognized properties. If the call is hitting a non-default host (other than the Platform or Data hostname the SDK knows about), escalate. Otherwise, drop the unused options and replace manually. + +**`Warning: name collision with SDK service(s)`** (non-blocking) +A local variable in `run()` shadows the destructured `data` or `platform` service. The codemod still produces the migration, but the inner scope's SDK calls will fail at runtime. Rename the local before merging. ### Step 1.3: Type-check @@ -283,7 +286,7 @@ Before opening the PR: ## Glossary - **Platform service:** `sdk.platform.*` — methods covering Apps, Spaces, Teams, Account, Pipelines, etc. -- **Data service:** `sdk.data.*` — methods covering Postgres / data-stores. Out of scope for this skill. +- **Data service:** `sdk.data.*` — methods covering Postgres / data-stores. The codemod migrates these alongside platform calls; the SDK supplies the data hostname automatically. - **Bare entry:** `import {HerokuSDK} from '@heroku/sdk'` — the canonical import. Do not use `@heroku/sdk/sdk` (removed in 0.4) or deep relative imports. - **Pre-flight baseline:** the snapshot of `tsc`/test state captured before any migration work, used to filter pre-existing noise out of post-migration verification. - **Codemod:** `scripts/codemods/sdk-migration/migrate-command.ts` — the deterministic transform run in Task 1, Step 1.1. diff --git a/scripts/codemods/sdk-migration/README.md b/scripts/codemods/sdk-migration/README.md index 9d5e770fe5..44bead726d 100644 --- a/scripts/codemods/sdk-migration/README.md +++ b/scripts/codemods/sdk-migration/README.md @@ -1,6 +1,6 @@ # SDK Migration Codemod -Mechanically migrates a single oclif command file from raw `this.heroku.(path)` calls to `@heroku/sdk` platform resource methods. Used as Task 1 of the SDK command migration playbook. +Mechanically migrates a single oclif command file from raw `this.heroku.(path)` calls to `@heroku/sdk` resource methods (`sdk.platform.*` for the Platform API and `sdk.data.*` for the Postgres data API). Used as Task 1 of the SDK command migration playbook. ## Usage @@ -20,11 +20,13 @@ Run on **one file at a time** for clean review. Passing multiple paths is suppor For each `this.heroku.(...)` call: -1. Looks up the `(verb, path)` pair in `@heroku/types/3.sdk/routes` to find the matching SDK resource and method. -2. Replaces the call with `platform..(...)`, mapping path placeholders to positional arguments in declaration order. +1. Looks up the `(verb, path)` pair against both `@heroku/types/3.sdk/routes` (Platform) and `@heroku/types/data/routes` (Data) to find the matching SDK resource, method, and service namespace. +2. Replaces the call with `..(...)` (e.g., `platform.app.info(id)` or `data.database.info(name)`), mapping path placeholders to positional arguments in declaration order. 3. Drops the surrounding `{body: x}` destructure (the SDK returns the body directly). -4. Adds `import {HerokuSDK} from '@heroku/sdk'` and inserts `const {platform} = new HerokuSDK()` at the top of `run()` if missing. -5. Removes `import * as Heroku from '@heroku-cli/schema'` if no remaining references. +4. Unwraps the http-call options object (`{body: data}` → `data`) when passing a request body to a write method. +5. For data routes, silently drops the `{hostname: utils.pg.host()}` options arg — the SDK provides the data hostname automatically. +6. Adds `import {HerokuSDK} from '@heroku/sdk'` and inserts `const {} = new HerokuSDK()` at the top of `run()` if missing, destructuring only the services actually used. +7. Removes `import * as Heroku from '@heroku-cli/schema'` if no remaining references. ## What it flags (does not auto-fix) @@ -32,11 +34,14 @@ The codemod inserts a `// TODO(sdk-migration): ` comment above call site - The path argument is not a string literal or template literal (e.g., a variable computed elsewhere). - No SDK route maps to the `(verb, path)` pair. -- The call passes a second argument (request options) the SDK method does not accept. +- Multiple SDK routes match the same `(verb, path)` and disambiguate via request body shape (e.g., `release.create` vs `release.rollback`). +- The call passes a second argument (request options) the SDK method does not accept. (For data routes, a single `{hostname}` option is dropped silently; anything else is flagged.) - The number of template placeholders does not match the SDK route's path placeholders. For these, the original call is left in place. Resolve manually before continuing the migration. +The codemod also emits a non-blocking **warning** when a local variable in `run()` would shadow an SDK service name (e.g., a local `const data = ...` colliding with the destructured `data` service). Rename the local before merging. + ## What it does NOT do Out of scope by design — these belong to the agent executing the playbook: @@ -48,12 +53,12 @@ Out of scope by design — these belong to the agent executing the playbook: ## How the route index is built -At startup, the codemod imports `@heroku/types/3.sdk/routes` (a generated metadata file shipped with the SDK types) and indexes every route by `(httpVerb, pathRegex)`. A concrete path like `/apps/foo/dynos` matches against the regex form of `/apps/{appIdentity}/dynos`. The matching route's `resource` and `method` come from the export name and key. +At startup, the codemod imports `@heroku/types/3.sdk/routes` and `@heroku/types/data/routes` (generated metadata files shipped with the SDK types) and indexes every route by `(httpVerb, pathRegex)`, tagged with its service (`platform` or `data`). A concrete path like `/apps/foo/dynos` matches against the regex form of `/apps/{appIdentity}/dynos`; `/client/v11/databases/foo` matches against `/client/v11/databases/{name}`. The matching route's `resource`, `method`, and `service` come directly from the SDK metadata. -The index detects collisions (two routes with the same verb+path) at load time and aborts. None exist today, but a future SDK schema change could introduce one. +When two routes match the same concrete `(verb, path)` (e.g., `release.create` and `release.rollback`, both POST `/apps/{id}/releases`), the codemod flags the call site rather than guessing. Path prefixes for the two services do not overlap, so cross-service ambiguity is impossible by construction. ## Extending -If a needed route has no SDK mapping, the right fix is in the SDK package, not here. The codemod is intentionally a pass-through over `@heroku/types/3.sdk/routes` — adding hand-curated mappings would create drift. +If a needed route has no SDK mapping, the right fix is in the SDK package, not here. The codemod is intentionally a pass-through over `@heroku/types/{3.sdk,data}/routes` — adding hand-curated mappings would create drift. If a calling pattern in the CLI doesn't match the codemod's recognizers (e.g., `this.heroku.get(...)` aliased through a helper), update `transform.ts` to recognize the new shape rather than working around it in the source. diff --git a/scripts/codemods/sdk-migration/routes-index.ts b/scripts/codemods/sdk-migration/routes-index.ts index 33651a9beb..05439b2bf2 100644 --- a/scripts/codemods/sdk-migration/routes-index.ts +++ b/scripts/codemods/sdk-migration/routes-index.ts @@ -1,7 +1,10 @@ -import * as routes from '@heroku/types/3.sdk/routes' +import * as dataRoutes from '@heroku/types/data/routes' +import * as platformRoutes from '@heroku/types/3.sdk/routes' export type HttpVerb = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT' +export type ServiceName = 'data' | 'platform' + export type RouteEntry = { hasRequestBody: boolean method: string @@ -9,6 +12,7 @@ export type RouteEntry = { pathRegex: RegExp placeholders: string[] resource: string + service: ServiceName verb: HttpVerb } @@ -31,13 +35,8 @@ export class RouteIndex { static load(): RouteIndex { const entries: RouteEntry[] = [] - for (const [resource, methods] of Object.entries(routes)) { - if (resource === 'default' || typeof methods !== 'object' || methods === null) continue - for (const [method, def] of Object.entries(methods as Record)) { - entries.push(buildEntry(resource, method, def)) - } - } - + collectEntries(platformRoutes, 'platform', entries) + collectEntries(dataRoutes, 'data', entries) return new RouteIndex(entries) } @@ -61,7 +60,21 @@ export class RouteIndex { } } -function buildEntry(resource: string, method: string, def: {hasRequestBody?: boolean; method: string; path: string}): RouteEntry { +function collectEntries(source: Record, service: ServiceName, entries: RouteEntry[]): void { + for (const [resource, methods] of Object.entries(source)) { + if (resource === 'default' || typeof methods !== 'object' || methods === null) continue + for (const [method, def] of Object.entries(methods as Record)) { + entries.push(buildEntry(resource, method, def, service)) + } + } +} + +function buildEntry( + resource: string, + method: string, + def: {hasRequestBody?: boolean; method: string; path: string}, + service: ServiceName, +): RouteEntry { const placeholders = [...def.path.matchAll(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g)].map(m => m[1]) return { hasRequestBody: Boolean(def.hasRequestBody), @@ -70,6 +83,7 @@ function buildEntry(resource: string, method: string, def: {hasRequestBody?: boo pathRegex: pathToRegex(def.path), placeholders, resource, + service, verb: def.method as HttpVerb, } } diff --git a/scripts/codemods/sdk-migration/transform.ts b/scripts/codemods/sdk-migration/transform.ts index 364e0f3a95..1464e89e91 100644 --- a/scripts/codemods/sdk-migration/transform.ts +++ b/scripts/codemods/sdk-migration/transform.ts @@ -8,12 +8,13 @@ import { type VariableDeclaration, } from 'ts-morph' -import {type HttpVerb, RouteIndex} from './routes-index.js' +import {type HttpVerb, RouteIndex, type ServiceName} from './routes-index.js' export type TransformResult = { changed: boolean flags: string[] unmatched: number + usedServices: Set warnings: string[] } @@ -29,7 +30,13 @@ const HEROKU_SCHEMA_IMPORT = '@heroku-cli/schema' const HEROKU_SDK_IMPORT = '@heroku/sdk' export function transform(sourceFile: SourceFile, index: RouteIndex): TransformResult { - const result: TransformResult = {changed: false, flags: [], unmatched: 0, warnings: []} + const result: TransformResult = { + changed: false, + flags: [], + unmatched: 0, + usedServices: new Set(), + warnings: [], + } const calls = collectHerokuCalls(sourceFile) if (calls.length === 0) return result @@ -44,6 +51,7 @@ export function transform(sourceFile: SourceFile, index: RouteIndex): TransformR } if (result.changed) { + flagServiceNameShadowing(sourceFile, result) ensureSdkSetup(sourceFile, result) pruneUnusedSchemaImport(sourceFile) } @@ -51,6 +59,34 @@ export function transform(sourceFile: SourceFile, index: RouteIndex): TransformR return result } +function flagServiceNameShadowing(sourceFile: SourceFile, result: TransformResult): void { + // Detect locals in run() that shadow the SDK service names we're about to destructure. + // Shadowing would silently break the migrated SDK calls in the inner scope. + const runMethod = sourceFile + .getClasses() + .flatMap(c => c.getInstanceMethods()) + .find(m => m.getName() === 'run') + if (!runMethod) return + + const services = result.usedServices + const conflicts: string[] = [] + for (const decl of runMethod.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) { + const nameNode = decl.getNameNode() + const declared = Node.isIdentifier(nameNode) ? [nameNode.getText()] : [] + for (const name of declared) { + if (services.has(name as ServiceName)) { + conflicts.push(`local '${name}' at line ${decl.getStartLineNumber()} shadows the SDK service '${name}'`) + } + } + } + + if (conflicts.length === 0) return + + result.warnings.push( + `name collision with SDK service(s); rename the local(s) before merging:\n ` + conflicts.join('\n '), + ) +} + type CallContext = { call: CallExpression verb: HttpVerb @@ -119,19 +155,20 @@ function replaceCall(ctx: CallContext, index: RouteIndex, result: TransformResul } const sdkArgs = [...pathInfo.params] + const fqMethod = `${entry.service}.${entry.resource}.${entry.method}` if (entry.hasRequestBody) { if (args.length < 2) { - flagCall(call, `SDK method platform.${entry.resource}.${entry.method} requires a request body`, result) + flagCall(call, `SDK method ${fqMethod} requires a request body`, result) result.unmatched++ return false } const bodyArg = args[1] - const bodyText = unwrapHttpCallBody(bodyArg) + const bodyText = unwrapHttpCallBody(bodyArg, entry.service) if (bodyText === null) { flagCall( call, - `cannot determine SDK request body shape for platform.${entry.resource}.${entry.method}; review the second argument manually`, + `cannot determine SDK request body shape for ${fqMethod}; review the second argument manually`, result, ) result.unmatched++ @@ -140,17 +177,23 @@ function replaceCall(ctx: CallContext, index: RouteIndex, result: TransformResul sdkArgs.push(bodyText) } else if (args.length > 1) { - // Don't replace this call — too risky to silently drop a request-options arg. - flagCall( - call, - `this.heroku.${verbName}(...) has an extra argument (request options?) that platform.${entry.resource}.${entry.method} does not accept; review manually`, - result, - ) - result.unmatched++ - return false + // Data routes commonly pass {hostname: utils.pg.host()}; the SDK provides the host automatically. + const optionsArg = args[1] + if (entry.service === 'data' && isHostnameOnlyOptions(optionsArg)) { + // Silently drop — the SDK handles hostname resolution for the data service. + } else { + flagCall( + call, + `this.heroku.${verbName}(...) has an extra argument (request options?) that ${fqMethod} does not accept; review manually`, + result, + ) + result.unmatched++ + return false + } } - const replacement = `platform.${entry.resource}.${entry.method}(${sdkArgs.join(', ')})` + const replacement = `${entry.service}.${entry.resource}.${entry.method}(${sdkArgs.join(', ')})` + result.usedServices.add(entry.service) const wrapping = findEnclosingAwaitOrBindingContext(call) rewriteCallSite(call, replacement, wrapping, result) @@ -178,22 +221,38 @@ function extractPath(node: Node): null | PathInfo { return null } -function unwrapHttpCallBody(node: Node): null | string { +function unwrapHttpCallBody(node: Node, service: ServiceName): null | string { // The CLI's http-call wraps requests as `{body: , ...}`. The SDK takes the body directly. - // If the second arg is an object literal with a single `body` property, return its value. + // If the options object has only `body`, return its value. For data routes, also accept and strip `hostname`. if (!Node.isObjectLiteralExpression(node)) { // Could be a variable holding the body; pass it through as-is. return node.getText() } const properties = node.getProperties() - if (properties.length !== 1) return null + let bodyValue: null | string = null + for (const property of properties) { + if (!Node.isPropertyAssignment(property)) return null + const name = property.getName() + if (name === 'body') { + bodyValue = property.getInitializer()?.getText() ?? null + } else if (name === 'hostname' && service === 'data') { + // accepted; the SDK provides the data hostname automatically + } else { + return null + } + } - const property = properties[0] - if (!Node.isPropertyAssignment(property)) return null - if (property.getName() !== 'body') return null + return bodyValue +} - return property.getInitializer()?.getText() ?? null +function isHostnameOnlyOptions(node: Node): boolean { + if (!Node.isObjectLiteralExpression(node)) return false + const properties = node.getProperties() + if (properties.length !== 1) return false + const property = properties[0] + if (!Node.isPropertyAssignment(property)) return false + return property.getName() === 'hostname' } function extractFromTemplate(template: TemplateExpression): PathInfo { @@ -282,6 +341,8 @@ function flagCall(call: CallExpression, message: string, result: TransformResult } function ensureSdkSetup(sourceFile: SourceFile, result: TransformResult): void { + if (result.usedServices.size === 0) return + const existing = sourceFile.getImportDeclaration(d => d.getModuleSpecifierValue() === HEROKU_SDK_IMPORT) if (!existing) { // Insert at the top of the imports block; the project formats named imports without inner-brace spaces. @@ -293,9 +354,6 @@ function ensureSdkSetup(sourceFile: SourceFile, result: TransformResult): void { if (!named.includes('HerokuSDK')) existing.addNamedImport('HerokuSDK') } - const usesPlatform = sourceFile.getText().includes('platform.') - if (!usesPlatform) return - const runMethod = sourceFile .getClasses() .flatMap(c => c.getInstanceMethods()) @@ -309,7 +367,9 @@ function ensureSdkSetup(sourceFile: SourceFile, result: TransformResult): void { const body = runMethod.getBodyText() ?? '' if (body.includes('new HerokuSDK')) return - runMethod.insertStatements(0, 'const {platform} = new HerokuSDK()') + // Sorted destructuring: 'data' comes before 'platform' alphabetically. + const services = [...result.usedServices].sort().join(', ') + runMethod.insertStatements(0, `const {${services}} = new HerokuSDK()`) } function pruneUnusedSchemaImport(sourceFile: SourceFile): void {