diff --git a/.agents/skills/changeset/no-agent-impact.json b/.agents/skills/changeset/no-agent-impact.json index ba65d26e5738..2d3921de1ce8 100644 --- a/.agents/skills/changeset/no-agent-impact.json +++ b/.agents/skills/changeset/no-agent-impact.json @@ -1,4 +1,5 @@ { "drop-typescript-3": "Agents don't install or keep TypeScript 3.x, so no realistic task hits the old behavior.", - "test-mock-reexports": "Agent-written test code and imports are the same before and after; only install-time peer resolution changes, for projects pinned to @data-client/react 0.15.2 or older." + "test-mock-reexports": "Agent-written test code and imports are the same before and after; only install-time peer resolution changes, for projects pinned to @data-client/react 0.15.2 or older.", + "codemods-package": "New package at its first release (minor takes it from 0.0.0 to 0.1.0); nothing existing changes behavior, so there is no old habit to fail on." } diff --git a/.agents/skills/data-client-v0.18-migration/SKILL.md b/.agents/skills/data-client-v0.18-migration/SKILL.md index 5f3637309d88..aa723fd78ba1 100644 --- a/.agents/skills/data-client-v0.18-migration/SKILL.md +++ b/.agents/skills/data-client-v0.18-migration/SKILL.md @@ -96,4 +96,4 @@ Unrelated to delegate signatures: v0.18 lets a single `Collection` carry both `a - New interfaces: [`IDenormalizeDelegate`](https://dataclient.io/rest/api/SchemaSimple), [`INormalizeDelegate`](https://dataclient.io/rest/api/SchemaSimple) - Built-in schema diffs: `packages/endpoint/src/schemas/{Array,Object,Values,Union,Query,Invalidate,Lazy,Collection}.ts` - Changesets: `.changeset/denormalize-delegate.md`, `.changeset/normalize-delegate.md` -- Codemod source: [`website/static/codemods/v0.18.js`](https://github.com/reactive/data-client/blob/master/website/static/codemods/v0.18.js) +- Codemod source: [`packages/codemods/src/transforms/v0.18.js`](https://github.com/reactive/data-client/blob/master/packages/codemods/src/transforms/v0.18.js) diff --git a/.changeset/codemods-package.md b/.changeset/codemods-package.md new file mode 100644 index 000000000000..6912b25380bb --- /dev/null +++ b/.changeset/codemods-package.md @@ -0,0 +1,11 @@ +--- +'@data-client/codemods': minor +--- + +New package: run Data Client codemods with `npx @data-client/codemods ` + +The codemods ship on npm with each release, instead of being fetched from dataclient.io at run time. + +```bash +npx @data-client/codemods v0.18 src/ +``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 61ed8fe2a7a9..42c80c3f3740 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -165,6 +165,15 @@ export default [ }, }, }, + // Mirrors packages/codemods/package.json "type": "commonjs": jscodeshift + // transforms and their CLI run in Node as CommonJS + { + files: ['packages/codemods/**/*.js'], + languageOptions: { + sourceType: 'commonjs', + globals: globals.node, + }, + }, { files: ['examples/**/*.?(m|c)ts?(x)'], rules: { diff --git a/jest.config.js b/jest.config.js index cd37aa340ffb..47cec4d704cf 100644 --- a/jest.config.js +++ b/jest.config.js @@ -39,6 +39,8 @@ const baseConfig = { 'packages/vue/src/test', 'packages/test', 'packages/graphql', + // dev-time migration tooling: tested, but not held to the library's coverage floors + 'packages/codemods', 'packages/rest/src/next', 'packages/core/src/next', 'packages/core/src/mock', @@ -66,6 +68,7 @@ const packages = [ 'normalizr', 'use-enhanced-reducer', 'img', + 'codemods', 'test', ]; diff --git a/package.json b/package.json index f554ea5b0663..be48a4dbc438 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,6 @@ "globals": "^16.0.0", "jest": "^30.0.0", "jest-environment-jsdom": "^30.0.0", - "jscodeshift": "^17.3.0", "mkdirp": "^3.0.0", "nock": "13.3.1", "npm-run-all": "^4.1.5", diff --git a/packages/codemods/LICENSE b/packages/codemods/LICENSE new file mode 100644 index 000000000000..ee5e0561fd6c --- /dev/null +++ b/packages/codemods/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Nathaniel Tucker. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/codemods/README.md b/packages/codemods/README.md new file mode 100644 index 000000000000..6d40f0b76bf0 --- /dev/null +++ b/packages/codemods/README.md @@ -0,0 +1,22 @@ +# @data-client/codemods + +[![npm version](https://img.shields.io/npm/v/@data-client/codemods.svg?style=flat-square)](https://www.npmjs.com/package/@data-client/codemods) + +[jscodeshift](https://github.com/facebook/jscodeshift) codemods that migrate code to new +[Reactive Data Client](https://dataclient.io) releases. Commit first so you can review the diff, then run one +against your source folders: + +```bash +npx @data-client/codemods v0.18 src/ +``` + +| Codemod | Migrates | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `v0.18` | `Schema.denormalize()`/`normalize()` to the delegate signatures ([v0.18 post](https://dataclient.io/blog/2026/05/01/v0.18-scalar-typed-downloads)) | +| `v0.16` | `path-to-regexp` v8 path syntax, `useFetch()` truthiness checks and `schema.X` imports ([v0.16 post](https://dataclient.io/blog/2026/04/01/v0.16-parallel-fetching-collection-move-lazy)) | +| `v0.15` | `useDebounce()` results, `entityMeta`, `MemoCache` state and `INVALID` imports ([v0.15 post](https://dataclient.io/blog/2026/01/06/v0.15-vue-support-collection-remove)) | +| `axios-to-rest` | `axios` calls to `RestEndpoint` ([axios migration guide](https://dataclient.io/rest/guides/axios-migration)) | + +It processes JavaScript and TypeScript files and skips `node_modules`. Other +[jscodeshift options](https://github.com/facebook/jscodeshift#usage-cli) pass through, such as `--dry` and `--print` +to preview changes, or `--extensions` to override the file types. diff --git a/packages/codemods/package.json b/packages/codemods/package.json new file mode 100644 index 000000000000..635d7e033326 --- /dev/null +++ b/packages/codemods/package.json @@ -0,0 +1,40 @@ +{ + "name": "@data-client/codemods", + "version": "0.0.0", + "description": "Codemods that migrate code to new Reactive Data Client releases", + "homepage": "https://dataclient.io/blog", + "repository": { + "type": "git", + "url": "git+https://github.com/reactive/data-client.git", + "directory": "packages/codemods" + }, + "bugs": { + "url": "https://github.com/reactive/data-client/issues" + }, + "keywords": [ + "codemod", + "jscodeshift", + "migration", + "upgrade", + "data-client" + ], + "type": "commonjs", + "bin": { + "data-client-codemods": "./src/cli.js" + }, + "engines": { + "node": ">=18" + }, + "files": [ + "src", + "!src/**/__tests__", + "LICENSE", + "README.md" + ], + "author": "Nathaniel Tucker (https://github.com/ntucker)", + "funding": "https://github.com/sponsors/ntucker", + "license": "Apache-2.0", + "dependencies": { + "jscodeshift": "^17.3.0" + } +} diff --git a/packages/codemods/src/__tests__/cli.node.js b/packages/codemods/src/__tests__/cli.node.js new file mode 100644 index 000000000000..7bb0c6be0508 --- /dev/null +++ b/packages/codemods/src/__tests__/cli.node.js @@ -0,0 +1,59 @@ +'use strict'; + +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const cli = path.join(__dirname, '../cli.js'); +const ignored = ['node_modules/dep', 'packages/app/node_modules/dep']; +const before = `import { Entity } from '@data-client/rest'; +class MySchema { + denormalize(input: {}, args: readonly any[], unvisit: any) { + return unvisit(this.schema, input); + } +} +`; + +function run(args, cwd) { + return spawnSync(process.execPath, [cli, ...args], { cwd, encoding: 'utf8' }); +} + +describe('cli', () => { + let dir; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'data-client-codemods-')); + for (const folder of ['src', ...ignored]) { + fs.mkdirSync(path.join(dir, folder), { recursive: true }); + fs.writeFileSync(path.join(dir, folder, 'schema.ts'), before); + } + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('lists the codemods when none is named', () => { + const { status, stderr } = run([], dir); + expect(status).toBe(1); + expect(stderr).toContain('Codemods: axios-to-rest, v0.15, v0.16, v0.18'); + }); + + it('rejects an unknown codemod', () => { + const { status, stderr } = run(['v0.1', 'src'], dir); + expect(status).toBe(1); + expect(stderr).toContain('Unknown codemod "v0.1"'); + }); + + it('transforms TypeScript sources and skips node_modules', () => { + const { status } = run(['v0.18', '.', '--run-in-band', '--silent'], dir); + expect(status).toBe(0); + expect(fs.readFileSync(path.join(dir, 'src/schema.ts'), 'utf8')).not.toBe( + before, + ); + for (const folder of ignored) { + expect(fs.readFileSync(path.join(dir, folder, 'schema.ts'), 'utf8')).toBe( + before, + ); + } + }); +}); diff --git a/packages/codemods/src/cli.js b/packages/codemods/src/cli.js new file mode 100755 index 000000000000..5e2aebdf287b --- /dev/null +++ b/packages/codemods/src/cli.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node +'use strict'; + +// npx @data-client/codemods [jscodeshift options] +// Runs one of ./transforms with jscodeshift, skipping node_modules. Other +// jscodeshift options (--dry, --print, --extensions) pass through. +const fs = require('node:fs'); +const path = require('node:path'); + +const dir = path.join(__dirname, 'transforms'); +const codemods = fs + .readdirSync(dir) + .filter(file => file.endsWith('.js')) + .map(file => file.slice(0, -'.js'.length)); + +const [codemod, ...args] = process.argv.slice(2); +if (!codemods.includes(codemod)) { + process.stderr.write( + (codemod ? `Unknown codemod "${codemod}".\n\n` : '') + + 'Usage: npx @data-client/codemods [jscodeshift options]\n\n' + + `Codemods: ${codemods.join(', ')}\n` + + 'Example: npx @data-client/codemods v0.18 src/\n', + ); + process.exit(1); +} + +process.argv = [ + process.argv[0], + 'jscodeshift', + `--transform=${path.join(dir, `${codemod}.js`)}`, + '--ignore-pattern=node_modules', + ...args, +]; +require('jscodeshift/bin/jscodeshift.js'); diff --git a/website/static/codemods/__tests__/axios-to-rest.test.js b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js similarity index 97% rename from website/static/codemods/__tests__/axios-to-rest.test.js rename to packages/codemods/src/transforms/__tests__/axios-to-rest.node.js index 2c37ed008348..9ce8c042fec1 100644 --- a/website/static/codemods/__tests__/axios-to-rest.test.js +++ b/packages/codemods/src/transforms/__tests__/axios-to-rest.node.js @@ -320,9 +320,11 @@ const other = 123, api = axios.create({ baseURL: 'https://api.example.com' }); `, ` import { RestEndpoint } from '@data-client/rest'; + class ApiEndpoint extends RestEndpoint { urlPrefix = 'https://api.example.com'; } + const other = 123; `, 'preserves sibling declarators when axios.create is not the first declarator', @@ -447,7 +449,11 @@ import axios from 'axios'; const url = getUrl(); const result = axios.get(url); `, - ``, + ` +import axios from 'axios'; +const url = getUrl(); +const result = axios.get(url); + `, 'leaves non-literal URL calls unchanged without rewriting imports', ); @@ -532,12 +538,12 @@ const result = api.post('/users', payload); `, ` import { RestEndpoint } from '@data-client/rest'; -const payload = { name: 'Taylor' }; class ApiEndpoint extends RestEndpoint { urlPrefix = 'https://api.example.com'; } +const payload = { name: 'Taylor' }; const result = new ApiEndpoint({ path: '/users', method: 'POST' @@ -718,7 +724,10 @@ import type { AxiosResponse } from 'axios'; import axios from 'axios'; function foo(client: typeof axios) { return client; } `, - ``, + ` +import axios from 'axios'; +function foo(client: typeof axios) { return client; } + `, 'no-op when axios is used only as typeof annotation', ); @@ -858,7 +867,10 @@ async function getUsers() { import axios from 'axios'; const result = axios('/users'); `, - ``, + ` +import axios from 'axios'; +const result = axios('/users'); + `, 'axios as default function call is left unchanged (no-op)', ); @@ -869,7 +881,10 @@ const result = axios('/users'); import axios from 'axios'; const result = axios.get('/users', { headers: { Authorization: 'Bearer token' }, params: { page: 1 } }); `, - ``, + ` +import axios from 'axios'; +const result = axios.get('/users', { headers: { Authorization: 'Bearer token' }, params: { page: 1 } }); + `, 'config object as second arg is left unchanged (no-op)', ); @@ -880,7 +895,10 @@ const result = axios.get('/users', { headers: { Authorization: 'Bearer token' }, import axios from 'axios'; const result = axios.request({ url: '/users', method: 'get' }); `, - ``, + ` +import axios from 'axios'; +const result = axios.request({ url: '/users', method: 'get' }); + `, 'axios.request() is left unchanged (no-op)', ); diff --git a/website/static/codemods/__tests__/v0.15.test.js b/packages/codemods/src/transforms/__tests__/v0.15.node.js similarity index 100% rename from website/static/codemods/__tests__/v0.15.test.js rename to packages/codemods/src/transforms/__tests__/v0.15.node.js diff --git a/packages/codemods/src/transforms/__tests__/v0.16.node.js b/packages/codemods/src/transforms/__tests__/v0.16.node.js new file mode 100644 index 000000000000..3e1f715a296c --- /dev/null +++ b/packages/codemods/src/transforms/__tests__/v0.16.node.js @@ -0,0 +1,55 @@ +'use strict'; + +const { defineInlineTest } = require('jscodeshift/dist/testUtils'); + +const transform = require('../v0.16'); + +describe('v0.16 codemod', () => { + describe('path-to-regexp v6 → v8', () => { + defineInlineTest( + transform, + {}, + ` +import { RestEndpoint } from '@data-client/rest'; +const getUser = new RestEndpoint({ path: '/users/:id(\\\\d+)/:tab?' }); + `, + ` +import { RestEndpoint } from '@data-client/rest'; +const getUser = new RestEndpoint({ path: '/users/:id{/:tab}' }); + `, + 'rewrites optional params and drops custom matchers in path strings', + ); + }); + + describe('schema.X namespace → direct imports', () => { + defineInlineTest( + transform, + {}, + ` +import { schema } from '@data-client/endpoint'; +const users = new schema.Collection([User]); + `, + ` +import { Collection } from '@data-client/endpoint'; +const users = new Collection([User]); + `, + 'imports schema members directly', + ); + }); + + describe('safety', () => { + defineInlineTest( + transform, + {}, + ` +import { RestEndpoint } from '@data-client/rest'; +const getUser = new RestEndpoint({ path: '/users/:id' }); + `, + ` +import { RestEndpoint } from '@data-client/rest'; +const getUser = new RestEndpoint({ path: '/users/:id' }); + `, + 'returns the source unchanged when nothing applies', + ); + }); +}); diff --git a/website/static/codemods/__tests__/v0.18.test.js b/packages/codemods/src/transforms/__tests__/v0.18.node.js similarity index 64% rename from website/static/codemods/__tests__/v0.18.test.js rename to packages/codemods/src/transforms/__tests__/v0.18.node.js index e3dfbfe50fe6..f35fc1abc545 100644 --- a/website/static/codemods/__tests__/v0.18.test.js +++ b/packages/codemods/src/transforms/__tests__/v0.18.node.js @@ -295,6 +295,248 @@ class Wrapper { ); }); + describe('other denormalize declarations', () => { + defineInlineTest( + transform, + { path: 'obj.js' }, + ` +import { Entity } from '@data-client/rest'; + +const MySchema = { + denormalize(input, args, unvisit) { + return unvisit(this.schema, input); + }, +}; + `, + ` +import { Entity } from '@data-client/rest'; + +const MySchema = { + denormalize(input, delegate) { + return delegate.unvisit(this.schema, input); + }, +}; + `, + 'rewrites object method denormalize', + ); + + defineInlineTest( + transform, + { path: 'obj-fn.js' }, + ` +import { Entity } from '@data-client/rest'; + +const MySchema = { + denormalize: function (input, args, unvisit) { + return unvisit(this.schema, input); + }, +}; + `, + ` +import { Entity } from '@data-client/rest'; + +const MySchema = { + denormalize: function(input, delegate) { + return delegate.unvisit(this.schema, input); + }, +}; + `, + 'rewrites object property function denormalize', + ); + + defineInlineTest( + transform, + { path: 'field.ts' }, + ` +import { Entity } from '@data-client/rest'; + +class MySchema { + denormalize = (input: {}, args: readonly any[], unvisit: (s: any, v: any) => any) => unvisit(this.schema, input); +} + `, + ` +import { Entity, type IDenormalizeDelegate } from '@data-client/rest'; + +class MySchema { + denormalize = (input: {}, delegate: IDenormalizeDelegate) => delegate.unvisit(this.schema, input); +} + `, + 'rewrites class field arrow denormalize', + ); + + defineInlineTest( + transform, + { path: 'fn.ts' }, + ` +import { Entity } from '@data-client/rest'; + +function denormalize(input: {}, args: readonly any[], unvisit: (s: any, v: any) => any) { + return unvisit(this.schema, input); +} + `, + ` +import { Entity, type IDenormalizeDelegate } from '@data-client/rest'; + +function denormalize(input: {}, delegate: IDenormalizeDelegate) { + return delegate.unvisit(this.schema, input); +} + `, + 'rewrites top-level function declaration named denormalize', + ); + + defineInlineTest( + transform, + { path: 'prop-sig.ts' }, + ` +import { Entity } from '@data-client/rest'; + +interface Lazy { + _denormalize: ( + input: {}, + args: readonly any[], + unvisit: (schema: any, input: any) => any, + ) => any; +} + `, + ` +import { Entity, type IDenormalizeDelegate } from '@data-client/rest'; + +interface Lazy { + _denormalize: (input: {}, delegate: IDenormalizeDelegate) => any; +} + `, + 'rewrites denormalize-like interface property signature', + ); + }); + + describe('other normalize declarations', () => { + defineInlineTest( + transform, + { path: 'obj.js' }, + ` +import { Entity } from '@data-client/rest'; + +const MySchema = { + normalize(input, parent, key, args, visit, delegate) { + return visit(this.schema, input, parent, key, args); + }, +}; + `, + ` +import { Entity } from '@data-client/rest'; + +const MySchema = { + normalize(input, parent, key, delegate) { + return delegate.visit(this.schema, input, parent, key); + }, +}; + `, + 'rewrites object method normalize', + ); + + defineInlineTest( + transform, + { path: 'obj-arrow.js' }, + ` +import { Entity } from '@data-client/rest'; + +const MySchema = { + normalize: (input, parent, key, args, visit, delegate) => + visit(MySchema.schema, input, parent, key, args), +}; + `, + ` +import { Entity } from '@data-client/rest'; + +const MySchema = { + normalize: (input, parent, key, delegate) => delegate.visit(MySchema.schema, input, parent, key), +}; + `, + 'rewrites object property arrow normalize', + ); + + defineInlineTest( + transform, + { path: 'fn.ts' }, + ` +import { Entity } from '@data-client/rest'; + +function normalize(input: any, parent: any, key: string, args: readonly any[], visit: (s: any, v: any, p: any, k: any, a: readonly any[]) => any, delegate: any) { + return visit(this.schema, input, parent, key, args); +} + `, + ` +import { Entity, type INormalizeDelegate } from '@data-client/rest'; + +function normalize(input: any, parent: any, key: string, delegate: INormalizeDelegate) { + return delegate.visit(this.schema, input, parent, key); +} + `, + 'rewrites top-level function declaration named normalize', + ); + + defineInlineTest( + transform, + { path: 'prop-sig.ts' }, + ` +import { Entity } from '@data-client/rest'; + +interface Wrapper { + normalize: ( + input: any, + parent: any, + key: string, + args: readonly any[], + visit: (s: any, v: any) => any, + delegate: any, + ) => any; +} + `, + ` +import { Entity, type INormalizeDelegate } from '@data-client/rest'; + +interface Wrapper { + normalize: (input: any, parent: any, key: string, delegate: INormalizeDelegate) => any; +} + `, + 'rewrites normalize interface property signature', + ); + + defineInlineTest( + transform, + { path: 'prop-sig-skip.ts' }, + ` +import { Entity } from '@data-client/rest'; + +interface Wrapper { + _normalize: ( + input: any, + parent: any, + key: string, + args: readonly any[], + visit: (s: any, v: any) => any, + delegate: any, + ) => any; +} + `, + ` +import { Entity } from '@data-client/rest'; + +interface Wrapper { + _normalize: ( + input: any, + parent: any, + key: string, + args: readonly any[], + visit: (s: any, v: any) => any, + delegate: any, + ) => any; +} + `, + 'leaves underscore-prefixed normalize property signatures alone (only denormalize is broadened)', + ); + }); + describe('combined schema methods', () => { defineInlineTest( transform, diff --git a/packages/codemods/src/transforms/axios-to-rest.js b/packages/codemods/src/transforms/axios-to-rest.js new file mode 100644 index 000000000000..fbce9f5fcfdd --- /dev/null +++ b/packages/codemods/src/transforms/axios-to-rest.js @@ -0,0 +1,513 @@ +'use strict'; + +/** + * Axios → @data-client/rest migration codemod + * + * Transforms: + * 1. import axios from 'axios' → import { RestEndpoint } from '@data-client/rest' + * 2. axios.create({ baseURL, headers }) → RestEndpoint subclass + * 3. axios.get/post/put/patch/delete(url) → new RestEndpoint({ path, method }) + * + * Usage: + * npx @data-client/codemods axios-to-rest src/ + */ + +const AXIOS_TYPE_IMPORTS = new Set([ + 'AxiosResponse', + 'AxiosRequestConfig', + 'AxiosInstance', + 'AxiosHeaders', + 'AxiosInterceptorManager', + 'AxiosPromise', + 'AxiosStatic', + 'AxiosDefaults', + 'AxiosAdapter', + 'AxiosBasicCredentials', + 'AxiosProxyConfig', + 'AxiosTransformer', + 'CancelTokenSource', + 'Canceler', + 'Cancel', + 'InternalAxiosRequestConfig', + 'CreateAxiosDefaults', + 'RawAxiosRequestConfig', + 'RawAxiosRequestHeaders', + 'RawAxiosResponseHeaders', +]); + +const AXIOS_RUNTIME_IMPORTS = new Set(['CancelToken', 'AxiosError']); + +const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete']); + +// --- Helpers --- + +function capitalize(s) { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +function deriveClassName(varName) { + if (!varName) return 'ApiEndpoint'; + const base = varName.replace(/[^a-zA-Z0-9]/g, ''); + if (!base) return 'ApiEndpoint'; + return capitalize(base) + 'Endpoint'; +} + +function getExistingClassNames(j, root) { + const names = new Set(); + root.find(j.ClassDeclaration).forEach(p => { + if (p.node.id) names.add(p.node.id.name); + }); + return names; +} + +function uniqueClassName(desired, existing) { + let name = desired; + while (existing.has(name)) { + name = '_' + name; + } + existing.add(name); + return name; +} + +function extractHeaderProperties(j, node) { + if (node.type === 'ObjectExpression') { + return node.properties.filter( + p => + p.type === 'ObjectProperty' || + p.type === 'Property' || + p.type === 'SpreadElement' || + p.type === 'SpreadProperty', + ); + } + return [j.spreadElement(node)]; +} + +// --- 1. Transform imports --- + +function hasReference(j, root, localName, importScope, excludeTypePositions) { + return ( + root + .find(j.Identifier, { name: localName }) + .filter(path => { + if (path.scope.lookup(localName) !== importScope) { + return false; + } + + const parent = path.parent.node; + + if ( + (parent.type === 'ImportDefaultSpecifier' || + parent.type === 'ImportSpecifier' || + parent.type === 'ImportNamespaceSpecifier') && + (parent.local === path.node || parent.imported === path.node) + ) { + return false; + } + + if ( + (parent.type === 'Property' || parent.type === 'ObjectProperty') && + parent.key === path.node && + !parent.computed + ) { + return false; + } + + if ( + parent.type === 'MemberExpression' && + parent.property === path.node && + !parent.computed + ) { + return false; + } + + if (excludeTypePositions) { + // Ignore references used only in TS type positions. + if ( + parent.type === 'TSTypeReference' || + parent.type === 'TSQualifiedName' || + parent.type === 'TSTypeQuery' || + parent.type === 'TSExpressionWithTypeArguments' || + parent.type === 'TSImportType' + ) { + return false; + } + } + + return true; + }) + .size() > 0 + ); +} + +function hasLiveReference(j, root, localName, importScope) { + return hasReference(j, root, localName, importScope, false); +} + +function hasRuntimeReference(j, root, localName, importScope) { + return hasReference(j, root, localName, importScope, true); +} + +function ensureRestEndpointImport(j, root) { + let dirty = false; + const restImports = root + .find(j.ImportDeclaration) + .filter(p => p.node.source.value === '@data-client/rest'); + + if (restImports.length) { + restImports.forEach(importPath => { + const specifiers = importPath.node.specifiers || []; + const hasRestEndpoint = specifiers.some( + s => + s.type === 'ImportSpecifier' && + s.imported && + (s.imported.name || s.imported.value) === 'RestEndpoint', + ); + if (hasRestEndpoint) return; + + specifiers.push(j.importSpecifier(j.identifier('RestEndpoint'))); + importPath.node.specifiers = specifiers; + dirty = true; + }); + return dirty; + } + + const newImport = j.importDeclaration( + [j.importSpecifier(j.identifier('RestEndpoint'))], + j.literal('@data-client/rest'), + ); + const firstImport = root.find(j.ImportDeclaration).at(0); + if (firstImport.length) { + firstImport.insertBefore(newImport); + } else { + root.get().node.program.body.unshift(newImport); + } + return true; +} + +function transformImports(j, root, axiosLocalName, needsRestEndpointImport) { + let dirty = false; + const axiosImportPaths = root + .find(j.ImportDeclaration) + .filter(p => p.node.source.value === 'axios') + .paths(); + const axiosImportPath = axiosImportPaths[0]; + const axiosStillUsed = hasLiveReference( + j, + root, + axiosLocalName, + axiosImportPath ? axiosImportPath.scope : null, + ); + + const hasDefaultImport = axiosImportPaths.some( + p => + p.node.importKind !== 'type' && + p.node.specifiers.some(s => s.type === 'ImportDefaultSpecifier'), + ); + + if (needsRestEndpointImport || (!axiosStillUsed && hasDefaultImport)) { + dirty = ensureRestEndpointImport(j, root) || dirty; + } + + root.find(j.ImportDeclaration).forEach(importPath => { + if (importPath.node.source.value !== 'axios') return; + + if (importPath.node.importKind === 'type') { + j(importPath).remove(); + dirty = true; + return; + } + + if (axiosStillUsed) return; + + const specs = importPath.node.specifiers; + const remaining = specs.filter(s => { + if (s.type === 'ImportDefaultSpecifier') return false; + if (s.type === 'ImportSpecifier' && s.importKind === 'type') return false; + if (s.type === 'ImportSpecifier' && s.imported) { + const importedName = s.imported.name || s.imported.value; + const localName = (s.local && s.local.name) || importedName; + + if (AXIOS_RUNTIME_IMPORTS.has(importedName)) { + return hasRuntimeReference(j, root, localName, importPath.scope); + } + if (AXIOS_TYPE_IMPORTS.has(importedName)) { + return false; + } + } + return true; + }); + + if (!remaining.length) { + j(importPath).remove(); + dirty = true; + return; + } + + importPath.node.specifiers = remaining; + dirty = true; + }); + + return dirty; +} + +// --- 2. Transform axios.create() → class --- + +function transformAxiosCreate(j, root, axiosLocalName) { + let dirty = false; + const existingClasses = getExistingClassNames(j, root); + const createToClass = new Map(); + + root.find(j.VariableDeclarator).forEach(declPath => { + const init = declPath.node.init; + if (!init || init.type !== 'CallExpression') return; + if ( + !init.callee || + init.callee.type !== 'MemberExpression' || + init.callee.object.type !== 'Identifier' || + init.callee.object.name !== axiosLocalName || + init.callee.property.type !== 'Identifier' || + init.callee.property.name !== 'create' + ) { + return; + } + + const varName = + declPath.node.id.type === 'Identifier' ? declPath.node.id.name : null; + const className = uniqueClassName( + deriveClassName(varName), + existingClasses, + ); + + if (varName) { + const declarationScope = declPath.scope.lookup(varName) || declPath.scope; + let scopedClassNames = createToClass.get(varName); + if (!scopedClassNames) { + scopedClassNames = new Map(); + createToClass.set(varName, scopedClassNames); + } + scopedClassNames.set(declarationScope, className); + } + + const config = + ( + init.arguments.length > 0 && + init.arguments[0].type === 'ObjectExpression' + ) ? + init.arguments[0] + : null; + + const classBody = []; + + if (config) { + for (const prop of config.properties) { + if (prop.type === 'SpreadElement' || prop.type === 'RestElement') + continue; + const key = + prop.key.type === 'Identifier' ? prop.key.name + : prop.key.type === 'StringLiteral' ? prop.key.value + : null; + if (!key) continue; + + if (key === 'baseURL') { + const urlProp = j.classProperty( + j.identifier('urlPrefix'), + prop.value, + ); + urlProp.static = false; + classBody.push(urlProp); + } else if (key === 'headers') { + const headersMethod = j.methodDefinition( + 'method', + j.identifier('getHeaders'), + j.functionExpression( + null, + [j.identifier('headers')], + j.blockStatement([ + j.returnStatement( + j.objectExpression([ + j.spreadElement(j.identifier('headers')), + ...extractHeaderProperties(j, prop.value), + ]), + ), + ]), + ), + false, + ); + classBody.push(headersMethod); + } + } + } + + const classDecl = j.classDeclaration( + j.identifier(className), + j.classBody(classBody), + j.identifier('RestEndpoint'), + ); + + const parentPath = declPath.parent; + if (parentPath.node.type === 'VariableDeclaration') { + const variableDeclPath = parentPath; + const variableDecl = variableDeclPath.node; + const statementPath = + variableDeclPath.parent.node.type === 'ExportNamedDeclaration' ? + variableDeclPath.parent + : variableDeclPath; + const classStatement = + statementPath.node.type === 'ExportNamedDeclaration' ? + j.exportNamedDeclaration(classDecl) + : classDecl; + + if (variableDecl.declarations.length === 1) { + j(statementPath).replaceWith(classStatement); + } else { + variableDecl.declarations = variableDecl.declarations.filter( + declaration => declaration !== declPath.node, + ); + j(statementPath).insertBefore(classStatement); + } + } else { + j(declPath).replaceWith(classDecl); + } + dirty = true; + }); + + return { dirty, createToClass }; +} + +// --- 3. Transform direct axios/instance method calls --- + +function transformDirectCalls( + j, + root, + axiosLocalName, + axiosImportScope, + createToClass, +) { + let dirty = false; + const callTargets = new Set([axiosLocalName, ...createToClass.keys()]); + + root.find(j.CallExpression).forEach(callPath => { + const callee = callPath.node.callee; + if ( + !callee || + callee.type !== 'MemberExpression' || + callee.object.type !== 'Identifier' || + !callTargets.has(callee.object.name) || + callee.property.type !== 'Identifier' || + !HTTP_METHODS.has(callee.property.name) + ) { + return; + } + + const methodName = callee.property.name; + const args = callPath.node.arguments; + if (args.length === 0) return; + + const targetName = callee.object.name; + const callBindingScope = callPath.scope.lookup(targetName); + + let className; + if (targetName === axiosLocalName) { + // Only transform calls that still refer to the imported axios binding. + if (callBindingScope !== axiosImportScope) return; + } else { + const scopedClassNames = createToClass.get(targetName); + if (!scopedClassNames) return; + className = scopedClassNames.get(callBindingScope); + // Avoid rewriting shadowed identifiers with the same name. + if (!className) return; + } + + const urlArg = args[0]; + if (urlArg.type !== 'StringLiteral' && urlArg.type !== 'TemplateLiteral') + return; + + const isCreatedInstance = Boolean(className); + + // Keep existing no-op behavior for direct axios calls with extra args. + // However, created instances must still be transformed so we don't leave + // dangling references after axios.create() is replaced by a class. + if (args.length > 1 && !isCreatedInstance) return; + + const method = methodName.toUpperCase(); + + const pathValue = + urlArg.type === 'StringLiteral' ? j.stringLiteral(urlArg.value) : urlArg; + + const properties = [j.objectProperty(j.identifier('path'), pathValue)]; + + if (method !== 'GET') { + properties.push( + j.objectProperty(j.identifier('method'), j.stringLiteral(method)), + ); + } + + let newExpr; + if (className) { + newExpr = j.newExpression(j.identifier(className), [ + j.objectExpression(properties), + ]); + } else { + newExpr = j.newExpression(j.identifier('RestEndpoint'), [ + j.objectExpression(properties), + ]); + } + + j(callPath).replaceWith(newExpr); + dirty = true; + }); + + return dirty; +} + +// --- Main --- + +module.exports = function transformer(fileInfo, api) { + // skip parsing files that can't import axios + if (!fileInfo.source.includes('axios')) return fileInfo.source; + const j = api.jscodeshift; + const root = j(fileInfo.source); + + const axiosImport = root + .find(j.ImportDeclaration) + .filter(p => p.node.source.value === 'axios'); + if (!axiosImport.length) return fileInfo.source; + + let axiosLocalName = 'axios'; + let axiosImportScope = null; + axiosImport.forEach(p => { + const defaultSpec = p.node.specifiers.find( + s => s.type === 'ImportDefaultSpecifier', + ); + if (defaultSpec) { + axiosLocalName = defaultSpec.local.name; + axiosImportScope = p.scope.lookup(axiosLocalName) || p.scope; + } + }); + + let dirty = false; + + const createResult = transformAxiosCreate(j, root, axiosLocalName); + dirty = createResult.dirty || dirty; + + const directCallsDirty = transformDirectCalls( + j, + root, + axiosLocalName, + axiosImportScope, + createResult.createToClass, + ); + dirty = directCallsDirty || dirty; + + dirty = + transformImports( + j, + root, + axiosLocalName, + createResult.dirty || directCallsDirty, + ) || dirty; + + return dirty ? root.toSource({ quote: 'single' }) : fileInfo.source; +}; + +module.exports.parser = 'tsx'; diff --git a/packages/codemods/src/transforms/v0.15.js b/packages/codemods/src/transforms/v0.15.js new file mode 100644 index 000000000000..0642f079fd0f --- /dev/null +++ b/packages/codemods/src/transforms/v0.15.js @@ -0,0 +1,301 @@ +'use strict'; + +/** + * @data-client v0.15 migration codemod + * + * Transforms: + * 1. useDebounce() plain value → [value] array destructuring + * 2. state.entityMeta → state.entitiesMeta + * 3. MemoCache.buildQueryKey / .query state arg consolidation + * 4. INVALID import → delegate.INVALID + * + * Usage: + * npx @data-client/codemods v0.15 src/ + */ + +// --- useDebounce() → [value, isPending] --- + +function transformUseDebounce(j, root) { + let dirty = false; + + const localNames = new Set(); + root.find(j.ImportDeclaration).forEach(importPath => { + const source = importPath.node.source.value; + if (source !== '@data-client/react' && source !== '@data-client/vue') + return; + importPath.node.specifiers.forEach(s => { + if ( + s.type === 'ImportSpecifier' && + s.imported && + (s.imported.name === 'useDebounce' || + s.imported.value === 'useDebounce') + ) { + localNames.add(s.local.name); + } + }); + }); + if (!localNames.size) return false; + + root.find(j.VariableDeclarator).forEach(declPath => { + const init = declPath.node.init; + if ( + !init || + init.type !== 'CallExpression' || + !init.callee || + init.callee.type !== 'Identifier' || + !localNames.has(init.callee.name) || + declPath.node.id.type !== 'Identifier' + ) { + return; + } + const pattern = j.arrayPattern([j.identifier(declPath.node.id.name)]); + declPath.node.id = pattern; + dirty = true; + }); + + return dirty; +} + +// --- state.entityMeta → state.entitiesMeta --- + +function transformEntityMeta(j, root) { + let dirty = false; + + const memberTypes = [j.MemberExpression]; + if (j.OptionalMemberExpression) memberTypes.push(j.OptionalMemberExpression); + + memberTypes.forEach(type => { + root + .find(type, { + computed: false, + property: { type: 'Identifier', name: 'entityMeta' }, + }) + .forEach(path => { + path.node.property.name = 'entitiesMeta'; + dirty = true; + }); + }); + + root.find(j.StringLiteral, { value: 'entityMeta' }).forEach(path => { + const parent = path.parent.node; + if ( + (parent.type === 'Property' || parent.type === 'ObjectProperty') && + parent.key === path.node + ) { + path.node.value = 'entitiesMeta'; + if (path.node.extra) { + delete path.node.extra.raw; + delete path.node.extra.rawValue; + } + dirty = true; + } + }); + + const propertyTypes = [j.Property]; + if (j.ObjectProperty) propertyTypes.push(j.ObjectProperty); + + propertyTypes.forEach(type => { + root + .find(type, { + computed: false, + key: { type: 'Identifier', name: 'entityMeta' }, + }) + .forEach(path => { + const prop = path.node; + prop.key = j.identifier('entitiesMeta'); + if (prop.shorthand) { + prop.shorthand = false; + } + dirty = true; + }); + }); + + return dirty; +} + +// --- MemoCache.buildQueryKey / .query state consolidation --- + +function transformMemoCacheState(j, root) { + let dirty = false; + + root.find(j.CallExpression).forEach(callPath => { + const callee = callPath.node.callee; + if ( + !callee || + callee.type !== 'MemberExpression' || + callee.property.type !== 'Identifier' + ) + return; + + const method = callee.property.name; + if (method !== 'buildQueryKey' && method !== 'query') return; + + const args = callPath.node.arguments; + // buildQueryKey(schema, args, X.entities, X.indexes, ...rest) + // query(schema, args, X.entities, X.indexes) + if (args.length < 4) return; + + const entitiesArg = args[2]; + const indexesArg = args[3]; + + if ( + entitiesArg.type !== 'MemberExpression' || + indexesArg.type !== 'MemberExpression' || + entitiesArg.property.type !== 'Identifier' || + indexesArg.property.type !== 'Identifier' || + entitiesArg.property.name !== 'entities' || + indexesArg.property.name !== 'indexes' + ) + return; + + const objA = entitiesArg.object; + const objB = indexesArg.object; + if (!nodesEqual(objA, objB)) return; + + const rest = args.slice(4); + callPath.node.arguments = [args[0], args[1], objA, ...rest]; + dirty = true; + }); + + return dirty; +} + +function nodesEqual(a, b) { + if (a.type !== b.type) return false; + if (a.type === 'Identifier') return a.name === b.name; + if (a.type === 'MemberExpression') { + return nodesEqual(a.object, b.object) && nodesEqual(a.property, b.property); + } + if (a.type === 'ThisExpression') return true; + return false; +} + +// --- INVALID import → delegate.INVALID --- + +function isValuePosition(idPath) { + const parent = idPath.parent.node; + + // Skip: import specifier + if ( + parent.type === 'ImportSpecifier' && + (parent.imported === idPath.node || parent.local === idPath.node) + ) + return false; + + // Skip: export specifier + if ( + parent.type === 'ExportSpecifier' && + (parent.local === idPath.node || parent.exported === idPath.node) + ) + return false; + + // Skip: non-computed member property (obj.INVALID — the .INVALID part) + if ( + (parent.type === 'MemberExpression' || + parent.type === 'OptionalMemberExpression') && + parent.property === idPath.node && + !parent.computed + ) + return false; + + // Skip: object property key (non-computed) + if ( + (parent.type === 'Property' || parent.type === 'ObjectProperty') && + parent.key === idPath.node && + !parent.computed + ) + return false; + + return true; +} + +function transformInvalid(j, root) { + let dirty = false; + + root.find(j.ImportDeclaration).forEach(importPath => { + const source = importPath.node.source.value; + if (source !== '@data-client/endpoint') return; + + const specs = importPath.node.specifiers; + const idx = specs.findIndex( + s => + s.type === 'ImportSpecifier' && + s.imported && + (s.imported.name === 'INVALID' || s.imported.value === 'INVALID'), + ); + if (idx === -1) return; + + const localName = specs[idx].local.name; + + // First pass: delegate.setEntity(schema, pk, INVALID) + // → delegate.invalidate(schema, pk) + root.find(j.CallExpression).forEach(callPath => { + const callee = callPath.node.callee; + if ( + !callee || + callee.type !== 'MemberExpression' || + callee.property.type !== 'Identifier' || + callee.property.name !== 'setEntity' + ) + return; + const args = callPath.node.arguments; + if (args.length < 3) return; + const last = args[args.length - 1]; + if (last.type !== 'Identifier' || last.name !== localName) return; + + callee.property.name = 'invalidate'; + callPath.node.arguments = args.slice(0, -1); + dirty = true; + }); + + // Second pass: remaining INVALID references → delegate.INVALID + root.find(j.Identifier, { name: localName }).forEach(idPath => { + if (!isValuePosition(idPath)) return; + + j(idPath).replaceWith( + j.memberExpression(j.identifier('delegate'), j.identifier('INVALID')), + ); + dirty = true; + }); + + // Keep the INVALID import when it is still needed by local exports: + // `export { INVALID }` must retain a local INVALID binding. + const hasLocalExport = root.find(j.ExportSpecifier).filter(specPath => { + const exportDecl = specPath.parent.node; + return ( + exportDecl.type === 'ExportNamedDeclaration' && + !exportDecl.source && + specPath.node.local && + specPath.node.local.type === 'Identifier' && + specPath.node.local.name === localName + ); + }).length; + + if (!hasLocalExport) { + specs.splice(idx, 1); + if (!specs.length) { + j(importPath).remove(); + } + dirty = true; + } + }); + + return dirty; +} + +// --- Main --- + +module.exports = function transformer(fileInfo, api) { + const j = api.jscodeshift; + const root = j(fileInfo.source); + let dirty = false; + + dirty = transformUseDebounce(j, root) || dirty; + dirty = transformEntityMeta(j, root) || dirty; + dirty = transformMemoCacheState(j, root) || dirty; + dirty = transformInvalid(j, root) || dirty; + + return dirty ? root.toSource({ quote: 'single' }) : fileInfo.source; +}; + +module.exports.parser = 'tsx'; diff --git a/packages/codemods/src/transforms/v0.16.js b/packages/codemods/src/transforms/v0.16.js new file mode 100644 index 000000000000..e2d2ea9b3f7a --- /dev/null +++ b/packages/codemods/src/transforms/v0.16.js @@ -0,0 +1,468 @@ +'use strict'; + +/** + * @data-client v0.16 migration codemod + * + * Transforms: + * 1. path-to-regexp v6 → v8 syntax in RestEndpoint/resource path strings + * 2. useFetch() truthiness checks → .resolved checks + * 3. schema.X namespace → direct imports + * + * Usage: + * npx @data-client/codemods v0.16 src/ + */ + +// Schema and path rewrites apply only to the packages that export `schema` +// and `RestEndpoint`; the useFetch rewrite applies to any @data-client package. +const DATA_CLIENT_PACKAGES = new Set([ + '@data-client/endpoint', + '@data-client/rest', +]); + +// --- path-to-regexp v6 → v8 --- + +function transformPathString(s) { + // :name(\d+) → :name (handles nested non-capturing groups) + s = s.replace(/:(\w+)\([^()]*(?:\([^()]*\)[^()]*)*\)/g, ':$1'); + // {group}? → {group} + s = s.replace(/(\{[^}]+\})\?/g, '$1'); + // /:name? → {/:name} (also handles - . ~ prefixes) + s = s.replace(/([/\-.~]):(\w+)\?/g, '{$1:$2}'); + // /:name+ → /*name + s = s.replace(/\/:(\w+)\+/g, '/*$1'); + // /:name* → {/*name} + s = s.replace(/\/:(\w+)\*/g, '{/*$1}'); + // Literal `?` and `+` in the path still require `\\?` / `\\+` in v8 (see path-to-regexp errors). + // Do not strip those escapes — e.g. `.../public\\?per_page=50` must stay escaped. + return s; +} + +function transformPaths(j, root) { + let dirty = false; + + const hasDataClientImport = root + .find(j.ImportDeclaration) + .filter(importPath => + DATA_CLIENT_PACKAGES.has(importPath.node.source.value), + ).length; + if (!hasDataClientImport) return false; + + root.find(j.StringLiteral).forEach(path => { + const parent = path.parent.node; + if ( + (parent.type === 'Property' || parent.type === 'ObjectProperty') && + parent.value === path.node && + parent.key && + (parent.key.name === 'path' || + (parent.key.type === 'StringLiteral' && parent.key.value === 'path')) + ) { + const updated = transformPathString(path.node.value); + if (updated !== path.node.value) { + path.node.value = updated; + if (path.node.extra) { + delete path.node.extra.raw; + delete path.node.extra.rawValue; + } + dirty = true; + } + } + }); + + return dirty; +} + +// --- useFetch() truthiness → .resolved --- + +function enclosingFunction(path) { + let cur = path.parent; + while (cur) { + const t = cur.node.type; + if ( + t === 'FunctionDeclaration' || + t === 'FunctionExpression' || + t === 'ArrowFunctionExpression' + ) { + return cur; + } + cur = cur.parent; + } + return null; +} + +function transformUseFetch(j, root) { + let dirty = false; + + const hasDataClientUseFetch = root + .find(j.ImportDeclaration) + .filter(importPath => { + const source = importPath.node.source.value; + if (!source.startsWith('@data-client/')) return false; + return importPath.node.specifiers.some( + s => + s.type === 'ImportSpecifier' && + s.imported && + (s.imported.name === 'useFetch' || s.imported.value === 'useFetch'), + ); + }).length; + if (!hasDataClientUseFetch) return false; + + root.find(j.VariableDeclarator).forEach(declPath => { + const init = declPath.node.init; + if ( + !init || + init.type !== 'CallExpression' || + !init.callee || + init.callee.type !== 'Identifier' || + init.callee.name !== 'useFetch' || + declPath.node.id.type !== 'Identifier' + ) { + return; + } + const varName = declPath.node.id.name; + const fnScope = enclosingFunction(declPath); + if (!fnScope) return; + + const scopeRoot = j(fnScope); + + function rewrite(test) { + // promise → promise?.resolved === false + if (test.type === 'Identifier' && test.name === varName) { + return j.binaryExpression( + '===', + j.optionalMemberExpression( + j.identifier(test.name), + j.identifier('resolved'), + false, + true, + ), + j.booleanLiteral(false), + ); + } + // !promise → promise?.resolved !== false + if ( + test.type === 'UnaryExpression' && + test.operator === '!' && + test.argument.type === 'Identifier' && + test.argument.name === varName + ) { + return j.binaryExpression( + '!==', + j.optionalMemberExpression( + j.identifier(test.argument.name), + j.identifier('resolved'), + false, + true, + ), + j.booleanLiteral(false), + ); + } + return null; + } + + const stopNode = fnScope.node.body; + + [j.IfStatement, j.ConditionalExpression].forEach(type => { + scopeRoot.find(type).forEach(p => { + if (isShadowed(p, varName, stopNode)) return; + const r = rewrite(p.node.test); + if (r) { + p.node.test = r; + dirty = true; + } + }); + }); + + scopeRoot + .find(j.LogicalExpression) + .filter(p => p.node.operator === '&&' || p.node.operator === '||') + .forEach(p => { + if (isShadowed(p, varName, stopNode)) return; + const l = rewrite(p.node.left); + if (l) { + p.node.left = l; + dirty = true; + } + }); + }); + + return dirty; +} + +// --- schema.X namespace → direct imports --- + +// These schema members are type-only exports and cannot be imported as values. +// They must stay as schema.Object / schema.Array namespace access. +const TYPE_ONLY_SCHEMAS = new Set(['Object', 'Array']); + +const JS_GLOBALS = new Set([ + 'Array', + 'Boolean', + 'Date', + 'Error', + 'Function', + 'JSON', + 'Map', + 'Math', + 'Number', + 'Object', + 'Promise', + 'Proxy', + 'Reflect', + 'RegExp', + 'Set', + 'String', + 'Symbol', + 'WeakMap', + 'WeakSet', +]); + +function patternBindsName(pattern, name) { + if (!pattern) return false; + if (pattern.type === 'Identifier') return pattern.name === name; + if (pattern.type === 'AssignmentPattern') + return patternBindsName(pattern.left, name); + if (pattern.type === 'RestElement') + return patternBindsName(pattern.argument, name); + if (pattern.type === 'ObjectPattern') { + return pattern.properties.some(p => + p.type === 'RestElement' ? + patternBindsName(p.argument, name) + : patternBindsName(p.value, name), + ); + } + if (pattern.type === 'ArrayPattern') { + return pattern.elements.some(e => e && patternBindsName(e, name)); + } + return false; +} + +function collectPatternNames(pattern, out) { + if (!pattern) return; + if (pattern.type === 'Identifier') { + out.add(pattern.name); + return; + } + if (pattern.type === 'AssignmentPattern') + return collectPatternNames(pattern.left, out); + if (pattern.type === 'RestElement') + return collectPatternNames(pattern.argument, out); + if (pattern.type === 'ObjectPattern') { + pattern.properties.forEach(p => + collectPatternNames(p.type === 'RestElement' ? p.argument : p.value, out), + ); + return; + } + if (pattern.type === 'ArrayPattern') { + pattern.elements.forEach(e => e && collectPatternNames(e, out)); + return; + } +} + +function isShadowed(path, name, stopNode) { + let cur = path; + while (cur.parent) { + cur = cur.parent; + if (stopNode && cur.node === stopNode) return false; + const node = cur.node; + + if ( + node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression' + ) { + if (node.params && node.params.some(p => patternBindsName(p, name))) { + return true; + } + } + + if (node.type === 'CatchClause') { + if (node.param && patternBindsName(node.param, name)) { + return true; + } + } + + if (node.type === 'BlockStatement' || node.type === 'Program') { + const stmts = node.body || []; + for (const stmt of stmts) { + if (stmt.type === 'VariableDeclaration') { + for (const decl of stmt.declarations) { + if (patternBindsName(decl.id, name)) return true; + } + } + if ( + (stmt.type === 'FunctionDeclaration' || + stmt.type === 'ClassDeclaration') && + stmt.id && + stmt.id.name === name + ) { + return true; + } + } + } + + if ( + node.type === 'ForStatement' || + node.type === 'ForInStatement' || + node.type === 'ForOfStatement' + ) { + const init = node.init || node.left; + if (init && init.type === 'VariableDeclaration') { + for (const decl of init.declarations) { + if (patternBindsName(decl.id, name)) return true; + } + } + } + } + return false; +} + +function transformSchemaImports(j, root) { + let dirty = false; + + root.find(j.ImportDeclaration).forEach(importPath => { + const source = importPath.node.source.value; + if (!DATA_CLIENT_PACKAGES.has(source)) return; + + const specs = importPath.node.specifiers; + const idx = specs.findIndex( + s => + s.type === 'ImportSpecifier' && + s.imported && + (s.imported.name === 'schema' || s.imported.value === 'schema'), + ); + if (idx === -1) return; + + const local = specs[idx].local.name; + // Map from exported name → local identifier to use in code + const used = new Map(); + + const scopeBindings = new Set(); + root.find(j.ImportDeclaration).forEach(ip => { + if (ip === importPath) return; + ip.node.specifiers.forEach(s => { + if (s.local) scopeBindings.add(s.local.name); + }); + }); + const isTopLevel = parentType => + parentType === 'Program' || + parentType === 'ExportNamedDeclaration' || + parentType === 'ExportDefaultDeclaration'; + root.find(j.VariableDeclaration).forEach(vp => { + if (isTopLevel(vp.parent.node.type)) { + vp.node.declarations.forEach(d => + collectPatternNames(d.id, scopeBindings), + ); + } + }); + root.find(j.FunctionDeclaration).forEach(fp => { + if (isTopLevel(fp.parent.node.type) && fp.node.id) { + scopeBindings.add(fp.node.id.name); + } + }); + root.find(j.ClassDeclaration).forEach(cp => { + if (isTopLevel(cp.parent.node.type) && cp.node.id) { + scopeBindings.add(cp.node.id.name); + } + }); + + function resolveLocal(name) { + if (!JS_GLOBALS.has(name) && !scopeBindings.has(name)) { + return name; + } + let candidate = 'Schema' + name; + while (JS_GLOBALS.has(candidate) || scopeBindings.has(candidate)) { + candidate = '_' + candidate; + } + return candidate; + } + + root + .find(j.MemberExpression, { + object: { type: 'Identifier', name: local }, + }) + .forEach(mp => { + if (isShadowed(mp, local)) return; + if (mp.node.property.type === 'Identifier') { + const name = mp.node.property.name; + if (TYPE_ONLY_SCHEMAS.has(name)) return; + if (!used.has(name)) used.set(name, resolveLocal(name)); + j(mp).replaceWith(j.identifier(used.get(name))); + } + }); + + // TypeScript qualified names: schema.Union in type positions + try { + root + .find(j.TSQualifiedName, { + left: { type: 'Identifier', name: local }, + }) + .forEach(qp => { + if (isShadowed(qp, local)) return; + if (qp.node.right.type === 'Identifier') { + const name = qp.node.right.name; + if (TYPE_ONLY_SCHEMAS.has(name)) return; + if (!used.has(name)) used.set(name, resolveLocal(name)); + j(qp).replaceWith(j.identifier(used.get(name))); + } + }); + } catch { + // parsers without TypeScript nodes have no qualified names to rewrite + } + + if (!used.size) return; + + // Only remove the schema import specifier when there are no remaining + // bare references (e.g. destructuring, function args, typeof). + const bareRefs = root.find(j.Identifier, { name: local }).filter(p => { + const parent = p.parent.node; + if ( + parent.type === 'ImportSpecifier' && + (parent.imported === p.node || parent.local === p.node) + ) + return false; + return true; + }); + if (!bareRefs.length) { + specs.splice(idx, 1); + } + + const existingLocals = new Set( + specs.filter(s => s.type === 'ImportSpecifier').map(s => s.local.name), + ); + for (const [imported, localName] of [...used.entries()].sort((a, b) => + a[0].localeCompare(b[0]), + )) { + if (!existingLocals.has(localName)) { + if (imported !== localName) { + specs.push( + j.importSpecifier(j.identifier(imported), j.identifier(localName)), + ); + } else { + specs.push(j.importSpecifier(j.identifier(imported))); + } + } + } + + dirty = true; + }); + + return dirty; +} + +// --- Main --- + +module.exports = function transformer(fileInfo, api) { + // every rewrite needs a @data-client import; skip parsing files without one + if (!fileInfo.source.includes('@data-client/')) return fileInfo.source; + const j = api.jscodeshift; + const root = j(fileInfo.source); + let dirty = false; + + dirty = transformPaths(j, root) || dirty; + dirty = transformUseFetch(j, root) || dirty; + dirty = transformSchemaImports(j, root) || dirty; + + return dirty ? root.toSource({ quote: 'single' }) : fileInfo.source; +}; + +module.exports.parser = 'tsx'; diff --git a/packages/codemods/src/transforms/v0.18.js b/packages/codemods/src/transforms/v0.18.js new file mode 100644 index 000000000000..321a98319312 --- /dev/null +++ b/packages/codemods/src/transforms/v0.18.js @@ -0,0 +1,583 @@ +'use strict'; + +/** + * @data-client v0.18 migration codemod + * + * Transforms: + * 1. Schema.denormalize(input, args, unvisit) → denormalize(input, delegate) + * - Class methods, object methods, function declarations + * - TypeScript method signatures and property types + * - Renames usages: unvisit(...) → delegate.unvisit(...), args → delegate.args + * - Pass-through calls foo.denormalize(input, args, unvisit) + * → foo.denormalize(input, delegate) when delegate is in scope + * 2. Adds `IDenormalizeDelegate` import from `@data-client/endpoint` (or + * `@data-client/rest` / `@data-client/normalizr` if those are already used) + * when type annotations are required. + * 3. Schema.normalize(input, parent, key, args, visit, delegate, parentEntity?) + * → normalize(input, parent, key, delegate, parentEntity?) + * - Renames usages: visit(...) → delegate.visit(...), args → delegate.args + * - Pass-through calls foo.normalize(input, parent, key, args, visit, delegate) + * → foo.normalize(input, parent, key, delegate) when delegate is in scope + * - Adds `INormalizeDelegate` import when type annotations are required. + * + * Usage: + * npx @data-client/codemods v0.18 src/ + */ + +const DATA_CLIENT_PREFIX = '@data-client/'; +// Packages that re-export the delegate types, in order of preference when +// adding the import to an existing declaration. +const DELEGATE_IMPORT_SOURCES = [ + '@data-client/endpoint', + '@data-client/rest', + '@data-client/normalizr', +]; +const DENORMALIZE_DELEGATE_TYPE_NAME = 'IDenormalizeDelegate'; +const NORMALIZE_DELEGATE_TYPE_NAME = 'INormalizeDelegate'; + +function hasDataClientImport(j, root) { + return ( + root.find(j.ImportDeclaration).filter(p => { + const v = p.node.source.value; + return typeof v === 'string' && v.startsWith(DATA_CLIENT_PREFIX); + }).length > 0 + ); +} + +// --- helpers -------------------------------------------------------------- + +function getParamName(param) { + if (!param) return undefined; + if (param.type === 'Identifier') return param.name; + if (param.type === 'AssignmentPattern' && param.left.type === 'Identifier') + return param.left.name; + if (param.type === 'TSParameterProperty' && param.parameter) + return getParamName(param.parameter); + return undefined; +} + +// The name of an `ident` or `'string'` key +function keyName(key) { + if (!key) return undefined; + if (key.type === 'Identifier') return key.name; + if (key.type === 'StringLiteral') return key.value; + return undefined; +} + +const isDenormalizeKey = key => keyName(key) === 'denormalize'; +const isNormalizeKey = key => keyName(key) === 'normalize'; + +// Broader for type-only declarations (class fields, property signatures): +// matches `denormalize`, `_denormalize`, `_denormalizeNullable`, etc. +const DENORMALIZE_LIKE = /^_?denormalize(Nullable)?$/; +const isDenormalizeLikeKey = key => DENORMALIZE_LIKE.test(keyName(key)); + +function buildDelegateParam(j, name, withTypes, typeName) { + const id = j.identifier(name); + if (withTypes) { + id.typeAnnotation = j.tsTypeAnnotation( + j.tsTypeReference(j.identifier(typeName)), + ); + } + return id; +} + +function paramsLookLikeDenormalize(params) { + if (!params || params.length !== 3) return false; + const argsName = getParamName(params[1]); + const unvisitName = getParamName(params[2]); + if (argsName === 'args' && unvisitName === 'unvisit') return true; + if (argsName === '_args' && unvisitName === '_unvisit') return true; + // Typed signature where 3rd param looks like a function type. + const ta3 = params[2] && params[2].typeAnnotation; + const tn = ta3 && (ta3.typeAnnotation || ta3); + return !!tn && tn.type === 'TSFunctionType'; +} + +function paramsLookLikeNormalize(params) { + if (!params || (params.length !== 6 && params.length !== 7)) return false; + const argsName = getParamName(params[3]); + const visitName = getParamName(params[4]); + if (argsName === 'args' && visitName === 'visit') return true; + if (argsName === '_args' && visitName === '_visit') return true; + // Typed signature where 5th param looks like a function type. + const ta5 = params[4] && params[4].typeAnnotation; + const tn = ta5 && (ta5.typeAnnotation || ta5); + return !!tn && tn.type === 'TSFunctionType'; +} + +// Whether an identifier reference at `p` is rebound by a parameter of a +// function nested inside `bodyNode`. +// Deliberately param-only (unlike v0.16's full lexical check): a `const args` +// inside the body is not treated as shadowing. Change only with tests. +function isShadowedByParam(p, name, bodyNode) { + for (let cur = p; cur && cur.node !== bodyNode; cur = cur.parent) { + const t = cur.node.type; + if ( + t === 'FunctionDeclaration' || + t === 'FunctionExpression' || + t === 'ArrowFunctionExpression' || + t === 'ObjectMethod' || + t === 'ClassMethod' + ) { + if (cur.node.params.some(prm => getParamName(prm) === name)) return true; + } + } + return false; +} + +// Identifier references that are names, not reads: `x.`, `{ : v }`, +// `const =`. +function isNonReferencePosition(p) { + const parent = p.parent.node; + if (!parent) return true; + if ( + parent.type === 'MemberExpression' && + parent.property === p.node && + !parent.computed + ) + return true; + if ( + parent.type === 'ObjectProperty' && + parent.key === p.node && + !parent.computed + ) + return true; + if (parent.type === 'VariableDeclarator' && parent.id === p.node) return true; + return false; +} + +// Rewrites the references shared by both method kinds: +// → . (bare reference, not a call) +// → .args +// Calls `(...)` are handled by each caller because their argument +// rewrites differ. +function rewriteDelegateReferences( + j, + body, + bodyNode, + { argsName, fnName, member, delegateName }, +) { + const delegateMember = name => + j.memberExpression(j.identifier(delegateName), j.identifier(name)); + + if (fnName) { + body + .find(j.Identifier, { name: fnName }) + .filter(p => { + if (isNonReferencePosition(p)) return false; + const parent = p.parent.node; + return !(parent.type === 'CallExpression' && parent.callee === p.node); + }) + .forEach(p => { + if (isShadowedByParam(p, fnName, bodyNode)) return; + j(p).replaceWith(delegateMember(member)); + }); + } + + if (argsName) { + body + .find(j.Identifier, { name: argsName }) + .filter(p => { + if (isNonReferencePosition(p)) return false; + const parent = p.parent.node; + return !( + parent.type === 'TSTypeReference' || parent.type === 'TSQualifiedName' + ); + }) + .forEach(p => { + if (isShadowedByParam(p, argsName, bodyNode)) return; + j(p).replaceWith(delegateMember('args')); + }); + } +} + +// A block, or an arrow function's expression body +function findMethodBody(methodPath) { + return methodPath.node.body ? methodPath.get('body') : undefined; +} + +// Like j(bodyPath), but its find() also matches the body itself, so an arrow's +// expression body `(…, visit) => visit(…)` is rewritten too +function bodyScope(j, bodyPath) { + const isInBody = p => { + for (let cur = p; cur; cur = cur.parent) { + if (cur.node === bodyPath.value) return true; + } + return false; + }; + return { + find: (type, filter) => + j(bodyPath.parent).find(type, filter).filter(isInBody), + }; +} + +// Rewrite identifier references inside a denormalize method body NodePath: +// → delegate.args +// (...) → delegate.unvisit(...) +// foo.denormalize(x, args, unvisit) → foo.denormalize(x, delegate) +function rewriteBody(j, methodPath, argsName, unvisitName) { + const bodyPath = findMethodBody(methodPath); + if (!bodyPath) return; + const bodyNode = bodyPath.node; + const body = bodyScope(j, bodyPath); + + // 1. Rewrite pass-through calls: x.denormalize(input, args, unvisit) → x.denormalize(input, delegate) + body + .find(j.CallExpression) + .filter(p => { + const callee = p.node.callee; + if (!callee || callee.type !== 'MemberExpression') return false; + if (!isDenormalizeKey(callee.property)) return false; + const args = p.node.arguments; + if (args.length !== 3) return false; + return ( + args[1].type === 'Identifier' && + args[1].name === argsName && + args[2].type === 'Identifier' && + args[2].name === unvisitName + ); + }) + .forEach(p => { + if ( + isShadowedByParam(p, argsName, bodyNode) || + isShadowedByParam(p, unvisitName, bodyNode) + ) + return; + p.node.arguments = [p.node.arguments[0], j.identifier('delegate')]; + }); + + // 2. unvisit(...) → delegate.unvisit(...) + if (unvisitName) { + body + .find(j.CallExpression, { + callee: { type: 'Identifier', name: unvisitName }, + }) + .forEach(p => { + if (isShadowedByParam(p, unvisitName, bodyNode)) return; + p.node.callee = j.memberExpression( + j.identifier('delegate'), + j.identifier('unvisit'), + ); + }); + } + + // 3. bare `unvisit` → `delegate.unvisit`; bare `args` → `delegate.args` + rewriteDelegateReferences(j, body, bodyNode, { + argsName, + fnName: unvisitName, + member: 'unvisit', + delegateName: 'delegate', + }); +} + +function rewriteNormalizeBody( + j, + methodPath, + argsName, + visitName, + delegateName, +) { + const bodyPath = findMethodBody(methodPath); + if (!bodyPath) return; + const bodyNode = bodyPath.node; + const body = bodyScope(j, bodyPath); + + // 1. Rewrite pass-through calls: + // x.normalize(input, parent, key, args, visit, delegate[, parentEntity]) + // → x.normalize(input, parent, key, delegate[, parentEntity]) + body + .find(j.CallExpression) + .filter(p => { + const callee = p.node.callee; + if (!callee || callee.type !== 'MemberExpression') return false; + if (!isNormalizeKey(callee.property)) return false; + const callArgs = p.node.arguments; + if (callArgs.length !== 6 && callArgs.length !== 7) return false; + return ( + callArgs[3].type === 'Identifier' && + callArgs[3].name === argsName && + callArgs[4].type === 'Identifier' && + callArgs[4].name === visitName && + callArgs[5].type === 'Identifier' && + callArgs[5].name === delegateName + ); + }) + .forEach(p => { + if ( + isShadowedByParam(p, argsName, bodyNode) || + isShadowedByParam(p, visitName, bodyNode) || + isShadowedByParam(p, delegateName, bodyNode) + ) + return; + p.node.arguments = [ + p.node.arguments[0], + p.node.arguments[1], + p.node.arguments[2], + j.identifier(delegateName), + ...p.node.arguments.slice(6), + ]; + }); + + // 2. visit(schema, value, parent, key, args) → delegate.visit(schema, value, parent, key) + if (visitName) { + body + .find(j.CallExpression, { + callee: { type: 'Identifier', name: visitName }, + }) + .forEach(p => { + if (isShadowedByParam(p, visitName, bodyNode)) return; + const callArgs = p.node.arguments; + if ( + callArgs.length === 5 && + callArgs[4].type === 'Identifier' && + callArgs[4].name === argsName + ) { + p.node.arguments = callArgs.slice(0, 4); + } + p.node.callee = j.memberExpression( + j.identifier(delegateName), + j.identifier('visit'), + ); + }); + } + + // 3. bare `visit` → `delegate.visit`; bare `args` → `delegate.args` + rewriteDelegateReferences(j, body, bodyNode, { + argsName, + fnName: visitName, + member: 'visit', + delegateName, + }); +} + +// --- shared walkers -------------------------------------------------------- + +function isFunctionValue(v) { + return ( + !!v && + (v.type === 'FunctionExpression' || v.type === 'ArrowFunctionExpression') + ); +} + +// Visits every runtime function-like declaration whose key passes `isKey`: +// class methods and fields, object methods, `{ key: function/arrow }` +// properties and top-level `function key()`. `rewrite(fnPath, paramsHolder)` +// returns whether it changed anything. +function forEachMethodLike(j, root, isKey, rewrite) { + let dirty = false; + const visit = (fnPath, paramsHolder) => { + if (rewrite(fnPath, paramsHolder)) dirty = true; + }; + + for (const type of [j.ClassMethod, j.ObjectMethod]) { + root.find(type).forEach(p => { + if (isKey(p.node.key)) visit(p, p.node); + }); + } + + // { key: function(...) {} }, { key: (...) => {} } and class fields + // `key = (...) => {}` + for (const type of [j.ObjectProperty, j.ClassProperty]) { + root + .find(type) + .filter(p => isKey(p.node.key) && isFunctionValue(p.node.value)) + .forEach(p => visit(p.get('value'), p.node.value)); + } + + // Top-level: function key(...) { ... } + root.find(j.FunctionDeclaration).forEach(p => { + if (p.node.id && isKey(p.node.id)) visit(p, p.node); + }); + + return dirty; +} + +// Visits TS method signatures (keys passing `isMethodKey`) and function-typed +// property signatures / class fields (keys passing `isPropertyKey`). +// `rewriteParams(params)` returns the new parameter list, or null to skip. +function forEachTypeSignature( + j, + root, + { isMethodKey, isPropertyKey, rewriteParams }, +) { + let dirty = false; + + root.find(j.TSMethodSignature).forEach(p => { + if (!isMethodKey(p.node.key)) return; + const newParams = rewriteParams(p.node.parameters); + if (!newParams) return; + p.node.parameters = newParams; + dirty = true; + }); + + function rewriteAnnotatedProperty(p) { + if (!isPropertyKey(p.node.key)) return; + const ann = p.node.typeAnnotation; + const fn = ann && ann.typeAnnotation; + if (!fn || fn.type !== 'TSFunctionType') return; + const newParams = rewriteParams(fn.parameters); + if (!newParams) return; + fn.parameters = newParams; + dirty = true; + } + + for (const type of [j.TSPropertySignature, j.ClassProperty]) { + root.find(type).forEach(rewriteAnnotatedProperty); + } + + return dirty; +} + +// --- denormalize: (input, args, unvisit) → (input, delegate) --------------- + +function denormalizeParams(j, params, withTypes) { + return [ + params[0], + buildDelegateParam( + j, + 'delegate', + withTypes, + DENORMALIZE_DELEGATE_TYPE_NAME, + ), + ]; +} + +function transformDenormalizeMethods(j, root, state) { + return forEachMethodLike(j, root, isDenormalizeKey, (methodPath, holder) => { + const params = holder.params; + if (!paramsLookLikeDenormalize(params)) return false; + const argsName = getParamName(params[1]); + const unvisitName = getParamName(params[2]); + + holder.params = denormalizeParams(j, params, state.useTypes); + rewriteBody(j, methodPath, argsName, unvisitName); + + if (state.useTypes) state.neededImports.add(DENORMALIZE_DELEGATE_TYPE_NAME); + return true; + }); +} + +function transformTypeSignatures(j, root, state) { + return forEachTypeSignature(j, root, { + isMethodKey: isDenormalizeKey, + // Type-only declarations also cover `_denormalize`, `_denormalizeNullable`. + isPropertyKey: isDenormalizeLikeKey, + rewriteParams(params) { + if (!paramsLookLikeDenormalize(params)) return null; + state.neededImports.add(DENORMALIZE_DELEGATE_TYPE_NAME); + return denormalizeParams(j, params, true); + }, + }); +} + +// --- normalize: (input, parent, key, args, visit, delegate, parentEntity?) +// → (input, parent, key, delegate, parentEntity?) --------------- + +function normalizeParams(j, params, withTypes) { + return [ + params[0], + params[1], + params[2], + buildDelegateParam( + j, + getParamName(params[5]) || 'delegate', + withTypes, + NORMALIZE_DELEGATE_TYPE_NAME, + ), + ...params.slice(6), + ]; +} + +function transformNormalizeMethods(j, root, state) { + return forEachMethodLike(j, root, isNormalizeKey, (methodPath, holder) => { + const params = holder.params; + if (!paramsLookLikeNormalize(params)) return false; + const argsName = getParamName(params[3]); + const visitName = getParamName(params[4]); + const delegateName = getParamName(params[5]) || 'delegate'; + + holder.params = normalizeParams(j, params, state.useTypes); + rewriteNormalizeBody(j, methodPath, argsName, visitName, delegateName); + + if (state.useTypes) state.neededImports.add(NORMALIZE_DELEGATE_TYPE_NAME); + return true; + }); +} + +function transformNormalizeTypeSignatures(j, root, state) { + return forEachTypeSignature(j, root, { + isMethodKey: isNormalizeKey, + isPropertyKey: isNormalizeKey, + rewriteParams(params) { + if (!paramsLookLikeNormalize(params)) return null; + state.neededImports.add(NORMALIZE_DELEGATE_TYPE_NAME); + return normalizeParams(j, params, true); + }, + }); +} + +// --- Import injection ----------------------------------------------------- + +function ensureDelegateImport(j, root, typeName) { + if (root.find(j.ImportSpecifier, { imported: { name: typeName } }).length) + return false; + + const target = DELEGATE_IMPORT_SOURCES.map( + name => + root.find(j.ImportDeclaration, { source: { value: name } }).paths()[0], + ).find(Boolean); + if (!target) { + const newImport = j.importDeclaration( + [j.importSpecifier(j.identifier(typeName))], + j.stringLiteral(DELEGATE_IMPORT_SOURCES[0]), + ); + newImport.importKind = 'type'; + const allImports = root.find(j.ImportDeclaration); + if (allImports.length) { + const paths = allImports.paths(); + j(paths[paths.length - 1]).insertAfter(newImport); + } else { + root.get().node.program.body.unshift(newImport); + } + return true; + } + + // Inline `type` keyword so this works under verbatimModuleSyntax / + // consistent-type-imports without importing a value at runtime. + const spec = j.importSpecifier(j.identifier(typeName)); + spec.importKind = 'type'; + target.node.specifiers.push(spec); + return true; +} + +// --- Main ----------------------------------------------------------------- + +module.exports = function transformer(fileInfo, api) { + // skip parsing files that can't import @data-client + if (!fileInfo.source.includes(DATA_CLIENT_PREFIX)) return fileInfo.source; + const j = api.jscodeshift; + const root = j(fileInfo.source); + + if (!hasDataClientImport(j, root)) return fileInfo.source; + + const isTs = + /\.tsx?$/.test(fileInfo.path || '') || + root.find(j.TSTypeAnnotation).length > 0 || + root.find(j.TSInterfaceDeclaration).length > 0; + const state = { useTypes: isTs, neededImports: new Set() }; + + let dirty = false; + dirty = transformDenormalizeMethods(j, root, state) || dirty; + dirty = transformTypeSignatures(j, root, state) || dirty; + dirty = transformNormalizeMethods(j, root, state) || dirty; + dirty = transformNormalizeTypeSignatures(j, root, state) || dirty; + + for (const typeName of state.neededImports) { + ensureDelegateImport(j, root, typeName); + } + + return dirty ? root.toSource({ quote: 'single' }) : fileInfo.source; +}; + +module.exports.parser = 'tsx'; diff --git a/scripts/check-dts-parse.mjs b/scripts/check-dts-parse.mjs index 180f310346c6..b181cd2d74af 100644 --- a/scripts/check-dts-parse.mjs +++ b/scripts/check-dts-parse.mjs @@ -25,7 +25,8 @@ function matches(range) { // Declaration entry points this TypeScript resolves for a package's exports function entryPoints(pkg) { const range = Object.keys(pkg.typesVersions ?? {}).find(matches); - if (!range) return [pkg.types]; + // packages without declarations have none + if (!range) return pkg.types ? [pkg.types] : []; return Object.entries(pkg.typesVersions[range]) .filter(([subpath]) => subpath !== '*') .map(([, [entry]]) => entry); diff --git a/website/static/codemods/axios-to-rest.js b/website/static/codemods/axios-to-rest.js deleted file mode 100644 index 334ea43d1da5..000000000000 --- a/website/static/codemods/axios-to-rest.js +++ /dev/null @@ -1,511 +0,0 @@ -'use strict'; - -/** - * Axios → @data-client/rest migration codemod - * - * Transforms: - * 1. import axios from 'axios' → import { RestEndpoint } from '@data-client/rest' - * 2. axios.create({ baseURL, headers }) → RestEndpoint subclass - * 3. axios.get/post/put/patch/delete(url) → new RestEndpoint({ path, method }) - * - * Usage: - * npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/ - */ - -const AXIOS_TYPE_IMPORTS = new Set([ - 'AxiosResponse', - 'AxiosRequestConfig', - 'AxiosInstance', - 'AxiosHeaders', - 'AxiosInterceptorManager', - 'AxiosPromise', - 'AxiosStatic', - 'AxiosDefaults', - 'AxiosAdapter', - 'AxiosBasicCredentials', - 'AxiosProxyConfig', - 'AxiosTransformer', - 'CancelTokenSource', - 'Canceler', - 'Cancel', - 'InternalAxiosRequestConfig', - 'CreateAxiosDefaults', - 'RawAxiosRequestConfig', - 'RawAxiosRequestHeaders', - 'RawAxiosResponseHeaders', -]); - -const AXIOS_RUNTIME_IMPORTS = new Set(['CancelToken', 'AxiosError']); - -const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete']); - -// --- Helpers --- - -function capitalize(s) { - return s.charAt(0).toUpperCase() + s.slice(1); -} - -function deriveClassName(varName) { - if (!varName) return 'ApiEndpoint'; - const base = varName.replace(/[^a-zA-Z0-9]/g, ''); - if (!base) return 'ApiEndpoint'; - return capitalize(base) + 'Endpoint'; -} - -function getExistingClassNames(j, root) { - const names = new Set(); - root.find(j.ClassDeclaration).forEach(p => { - if (p.node.id) names.add(p.node.id.name); - }); - return names; -} - -function uniqueClassName(desired, existing) { - let name = desired; - while (existing.has(name)) { - name = '_' + name; - } - existing.add(name); - return name; -} - -function extractHeaderProperties(j, node) { - if (node.type === 'ObjectExpression') { - return node.properties.filter( - p => - p.type === 'ObjectProperty' || - p.type === 'Property' || - p.type === 'SpreadElement' || - p.type === 'SpreadProperty', - ); - } - return [j.spreadElement(node)]; -} - -// --- 1. Transform imports --- - -function hasReference(j, root, localName, importScope, excludeTypePositions) { - return ( - root - .find(j.Identifier, { name: localName }) - .filter(path => { - if (path.scope.lookup(localName) !== importScope) { - return false; - } - - const parent = path.parent.node; - - if ( - (parent.type === 'ImportDefaultSpecifier' || - parent.type === 'ImportSpecifier' || - parent.type === 'ImportNamespaceSpecifier') && - (parent.local === path.node || parent.imported === path.node) - ) { - return false; - } - - if ( - (parent.type === 'Property' || parent.type === 'ObjectProperty') && - parent.key === path.node && - !parent.computed - ) { - return false; - } - - if ( - parent.type === 'MemberExpression' && - parent.property === path.node && - !parent.computed - ) { - return false; - } - - if (excludeTypePositions) { - // Ignore references used only in TS type positions. - if ( - parent.type === 'TSTypeReference' || - parent.type === 'TSQualifiedName' || - parent.type === 'TSTypeQuery' || - parent.type === 'TSExpressionWithTypeArguments' || - parent.type === 'TSImportType' - ) { - return false; - } - } - - return true; - }) - .size() > 0 - ); -} - -function hasLiveReference(j, root, localName, importScope) { - return hasReference(j, root, localName, importScope, false); -} - -function hasRuntimeReference(j, root, localName, importScope) { - return hasReference(j, root, localName, importScope, true); -} - -function ensureRestEndpointImport(j, root) { - let dirty = false; - const restImports = root - .find(j.ImportDeclaration) - .filter(p => p.node.source.value === '@data-client/rest'); - - if (restImports.length) { - restImports.forEach(importPath => { - const specifiers = importPath.node.specifiers || []; - const hasRestEndpoint = specifiers.some( - s => - s.type === 'ImportSpecifier' && - s.imported && - (s.imported.name || s.imported.value) === 'RestEndpoint', - ); - if (hasRestEndpoint) return; - - specifiers.push(j.importSpecifier(j.identifier('RestEndpoint'))); - importPath.node.specifiers = specifiers; - dirty = true; - }); - return dirty; - } - - const newImport = j.importDeclaration( - [j.importSpecifier(j.identifier('RestEndpoint'))], - j.literal('@data-client/rest'), - ); - const firstImport = root.find(j.ImportDeclaration).at(0); - if (firstImport.length) { - firstImport.insertBefore(newImport); - } else { - root.get().node.program.body.unshift(newImport); - } - return true; -} - -function transformImports(j, root, axiosLocalName, needsRestEndpointImport) { - let dirty = false; - const axiosImportPaths = root - .find(j.ImportDeclaration) - .filter(p => p.node.source.value === 'axios') - .paths(); - const axiosImportPath = axiosImportPaths[0]; - const axiosStillUsed = hasLiveReference( - j, - root, - axiosLocalName, - axiosImportPath ? axiosImportPath.scope : null, - ); - - const hasDefaultImport = axiosImportPaths.some( - p => - p.node.importKind !== 'type' && - p.node.specifiers.some(s => s.type === 'ImportDefaultSpecifier'), - ); - - if (needsRestEndpointImport || (!axiosStillUsed && hasDefaultImport)) { - dirty = ensureRestEndpointImport(j, root) || dirty; - } - - root.find(j.ImportDeclaration).forEach(importPath => { - if (importPath.node.source.value !== 'axios') return; - - if (importPath.node.importKind === 'type') { - j(importPath).remove(); - dirty = true; - return; - } - - if (axiosStillUsed) return; - - const specs = importPath.node.specifiers; - const remaining = specs.filter(s => { - if (s.type === 'ImportDefaultSpecifier') return false; - if (s.type === 'ImportSpecifier' && s.importKind === 'type') return false; - if (s.type === 'ImportSpecifier' && s.imported) { - const importedName = s.imported.name || s.imported.value; - const localName = (s.local && s.local.name) || importedName; - - if (AXIOS_RUNTIME_IMPORTS.has(importedName)) { - return hasRuntimeReference(j, root, localName, importPath.scope); - } - if (AXIOS_TYPE_IMPORTS.has(importedName)) { - return false; - } - } - return true; - }); - - if (!remaining.length) { - j(importPath).remove(); - dirty = true; - return; - } - - importPath.node.specifiers = remaining; - dirty = true; - }); - - return dirty; -} - -// --- 2. Transform axios.create() → class --- - -function transformAxiosCreate(j, root, axiosLocalName) { - let dirty = false; - const existingClasses = getExistingClassNames(j, root); - const createToClass = new Map(); - - root.find(j.VariableDeclarator).forEach(declPath => { - const init = declPath.node.init; - if (!init || init.type !== 'CallExpression') return; - if ( - !init.callee || - init.callee.type !== 'MemberExpression' || - init.callee.object.type !== 'Identifier' || - init.callee.object.name !== axiosLocalName || - init.callee.property.type !== 'Identifier' || - init.callee.property.name !== 'create' - ) { - return; - } - - const varName = - declPath.node.id.type === 'Identifier' ? declPath.node.id.name : null; - const className = uniqueClassName( - deriveClassName(varName), - existingClasses, - ); - - if (varName) { - const declarationScope = declPath.scope.lookup(varName) || declPath.scope; - let scopedClassNames = createToClass.get(varName); - if (!scopedClassNames) { - scopedClassNames = new Map(); - createToClass.set(varName, scopedClassNames); - } - scopedClassNames.set(declarationScope, className); - } - - const config = - ( - init.arguments.length > 0 && - init.arguments[0].type === 'ObjectExpression' - ) ? - init.arguments[0] - : null; - - const classBody = []; - - if (config) { - for (const prop of config.properties) { - if (prop.type === 'SpreadElement' || prop.type === 'RestElement') - continue; - const key = - prop.key.type === 'Identifier' ? prop.key.name - : prop.key.type === 'StringLiteral' ? prop.key.value - : null; - if (!key) continue; - - if (key === 'baseURL') { - const urlProp = j.classProperty( - j.identifier('urlPrefix'), - prop.value, - ); - urlProp.static = false; - classBody.push(urlProp); - } else if (key === 'headers') { - const headersMethod = j.methodDefinition( - 'method', - j.identifier('getHeaders'), - j.functionExpression( - null, - [j.identifier('headers')], - j.blockStatement([ - j.returnStatement( - j.objectExpression([ - j.spreadElement(j.identifier('headers')), - ...extractHeaderProperties(j, prop.value), - ]), - ), - ]), - ), - false, - ); - classBody.push(headersMethod); - } - } - } - - const classDecl = j.classDeclaration( - j.identifier(className), - j.classBody(classBody), - j.identifier('RestEndpoint'), - ); - - const parentPath = declPath.parent; - if (parentPath.node.type === 'VariableDeclaration') { - const variableDeclPath = parentPath; - const variableDecl = variableDeclPath.node; - const statementPath = - variableDeclPath.parent.node.type === 'ExportNamedDeclaration' ? - variableDeclPath.parent - : variableDeclPath; - const classStatement = - statementPath.node.type === 'ExportNamedDeclaration' ? - j.exportNamedDeclaration(classDecl) - : classDecl; - - if (variableDecl.declarations.length === 1) { - j(statementPath).replaceWith(classStatement); - } else { - variableDecl.declarations = variableDecl.declarations.filter( - declaration => declaration !== declPath.node, - ); - j(statementPath).insertBefore(classStatement); - } - } else { - j(declPath).replaceWith(classDecl); - } - dirty = true; - }); - - return { dirty, createToClass }; -} - -// --- 3. Transform direct axios/instance method calls --- - -function transformDirectCalls( - j, - root, - axiosLocalName, - axiosImportScope, - createToClass, -) { - let dirty = false; - const callTargets = new Set([axiosLocalName, ...createToClass.keys()]); - - root.find(j.CallExpression).forEach(callPath => { - const callee = callPath.node.callee; - if ( - !callee || - callee.type !== 'MemberExpression' || - callee.object.type !== 'Identifier' || - !callTargets.has(callee.object.name) || - callee.property.type !== 'Identifier' || - !HTTP_METHODS.has(callee.property.name) - ) { - return; - } - - const methodName = callee.property.name; - const args = callPath.node.arguments; - if (args.length === 0) return; - - const targetName = callee.object.name; - const callBindingScope = callPath.scope.lookup(targetName); - - let className; - if (targetName === axiosLocalName) { - // Only transform calls that still refer to the imported axios binding. - if (callBindingScope !== axiosImportScope) return; - } else { - const scopedClassNames = createToClass.get(targetName); - if (!scopedClassNames) return; - className = scopedClassNames.get(callBindingScope); - // Avoid rewriting shadowed identifiers with the same name. - if (!className) return; - } - - const urlArg = args[0]; - if (urlArg.type !== 'StringLiteral' && urlArg.type !== 'TemplateLiteral') - return; - - const isCreatedInstance = Boolean(className); - - // Keep existing no-op behavior for direct axios calls with extra args. - // However, created instances must still be transformed so we don't leave - // dangling references after axios.create() is replaced by a class. - if (args.length > 1 && !isCreatedInstance) return; - - const method = methodName.toUpperCase(); - - const pathValue = - urlArg.type === 'StringLiteral' ? j.stringLiteral(urlArg.value) : urlArg; - - const properties = [j.objectProperty(j.identifier('path'), pathValue)]; - - if (method !== 'GET') { - properties.push( - j.objectProperty(j.identifier('method'), j.stringLiteral(method)), - ); - } - - let newExpr; - if (className) { - newExpr = j.newExpression(j.identifier(className), [ - j.objectExpression(properties), - ]); - } else { - newExpr = j.newExpression(j.identifier('RestEndpoint'), [ - j.objectExpression(properties), - ]); - } - - j(callPath).replaceWith(newExpr); - dirty = true; - }); - - return dirty; -} - -// --- Main --- - -module.exports = function transformer(fileInfo, api) { - const j = api.jscodeshift; - const root = j(fileInfo.source); - - const axiosImport = root - .find(j.ImportDeclaration) - .filter(p => p.node.source.value === 'axios'); - if (!axiosImport.length) return fileInfo.source; - - let axiosLocalName = 'axios'; - let axiosImportScope = null; - axiosImport.forEach(p => { - const defaultSpec = p.node.specifiers.find( - s => s.type === 'ImportDefaultSpecifier', - ); - if (defaultSpec) { - axiosLocalName = defaultSpec.local.name; - axiosImportScope = p.scope.lookup(axiosLocalName) || p.scope; - } - }); - - let dirty = false; - - const createResult = transformAxiosCreate(j, root, axiosLocalName); - dirty = createResult.dirty || dirty; - - const directCallsDirty = transformDirectCalls( - j, - root, - axiosLocalName, - axiosImportScope, - createResult.createToClass, - ); - dirty = directCallsDirty || dirty; - - dirty = - transformImports( - j, - root, - axiosLocalName, - createResult.dirty || directCallsDirty, - ) || dirty; - - return dirty ? root.toSource({ quote: 'single' }) : undefined; -}; - -module.exports.parser = 'tsx'; diff --git a/website/static/codemods/axios-to-rest.js b/website/static/codemods/axios-to-rest.js new file mode 120000 index 000000000000..5d912c55d8e0 --- /dev/null +++ b/website/static/codemods/axios-to-rest.js @@ -0,0 +1 @@ +../../../packages/codemods/src/transforms/axios-to-rest.js \ No newline at end of file diff --git a/website/static/codemods/v0.15.js b/website/static/codemods/v0.15.js deleted file mode 100644 index e5be908a6f15..000000000000 --- a/website/static/codemods/v0.15.js +++ /dev/null @@ -1,305 +0,0 @@ -'use strict'; - -/** - * @data-client v0.15 migration codemod - * - * Transforms: - * 1. useDebounce() plain value → [value] array destructuring - * 2. state.entityMeta → state.entitiesMeta - * 3. MemoCache.buildQueryKey / .query state arg consolidation - * 4. INVALID import → delegate.INVALID - * - * Usage: - * npx jscodeshift -t https://dataclient.io/codemods/v0.15.js --extensions=ts,tsx,js,jsx src/ - */ - -// --- useDebounce() → [value, isPending] --- - -function transformUseDebounce(j, root) { - let dirty = false; - - const localNames = new Set(); - root.find(j.ImportDeclaration).forEach(importPath => { - const source = importPath.node.source.value; - if (source !== '@data-client/react' && source !== '@data-client/vue') - return; - importPath.node.specifiers.forEach(s => { - if ( - s.type === 'ImportSpecifier' && - s.imported && - (s.imported.name === 'useDebounce' || - s.imported.value === 'useDebounce') - ) { - localNames.add(s.local.name); - } - }); - }); - if (!localNames.size) return false; - - root.find(j.VariableDeclarator).forEach(declPath => { - const init = declPath.node.init; - if ( - !init || - init.type !== 'CallExpression' || - !init.callee || - init.callee.type !== 'Identifier' || - !localNames.has(init.callee.name) || - declPath.node.id.type !== 'Identifier' - ) { - return; - } - const pattern = j.arrayPattern([j.identifier(declPath.node.id.name)]); - declPath.node.id = pattern; - dirty = true; - }); - - return dirty; -} - -// --- state.entityMeta → state.entitiesMeta --- - -function transformEntityMeta(j, root) { - let dirty = false; - - const memberTypes = [j.MemberExpression]; - try { - memberTypes.push(j.OptionalMemberExpression); - } catch (_) {} - - memberTypes.forEach(type => { - root - .find(type, { - computed: false, - property: { type: 'Identifier', name: 'entityMeta' }, - }) - .forEach(path => { - path.node.property.name = 'entitiesMeta'; - dirty = true; - }); - }); - - root.find(j.StringLiteral, { value: 'entityMeta' }).forEach(path => { - const parent = path.parent.node; - if ( - (parent.type === 'Property' || parent.type === 'ObjectProperty') && - parent.key === path.node - ) { - path.node.value = 'entitiesMeta'; - if (path.node.extra) { - delete path.node.extra.raw; - delete path.node.extra.rawValue; - } - dirty = true; - } - }); - - const propertyTypes = [j.Property]; - try { - propertyTypes.push(j.ObjectProperty); - } catch (_) {} - - propertyTypes.forEach(type => { - root - .find(type, { - computed: false, - key: { type: 'Identifier', name: 'entityMeta' }, - }) - .forEach(path => { - const prop = path.node; - prop.key = j.identifier('entitiesMeta'); - if (prop.shorthand) { - prop.shorthand = false; - } - dirty = true; - }); - }); - - return dirty; -} - -// --- MemoCache.buildQueryKey / .query state consolidation --- - -function transformMemoCacheState(j, root) { - let dirty = false; - - root.find(j.CallExpression).forEach(callPath => { - const callee = callPath.node.callee; - if ( - !callee || - callee.type !== 'MemberExpression' || - callee.property.type !== 'Identifier' - ) - return; - - const method = callee.property.name; - if (method !== 'buildQueryKey' && method !== 'query') return; - - const args = callPath.node.arguments; - // buildQueryKey(schema, args, X.entities, X.indexes, ...rest) - // query(schema, args, X.entities, X.indexes) - if (args.length < 4) return; - - const entitiesArg = args[2]; - const indexesArg = args[3]; - - if ( - entitiesArg.type !== 'MemberExpression' || - indexesArg.type !== 'MemberExpression' || - entitiesArg.property.type !== 'Identifier' || - indexesArg.property.type !== 'Identifier' || - entitiesArg.property.name !== 'entities' || - indexesArg.property.name !== 'indexes' - ) - return; - - const objA = entitiesArg.object; - const objB = indexesArg.object; - if (!nodesEqual(objA, objB)) return; - - const rest = args.slice(4); - callPath.node.arguments = [args[0], args[1], objA, ...rest]; - dirty = true; - }); - - return dirty; -} - -function nodesEqual(a, b) { - if (a.type !== b.type) return false; - if (a.type === 'Identifier') return a.name === b.name; - if (a.type === 'MemberExpression') { - return nodesEqual(a.object, b.object) && nodesEqual(a.property, b.property); - } - if (a.type === 'ThisExpression') return true; - return false; -} - -// --- INVALID import → delegate.INVALID --- - -function isValuePosition(idPath) { - const parent = idPath.parent.node; - - // Skip: import specifier - if ( - parent.type === 'ImportSpecifier' && - (parent.imported === idPath.node || parent.local === idPath.node) - ) - return false; - - // Skip: export specifier - if ( - parent.type === 'ExportSpecifier' && - (parent.local === idPath.node || parent.exported === idPath.node) - ) - return false; - - // Skip: non-computed member property (obj.INVALID — the .INVALID part) - if ( - (parent.type === 'MemberExpression' || - parent.type === 'OptionalMemberExpression') && - parent.property === idPath.node && - !parent.computed - ) - return false; - - // Skip: object property key (non-computed) - if ( - (parent.type === 'Property' || parent.type === 'ObjectProperty') && - parent.key === idPath.node && - !parent.computed - ) - return false; - - return true; -} - -function transformInvalid(j, root) { - let dirty = false; - - root.find(j.ImportDeclaration).forEach(importPath => { - const source = importPath.node.source.value; - if (source !== '@data-client/endpoint') return; - - const specs = importPath.node.specifiers; - const idx = specs.findIndex( - s => - s.type === 'ImportSpecifier' && - s.imported && - (s.imported.name === 'INVALID' || s.imported.value === 'INVALID'), - ); - if (idx === -1) return; - - const localName = specs[idx].local.name; - - // First pass: delegate.setEntity(schema, pk, INVALID) - // → delegate.invalidate(schema, pk) - root.find(j.CallExpression).forEach(callPath => { - const callee = callPath.node.callee; - if ( - !callee || - callee.type !== 'MemberExpression' || - callee.property.type !== 'Identifier' || - callee.property.name !== 'setEntity' - ) - return; - const args = callPath.node.arguments; - if (args.length < 3) return; - const last = args[args.length - 1]; - if (last.type !== 'Identifier' || last.name !== localName) return; - - callee.property.name = 'invalidate'; - callPath.node.arguments = args.slice(0, -1); - dirty = true; - }); - - // Second pass: remaining INVALID references → delegate.INVALID - root.find(j.Identifier, { name: localName }).forEach(idPath => { - if (!isValuePosition(idPath)) return; - - j(idPath).replaceWith( - j.memberExpression(j.identifier('delegate'), j.identifier('INVALID')), - ); - dirty = true; - }); - - // Keep the INVALID import when it is still needed by local exports: - // `export { INVALID }` must retain a local INVALID binding. - const hasLocalExport = root.find(j.ExportSpecifier).filter(specPath => { - const exportDecl = specPath.parent.node; - return ( - exportDecl.type === 'ExportNamedDeclaration' && - !exportDecl.source && - specPath.node.local && - specPath.node.local.type === 'Identifier' && - specPath.node.local.name === localName - ); - }).length; - - if (!hasLocalExport) { - specs.splice(idx, 1); - if (!specs.length) { - j(importPath).remove(); - } - dirty = true; - } - }); - - return dirty; -} - -// --- Main --- - -module.exports = function transformer(fileInfo, api) { - const j = api.jscodeshift; - const root = j(fileInfo.source); - let dirty = false; - - dirty = transformUseDebounce(j, root) || dirty; - dirty = transformEntityMeta(j, root) || dirty; - dirty = transformMemoCacheState(j, root) || dirty; - dirty = transformInvalid(j, root) || dirty; - - return dirty ? root.toSource({ quote: 'single' }) : undefined; -}; - -module.exports.parser = 'tsx'; diff --git a/website/static/codemods/v0.15.js b/website/static/codemods/v0.15.js new file mode 120000 index 000000000000..b896b8aa6de3 --- /dev/null +++ b/website/static/codemods/v0.15.js @@ -0,0 +1 @@ +../../../packages/codemods/src/transforms/v0.15.js \ No newline at end of file diff --git a/website/static/codemods/v0.16.js b/website/static/codemods/v0.16.js deleted file mode 100644 index 355f5c703db4..000000000000 --- a/website/static/codemods/v0.16.js +++ /dev/null @@ -1,462 +0,0 @@ -'use strict'; - -/** - * @data-client v0.16 migration codemod - * - * Transforms: - * 1. path-to-regexp v6 → v8 syntax in RestEndpoint/resource path strings - * 2. useFetch() truthiness checks → .resolved checks - * 3. schema.X namespace → direct imports - * - * Usage: - * npx jscodeshift -t https://dataclient.io/codemods/v0.16.js --extensions=ts,tsx,js,jsx src/ - */ - -const DATA_CLIENT_PACKAGES = new Set([ - '@data-client/endpoint', - '@data-client/rest', -]); - -// --- path-to-regexp v6 → v8 --- - -function transformPathString(s) { - // :name(\d+) → :name (handles nested non-capturing groups) - s = s.replace(/:(\w+)\([^()]*(?:\([^()]*\)[^()]*)*\)/g, ':$1'); - // {group}? → {group} - s = s.replace(/(\{[^}]+\})\?/g, '$1'); - // /:name? → {/:name} (also handles - . ~ prefixes) - s = s.replace(/([/\-.~]):(\w+)\?/g, '{$1:$2}'); - // /:name+ → /*name - s = s.replace(/\/:(\w+)\+/g, '/*$1'); - // /:name* → {/*name} - s = s.replace(/\/:(\w+)\*/g, '{/*$1}'); - // Literal `?` and `+` in the path still require `\\?` / `\\+` in v8 (see path-to-regexp errors). - // Do not strip those escapes — e.g. `.../public\\?per_page=50` must stay escaped. - return s; -} - -function transformPaths(j, root) { - let dirty = false; - - const hasDataClientImport = root - .find(j.ImportDeclaration) - .filter(importPath => - DATA_CLIENT_PACKAGES.has(importPath.node.source.value), - ).length; - if (!hasDataClientImport) return false; - - root.find(j.StringLiteral).forEach(path => { - const parent = path.parent.node; - if ( - (parent.type === 'Property' || parent.type === 'ObjectProperty') && - parent.value === path.node && - parent.key && - (parent.key.name === 'path' || - (parent.key.type === 'StringLiteral' && parent.key.value === 'path')) - ) { - const updated = transformPathString(path.node.value); - if (updated !== path.node.value) { - path.node.value = updated; - if (path.node.extra) { - delete path.node.extra.raw; - delete path.node.extra.rawValue; - } - dirty = true; - } - } - }); - - return dirty; -} - -// --- useFetch() truthiness → .resolved --- - -function enclosingFunction(path) { - let cur = path.parent; - while (cur) { - const t = cur.node.type; - if ( - t === 'FunctionDeclaration' || - t === 'FunctionExpression' || - t === 'ArrowFunctionExpression' - ) { - return cur; - } - cur = cur.parent; - } - return null; -} - -function transformUseFetch(j, root) { - let dirty = false; - - const hasDataClientUseFetch = root - .find(j.ImportDeclaration) - .filter(importPath => { - const source = importPath.node.source.value; - if (!source.startsWith('@data-client/')) return false; - return importPath.node.specifiers.some( - s => - s.type === 'ImportSpecifier' && - s.imported && - (s.imported.name === 'useFetch' || s.imported.value === 'useFetch'), - ); - }).length; - if (!hasDataClientUseFetch) return false; - - root.find(j.VariableDeclarator).forEach(declPath => { - const init = declPath.node.init; - if ( - !init || - init.type !== 'CallExpression' || - !init.callee || - init.callee.type !== 'Identifier' || - init.callee.name !== 'useFetch' || - declPath.node.id.type !== 'Identifier' - ) { - return; - } - const varName = declPath.node.id.name; - const fnScope = enclosingFunction(declPath); - if (!fnScope) return; - - const scopeRoot = j(fnScope); - - function rewrite(test) { - // promise → promise?.resolved === false - if (test.type === 'Identifier' && test.name === varName) { - return j.binaryExpression( - '===', - j.optionalMemberExpression( - j.identifier(test.name), - j.identifier('resolved'), - false, - true, - ), - j.booleanLiteral(false), - ); - } - // !promise → promise?.resolved !== false - if ( - test.type === 'UnaryExpression' && - test.operator === '!' && - test.argument.type === 'Identifier' && - test.argument.name === varName - ) { - return j.binaryExpression( - '!==', - j.optionalMemberExpression( - j.identifier(test.argument.name), - j.identifier('resolved'), - false, - true, - ), - j.booleanLiteral(false), - ); - } - return null; - } - - const stopNode = fnScope.node.body; - - [j.IfStatement, j.ConditionalExpression].forEach(type => { - scopeRoot.find(type).forEach(p => { - if (isShadowed(p, varName, stopNode)) return; - const r = rewrite(p.node.test); - if (r) { - p.node.test = r; - dirty = true; - } - }); - }); - - scopeRoot - .find(j.LogicalExpression) - .filter(p => p.node.operator === '&&' || p.node.operator === '||') - .forEach(p => { - if (isShadowed(p, varName, stopNode)) return; - const l = rewrite(p.node.left); - if (l) { - p.node.left = l; - dirty = true; - } - }); - }); - - return dirty; -} - -// --- schema.X namespace → direct imports --- - -// These schema members are type-only exports and cannot be imported as values. -// They must stay as schema.Object / schema.Array namespace access. -const TYPE_ONLY_SCHEMAS = new Set(['Object', 'Array']); - -const JS_GLOBALS = new Set([ - 'Array', - 'Boolean', - 'Date', - 'Error', - 'Function', - 'JSON', - 'Map', - 'Math', - 'Number', - 'Object', - 'Promise', - 'Proxy', - 'Reflect', - 'RegExp', - 'Set', - 'String', - 'Symbol', - 'WeakMap', - 'WeakSet', -]); - -function patternBindsName(pattern, name) { - if (!pattern) return false; - if (pattern.type === 'Identifier') return pattern.name === name; - if (pattern.type === 'AssignmentPattern') - return patternBindsName(pattern.left, name); - if (pattern.type === 'RestElement') - return patternBindsName(pattern.argument, name); - if (pattern.type === 'ObjectPattern') { - return pattern.properties.some(p => - p.type === 'RestElement' ? - patternBindsName(p.argument, name) - : patternBindsName(p.value, name), - ); - } - if (pattern.type === 'ArrayPattern') { - return pattern.elements.some(e => e && patternBindsName(e, name)); - } - return false; -} - -function collectPatternNames(pattern, out) { - if (!pattern) return; - if (pattern.type === 'Identifier') { - out.add(pattern.name); - return; - } - if (pattern.type === 'AssignmentPattern') - return collectPatternNames(pattern.left, out); - if (pattern.type === 'RestElement') - return collectPatternNames(pattern.argument, out); - if (pattern.type === 'ObjectPattern') { - pattern.properties.forEach(p => - collectPatternNames(p.type === 'RestElement' ? p.argument : p.value, out), - ); - return; - } - if (pattern.type === 'ArrayPattern') { - pattern.elements.forEach(e => e && collectPatternNames(e, out)); - return; - } -} - -function isShadowed(path, name, stopNode) { - let cur = path; - while (cur.parent) { - cur = cur.parent; - if (stopNode && cur.node === stopNode) return false; - const node = cur.node; - - if ( - node.type === 'FunctionDeclaration' || - node.type === 'FunctionExpression' || - node.type === 'ArrowFunctionExpression' - ) { - if (node.params && node.params.some(p => patternBindsName(p, name))) { - return true; - } - } - - if (node.type === 'CatchClause') { - if (node.param && patternBindsName(node.param, name)) { - return true; - } - } - - if (node.type === 'BlockStatement' || node.type === 'Program') { - const stmts = node.body || []; - for (const stmt of stmts) { - if (stmt.type === 'VariableDeclaration') { - for (const decl of stmt.declarations) { - if (patternBindsName(decl.id, name)) return true; - } - } - if ( - (stmt.type === 'FunctionDeclaration' || - stmt.type === 'ClassDeclaration') && - stmt.id && - stmt.id.name === name - ) { - return true; - } - } - } - - if ( - node.type === 'ForStatement' || - node.type === 'ForInStatement' || - node.type === 'ForOfStatement' - ) { - const init = node.init || node.left; - if (init && init.type === 'VariableDeclaration') { - for (const decl of init.declarations) { - if (patternBindsName(decl.id, name)) return true; - } - } - } - } - return false; -} - -function transformSchemaImports(j, root) { - let dirty = false; - - root.find(j.ImportDeclaration).forEach(importPath => { - const source = importPath.node.source.value; - if (!DATA_CLIENT_PACKAGES.has(source)) return; - - const specs = importPath.node.specifiers; - const idx = specs.findIndex( - s => - s.type === 'ImportSpecifier' && - s.imported && - (s.imported.name === 'schema' || s.imported.value === 'schema'), - ); - if (idx === -1) return; - - const local = specs[idx].local.name; - // Map from exported name → local identifier to use in code - const used = new Map(); - - const scopeBindings = new Set(); - root.find(j.ImportDeclaration).forEach(ip => { - if (ip === importPath) return; - ip.node.specifiers.forEach(s => { - if (s.local) scopeBindings.add(s.local.name); - }); - }); - const isTopLevel = parentType => - parentType === 'Program' || - parentType === 'ExportNamedDeclaration' || - parentType === 'ExportDefaultDeclaration'; - root.find(j.VariableDeclaration).forEach(vp => { - if (isTopLevel(vp.parent.node.type)) { - vp.node.declarations.forEach(d => - collectPatternNames(d.id, scopeBindings), - ); - } - }); - root.find(j.FunctionDeclaration).forEach(fp => { - if (isTopLevel(fp.parent.node.type) && fp.node.id) { - scopeBindings.add(fp.node.id.name); - } - }); - root.find(j.ClassDeclaration).forEach(cp => { - if (isTopLevel(cp.parent.node.type) && cp.node.id) { - scopeBindings.add(cp.node.id.name); - } - }); - - function resolveLocal(name) { - if (!JS_GLOBALS.has(name) && !scopeBindings.has(name)) { - return name; - } - let candidate = 'Schema' + name; - while (JS_GLOBALS.has(candidate) || scopeBindings.has(candidate)) { - candidate = '_' + candidate; - } - return candidate; - } - - root - .find(j.MemberExpression, { - object: { type: 'Identifier', name: local }, - }) - .forEach(mp => { - if (isShadowed(mp, local)) return; - if (mp.node.property.type === 'Identifier') { - const name = mp.node.property.name; - if (TYPE_ONLY_SCHEMAS.has(name)) return; - if (!used.has(name)) used.set(name, resolveLocal(name)); - j(mp).replaceWith(j.identifier(used.get(name))); - } - }); - - // TypeScript qualified names: schema.Union in type positions - try { - root - .find(j.TSQualifiedName, { - left: { type: 'Identifier', name: local }, - }) - .forEach(qp => { - if (isShadowed(qp, local)) return; - if (qp.node.right.type === 'Identifier') { - const name = qp.node.right.name; - if (TYPE_ONLY_SCHEMAS.has(name)) return; - if (!used.has(name)) used.set(name, resolveLocal(name)); - j(qp).replaceWith(j.identifier(used.get(name))); - } - }); - } catch (_) {} - - if (!used.size) return; - - // Only remove the schema import specifier when there are no remaining - // bare references (e.g. destructuring, function args, typeof). - const bareRefs = root.find(j.Identifier, { name: local }).filter(p => { - const parent = p.parent.node; - if ( - parent.type === 'ImportSpecifier' && - (parent.imported === p.node || parent.local === p.node) - ) - return false; - return true; - }); - if (!bareRefs.length) { - specs.splice(idx, 1); - } - - const existingLocals = new Set( - specs.filter(s => s.type === 'ImportSpecifier').map(s => s.local.name), - ); - for (const [imported, localName] of [...used.entries()].sort((a, b) => - a[0].localeCompare(b[0]), - )) { - if (!existingLocals.has(localName)) { - if (imported !== localName) { - specs.push( - j.importSpecifier(j.identifier(imported), j.identifier(localName)), - ); - } else { - specs.push(j.importSpecifier(j.identifier(imported))); - } - } - } - - dirty = true; - }); - - return dirty; -} - -// --- Main --- - -module.exports = function transformer(fileInfo, api) { - const j = api.jscodeshift; - const root = j(fileInfo.source); - let dirty = false; - - dirty = transformPaths(j, root) || dirty; - dirty = transformUseFetch(j, root) || dirty; - dirty = transformSchemaImports(j, root) || dirty; - - return dirty ? root.toSource({ quote: 'single' }) : undefined; -}; - -module.exports.parser = 'tsx'; diff --git a/website/static/codemods/v0.16.js b/website/static/codemods/v0.16.js new file mode 120000 index 000000000000..204b334855c7 --- /dev/null +++ b/website/static/codemods/v0.16.js @@ -0,0 +1 @@ +../../../packages/codemods/src/transforms/v0.16.js \ No newline at end of file diff --git a/website/static/codemods/v0.18.js b/website/static/codemods/v0.18.js deleted file mode 100644 index 169b680e4787..000000000000 --- a/website/static/codemods/v0.18.js +++ /dev/null @@ -1,807 +0,0 @@ -'use strict'; - -/** - * @data-client v0.18 migration codemod - * - * Transforms: - * 1. Schema.denormalize(input, args, unvisit) → denormalize(input, delegate) - * - Class methods, object methods, function declarations - * - TypeScript method signatures and property types - * - Renames usages: unvisit(...) → delegate.unvisit(...), args → delegate.args - * - Pass-through calls foo.denormalize(input, args, unvisit) - * → foo.denormalize(input, delegate) when delegate is in scope - * 2. Adds `IDenormalizeDelegate` import from `@data-client/endpoint` (or - * `@data-client/rest` / `@data-client/normalizr` if those are already used) - * when type annotations are required. - * 3. Schema.normalize(input, parent, key, args, visit, delegate, parentEntity?) - * → normalize(input, parent, key, delegate, parentEntity?) - * - Renames usages: visit(...) → delegate.visit(...), args → delegate.args - * - Pass-through calls foo.normalize(input, parent, key, args, visit, delegate) - * → foo.normalize(input, parent, key, delegate) when delegate is in scope - * - Adds `INormalizeDelegate` import when type annotations are required. - * - * Usage: - * npx jscodeshift -t https://dataclient.io/codemods/v0.18.js --extensions=ts,tsx,js,jsx src/ - */ - -const DATA_CLIENT_PREFIX = '@data-client/'; -const DENORMALIZE_DELEGATE_TYPE_NAME = 'IDenormalizeDelegate'; -const NORMALIZE_DELEGATE_TYPE_NAME = 'INormalizeDelegate'; - -function hasDataClientImport(j, root) { - return ( - root.find(j.ImportDeclaration).filter(p => { - const v = p.node.source.value; - return typeof v === 'string' && v.startsWith(DATA_CLIENT_PREFIX); - }).length > 0 - ); -} - -// --- helpers -------------------------------------------------------------- - -function getParamName(param) { - if (!param) return undefined; - if (param.type === 'Identifier') return param.name; - if (param.type === 'AssignmentPattern' && param.left.type === 'Identifier') - return param.left.name; - if (param.type === 'TSParameterProperty' && param.parameter) - return getParamName(param.parameter); - return undefined; -} - -function isDenormalizeKey(key) { - if (!key) return false; - if (key.type === 'Identifier') return key.name === 'denormalize'; - if (key.type === 'StringLiteral' || key.type === 'Literal') - return key.value === 'denormalize'; - return false; -} - -function isNormalizeKey(key) { - if (!key) return false; - if (key.type === 'Identifier') return key.name === 'normalize'; - if (key.type === 'StringLiteral' || key.type === 'Literal') - return key.value === 'normalize'; - return false; -} - -// Broader for type-only declarations (class fields, property signatures): -// matches `denormalize`, `_denormalize`, `_denormalizeNullable`, etc. -const DENORMALIZE_LIKE = /^_?denormalize(Nullable)?$/; -function isDenormalizeLikeKey(key) { - if (!key) return false; - if (key.type === 'Identifier') return DENORMALIZE_LIKE.test(key.name); - if (key.type === 'StringLiteral' || key.type === 'Literal') - return DENORMALIZE_LIKE.test(String(key.value)); - return false; -} - -function buildNamedDelegateParam(j, withTypes, typeName) { - const id = j.identifier('delegate'); - if (withTypes) { - id.typeAnnotation = j.tsTypeAnnotation( - j.tsTypeReference(j.identifier(typeName)), - ); - } - return id; -} - -function buildDelegateParamWithName(j, name, withTypes, typeName) { - const id = j.identifier(name || 'delegate'); - if (withTypes) { - id.typeAnnotation = j.tsTypeAnnotation( - j.tsTypeReference(j.identifier(typeName)), - ); - } - return id; -} - -function paramsLookLikeDenormalize(params) { - if (!params || params.length !== 3) return false; - const argsName = getParamName(params[1]); - const unvisitName = getParamName(params[2]); - if (argsName === 'args' && unvisitName === 'unvisit') return true; - if (argsName === '_args' && unvisitName === '_unvisit') return true; - // Typed signature where 3rd param looks like a function type. - const ta3 = params[2] && params[2].typeAnnotation; - const tn = ta3 && (ta3.typeAnnotation || ta3); - if ( - tn && - (tn.type === 'TSFunctionType' || tn.type === 'FunctionTypeAnnotation') - ) - return true; - return false; -} - -function paramsLookLikeNormalize(params) { - if (!params || (params.length !== 6 && params.length !== 7)) return false; - const argsName = getParamName(params[3]); - const visitName = getParamName(params[4]); - if (argsName === 'args' && visitName === 'visit') return true; - if (argsName === '_args' && visitName === '_visit') return true; - // Typed signature where 5th param looks like a function type. - const ta5 = params[4] && params[4].typeAnnotation; - const tn = ta5 && (ta5.typeAnnotation || ta5); - if ( - tn && - (tn.type === 'TSFunctionType' || tn.type === 'FunctionTypeAnnotation') - ) - return true; - return false; -} - -// Rewrite identifier references inside a method body NodePath: -// → delegate.args -// (...) → delegate.unvisit(...) -// foo.denormalize(x, args, unvisit) → foo.denormalize(x, delegate) -function rewriteBody(j, methodPath, argsName, unvisitName) { - const bodyCol = j(methodPath).find(j.BlockStatement).at(0); - if (!bodyCol.length) return; - const bodyPath = bodyCol.paths()[0]; - const body = j(bodyPath); - - function isShadowed(p, name) { - let cur = p; - while (cur && cur.parent) { - cur = cur.parent; - if (cur.node === bodyPath.node) return false; - const t = cur.node.type; - if ( - t === 'FunctionDeclaration' || - t === 'FunctionExpression' || - t === 'ArrowFunctionExpression' || - t === 'ObjectMethod' || - t === 'ClassMethod' || - t === 'MethodDefinition' - ) { - const params = - (cur.node.params && cur.node.params) || - (cur.node.value && cur.node.value.params) || - []; - if (params.some(prm => getParamName(prm) === name)) return true; - } - } - return false; - } - - // 1. Rewrite pass-through calls: x.denormalize(input, args, unvisit) → x.denormalize(input, delegate) - body - .find(j.CallExpression) - .filter(p => { - const callee = p.node.callee; - if (!callee || callee.type !== 'MemberExpression') return false; - if (!isDenormalizeKey(callee.property)) return false; - const args = p.node.arguments; - if (args.length !== 3) return false; - return ( - args[1].type === 'Identifier' && - args[1].name === argsName && - args[2].type === 'Identifier' && - args[2].name === unvisitName - ); - }) - .forEach(p => { - if (isShadowed(p, argsName) || isShadowed(p, unvisitName)) return; - p.node.arguments = [p.node.arguments[0], j.identifier('delegate')]; - }); - - // 2. unvisit(...) → delegate.unvisit(...) - if (unvisitName) { - body - .find(j.CallExpression, { - callee: { type: 'Identifier', name: unvisitName }, - }) - .forEach(p => { - if (isShadowed(p, unvisitName)) return; - p.node.callee = j.memberExpression( - j.identifier('delegate'), - j.identifier('unvisit'), - ); - }); - - // bare reference: passing `unvisit` to another fn → `delegate.unvisit` - body - .find(j.Identifier, { name: unvisitName }) - .filter(p => { - const parent = p.parent.node; - if (!parent) return false; - if ( - parent.type === 'MemberExpression' && - parent.property === p.node && - !parent.computed - ) - return false; - if ( - (parent.type === 'Property' || parent.type === 'ObjectProperty') && - parent.key === p.node && - !parent.computed - ) - return false; - if (parent.type === 'CallExpression' && parent.callee === p.node) - return false; - if (parent.type === 'VariableDeclarator' && parent.id === p.node) - return false; - return true; - }) - .forEach(p => { - if (isShadowed(p, unvisitName)) return; - j(p).replaceWith( - j.memberExpression(j.identifier('delegate'), j.identifier('unvisit')), - ); - }); - } - - // 3. bare `args` → `delegate.args` - if (argsName) { - body - .find(j.Identifier, { name: argsName }) - .filter(p => { - const parent = p.parent.node; - if (!parent) return false; - if ( - parent.type === 'MemberExpression' && - parent.property === p.node && - !parent.computed - ) - return false; - if ( - (parent.type === 'Property' || parent.type === 'ObjectProperty') && - parent.key === p.node && - !parent.computed - ) - return false; - if (parent.type === 'VariableDeclarator' && parent.id === p.node) - return false; - if ( - parent.type === 'TSTypeReference' || - parent.type === 'TSQualifiedName' - ) - return false; - return true; - }) - .forEach(p => { - if (isShadowed(p, argsName)) return; - j(p).replaceWith( - j.memberExpression(j.identifier('delegate'), j.identifier('args')), - ); - }); - } -} - -function rewriteNormalizeBody( - j, - methodPath, - argsName, - visitName, - delegateName, -) { - const bodyCol = j(methodPath).find(j.BlockStatement).at(0); - if (!bodyCol.length) return; - const bodyPath = bodyCol.paths()[0]; - const body = j(bodyPath); - - function isShadowed(p, name) { - let cur = p; - while (cur && cur.parent) { - cur = cur.parent; - if (cur.node === bodyPath.node) return false; - const t = cur.node.type; - if ( - t === 'FunctionDeclaration' || - t === 'FunctionExpression' || - t === 'ArrowFunctionExpression' || - t === 'ObjectMethod' || - t === 'ClassMethod' || - t === 'MethodDefinition' - ) { - const params = - (cur.node.params && cur.node.params) || - (cur.node.value && cur.node.value.params) || - []; - if (params.some(prm => getParamName(prm) === name)) return true; - } - } - return false; - } - - // 1. Rewrite pass-through calls: - // x.normalize(input, parent, key, args, visit, delegate[, parentEntity]) - // → x.normalize(input, parent, key, delegate[, parentEntity]) - body - .find(j.CallExpression) - .filter(p => { - const callee = p.node.callee; - if (!callee || callee.type !== 'MemberExpression') return false; - if (!isNormalizeKey(callee.property)) return false; - const callArgs = p.node.arguments; - if (callArgs.length !== 6 && callArgs.length !== 7) return false; - return ( - callArgs[3].type === 'Identifier' && - callArgs[3].name === argsName && - callArgs[4].type === 'Identifier' && - callArgs[4].name === visitName && - callArgs[5].type === 'Identifier' && - callArgs[5].name === delegateName - ); - }) - .forEach(p => { - if ( - isShadowed(p, argsName) || - isShadowed(p, visitName) || - isShadowed(p, delegateName) - ) - return; - p.node.arguments = [ - p.node.arguments[0], - p.node.arguments[1], - p.node.arguments[2], - j.identifier(delegateName), - ...p.node.arguments.slice(6), - ]; - }); - - // 2. visit(schema, value, parent, key, args) → delegate.visit(schema, value, parent, key) - if (visitName) { - body - .find(j.CallExpression, { - callee: { type: 'Identifier', name: visitName }, - }) - .forEach(p => { - if (isShadowed(p, visitName)) return; - const callArgs = p.node.arguments; - if ( - callArgs.length === 5 && - callArgs[4].type === 'Identifier' && - callArgs[4].name === argsName - ) { - p.node.arguments = callArgs.slice(0, 4); - } - p.node.callee = j.memberExpression( - j.identifier(delegateName), - j.identifier('visit'), - ); - }); - - // bare reference: passing `visit` to another fn → `delegate.visit` - body - .find(j.Identifier, { name: visitName }) - .filter(p => { - const parent = p.parent.node; - if (!parent) return false; - if ( - parent.type === 'MemberExpression' && - parent.property === p.node && - !parent.computed - ) - return false; - if ( - (parent.type === 'Property' || parent.type === 'ObjectProperty') && - parent.key === p.node && - !parent.computed - ) - return false; - if (parent.type === 'CallExpression' && parent.callee === p.node) - return false; - if (parent.type === 'VariableDeclarator' && parent.id === p.node) - return false; - return true; - }) - .forEach(p => { - if (isShadowed(p, visitName)) return; - j(p).replaceWith( - j.memberExpression(j.identifier(delegateName), j.identifier('visit')), - ); - }); - } - - // 3. bare `args` → `delegate.args` - if (argsName) { - body - .find(j.Identifier, { name: argsName }) - .filter(p => { - const parent = p.parent.node; - if (!parent) return false; - if ( - parent.type === 'MemberExpression' && - parent.property === p.node && - !parent.computed - ) - return false; - if ( - (parent.type === 'Property' || parent.type === 'ObjectProperty') && - parent.key === p.node && - !parent.computed - ) - return false; - if (parent.type === 'VariableDeclarator' && parent.id === p.node) - return false; - if ( - parent.type === 'TSTypeReference' || - parent.type === 'TSQualifiedName' - ) - return false; - return true; - }) - .forEach(p => { - if (isShadowed(p, argsName)) return; - j(p).replaceWith( - j.memberExpression(j.identifier(delegateName), j.identifier('args')), - ); - }); - } -} - -// --- runtime denormalize methods (class/object/function) ------------------- - -function transformDenormalizeMethods(j, root, state) { - let dirty = false; - - function processFunctionLike(methodPath, paramsHolder) { - const params = paramsHolder.params; - if (!paramsLookLikeDenormalize(params)) return false; - const argsName = getParamName(params[1]); - const unvisitName = getParamName(params[2]); - - paramsHolder.params = [ - params[0], - buildNamedDelegateParam( - j, - state.useTypes, - DENORMALIZE_DELEGATE_TYPE_NAME, - ), - ]; - rewriteBody(j, methodPath, argsName, unvisitName); - - if (state.useTypes) state.neededImports.add(DENORMALIZE_DELEGATE_TYPE_NAME); - return true; - } - - // ClassMethod (Babel/tsx parser) - if (j.ClassMethod) { - root.find(j.ClassMethod).forEach(p => { - if (!isDenormalizeKey(p.node.key)) return; - if (processFunctionLike(p, p.node)) dirty = true; - }); - } - - // MethodDefinition (ESTree) - if (j.MethodDefinition) { - root.find(j.MethodDefinition).forEach(p => { - if (!isDenormalizeKey(p.node.key)) return; - const fn = p.node.value; - if (!fn) return; - // For MethodDefinition, search inside the FunctionExpression value. - const fnPath = p.get('value'); - if (processFunctionLike(fnPath, fn)) dirty = true; - }); - } - - // ObjectMethod (Babel) - if (j.ObjectMethod) { - root.find(j.ObjectMethod).forEach(p => { - if (!isDenormalizeKey(p.node.key)) return; - if (processFunctionLike(p, p.node)) dirty = true; - }); - } - - // Property: { denormalize: function(...) {} } and { denormalize: (...) => {} } - root - .find(j.Property) - .filter(p => { - if (!isDenormalizeKey(p.node.key)) return false; - const v = p.node.value; - return ( - v && - (v.type === 'FunctionExpression' || - v.type === 'ArrowFunctionExpression') - ); - }) - .forEach(p => { - const fn = p.node.value; - const fnPath = p.get('value'); - if (processFunctionLike(fnPath, fn)) dirty = true; - }); - - // Top-level: function denormalize(input, args, unvisit) { ... } - root.find(j.FunctionDeclaration).forEach(p => { - if (!p.node.id || p.node.id.name !== 'denormalize') return; - if (processFunctionLike(p, p.node)) dirty = true; - }); - - return dirty; -} - -// --- TS interface / type signatures --------------------------------------- - -function transformTypeSignatures(j, root, state) { - if (!j.TSMethodSignature && !j.TSPropertySignature) return false; - let dirty = false; - - if (j.TSMethodSignature) { - root.find(j.TSMethodSignature).forEach(p => { - if (!isDenormalizeKey(p.node.key)) return; - const params = p.node.parameters || p.node.params; - if (!paramsLookLikeDenormalize(params)) return; - const newParams = [ - params[0], - buildNamedDelegateParam(j, true, DENORMALIZE_DELEGATE_TYPE_NAME), - ]; - if (p.node.parameters) p.node.parameters = newParams; - else p.node.params = newParams; - state.neededImports.add(DENORMALIZE_DELEGATE_TYPE_NAME); - dirty = true; - }); - } - - function rewriteFnTypeNode(fn) { - if (!fn) return false; - if (fn.type !== 'TSFunctionType') return false; - const params = fn.parameters; - if (!paramsLookLikeDenormalize(params)) return false; - fn.parameters = [ - params[0], - buildNamedDelegateParam(j, true, DENORMALIZE_DELEGATE_TYPE_NAME), - ]; - state.neededImports.add(DENORMALIZE_DELEGATE_TYPE_NAME); - return true; - } - - if (j.TSPropertySignature) { - root.find(j.TSPropertySignature).forEach(p => { - if (!isDenormalizeLikeKey(p.node.key)) return; - const ann = p.node.typeAnnotation; - if (!ann || !ann.typeAnnotation) return; - if (rewriteFnTypeNode(ann.typeAnnotation)) dirty = true; - }); - } - - ['ClassProperty', 'PropertyDefinition'].forEach(t => { - if (!j[t]) return; - root.find(j[t]).forEach(p => { - if (!isDenormalizeLikeKey(p.node.key)) return; - const ann = p.node.typeAnnotation; - if (!ann || !ann.typeAnnotation) return; - if (rewriteFnTypeNode(ann.typeAnnotation)) dirty = true; - }); - }); - - return dirty; -} - -// --- runtime normalize methods (class/object/function) --------------------- - -function transformNormalizeMethods(j, root, state) { - let dirty = false; - - function processFunctionLike(methodPath, paramsHolder) { - const params = paramsHolder.params; - if (!paramsLookLikeNormalize(params)) return false; - const argsName = getParamName(params[3]); - const visitName = getParamName(params[4]); - const delegateName = getParamName(params[5]) || 'delegate'; - - paramsHolder.params = [ - params[0], - params[1], - params[2], - buildDelegateParamWithName( - j, - delegateName, - state.useTypes, - NORMALIZE_DELEGATE_TYPE_NAME, - ), - ...params.slice(6), - ]; - rewriteNormalizeBody(j, methodPath, argsName, visitName, delegateName); - - if (state.useTypes) state.neededImports.add(NORMALIZE_DELEGATE_TYPE_NAME); - return true; - } - - if (j.ClassMethod) { - root.find(j.ClassMethod).forEach(p => { - if (!isNormalizeKey(p.node.key)) return; - if (processFunctionLike(p, p.node)) dirty = true; - }); - } - - if (j.MethodDefinition) { - root.find(j.MethodDefinition).forEach(p => { - if (!isNormalizeKey(p.node.key)) return; - const fn = p.node.value; - if (!fn) return; - const fnPath = p.get('value'); - if (processFunctionLike(fnPath, fn)) dirty = true; - }); - } - - if (j.ObjectMethod) { - root.find(j.ObjectMethod).forEach(p => { - if (!isNormalizeKey(p.node.key)) return; - if (processFunctionLike(p, p.node)) dirty = true; - }); - } - - root - .find(j.Property) - .filter(p => { - if (!isNormalizeKey(p.node.key)) return false; - const v = p.node.value; - return ( - v && - (v.type === 'FunctionExpression' || - v.type === 'ArrowFunctionExpression') - ); - }) - .forEach(p => { - const fn = p.node.value; - const fnPath = p.get('value'); - if (processFunctionLike(fnPath, fn)) dirty = true; - }); - - root.find(j.FunctionDeclaration).forEach(p => { - if (!p.node.id || p.node.id.name !== 'normalize') return; - if (processFunctionLike(p, p.node)) dirty = true; - }); - - return dirty; -} - -function transformNormalizeTypeSignatures(j, root, state) { - if (!j.TSMethodSignature && !j.TSPropertySignature) return false; - let dirty = false; - - if (j.TSMethodSignature) { - root.find(j.TSMethodSignature).forEach(p => { - if (!isNormalizeKey(p.node.key)) return; - const params = p.node.parameters || p.node.params; - if (!paramsLookLikeNormalize(params)) return; - const delegateName = getParamName(params[5]) || 'delegate'; - const newParams = [ - params[0], - params[1], - params[2], - buildDelegateParamWithName( - j, - delegateName, - true, - NORMALIZE_DELEGATE_TYPE_NAME, - ), - ...params.slice(6), - ]; - if (p.node.parameters) p.node.parameters = newParams; - else p.node.params = newParams; - state.neededImports.add(NORMALIZE_DELEGATE_TYPE_NAME); - dirty = true; - }); - } - - function rewriteFnTypeNode(fn) { - if (!fn) return false; - if (fn.type !== 'TSFunctionType') return false; - const params = fn.parameters; - if (!paramsLookLikeNormalize(params)) return false; - const delegateName = getParamName(params[5]) || 'delegate'; - fn.parameters = [ - params[0], - params[1], - params[2], - buildDelegateParamWithName( - j, - delegateName, - true, - NORMALIZE_DELEGATE_TYPE_NAME, - ), - ...params.slice(6), - ]; - state.neededImports.add(NORMALIZE_DELEGATE_TYPE_NAME); - return true; - } - - if (j.TSPropertySignature) { - root.find(j.TSPropertySignature).forEach(p => { - if (!isNormalizeKey(p.node.key)) return; - const ann = p.node.typeAnnotation; - if (!ann || !ann.typeAnnotation) return; - if (rewriteFnTypeNode(ann.typeAnnotation)) dirty = true; - }); - } - - ['ClassProperty', 'PropertyDefinition'].forEach(t => { - if (!j[t]) return; - root.find(j[t]).forEach(p => { - if (!isNormalizeKey(p.node.key)) return; - const ann = p.node.typeAnnotation; - if (!ann || !ann.typeAnnotation) return; - if (rewriteFnTypeNode(ann.typeAnnotation)) dirty = true; - }); - }); - - return dirty; -} - -// --- Import injection ----------------------------------------------------- - -function ensureDelegateImport(j, root, typeName) { - let alreadyImported = false; - root.find(j.ImportDeclaration).forEach(p => { - if (alreadyImported) return; - p.node.specifiers.forEach(s => { - if ( - s.type === 'ImportSpecifier' && - s.imported && - s.imported.name === typeName - ) { - alreadyImported = true; - } - }); - }); - if (alreadyImported) return false; - - const preferred = [ - '@data-client/endpoint', - '@data-client/rest', - '@data-client/normalizr', - ]; - let target = null; - for (const name of preferred) { - const found = root - .find(j.ImportDeclaration) - .filter(p => p.node.source.value === name); - if (found.length) { - target = found.paths()[0]; - break; - } - } - if (!target) { - const newImport = j.importDeclaration( - [j.importSpecifier(j.identifier(typeName))], - j.stringLiteral('@data-client/endpoint'), - ); - newImport.importKind = 'type'; - const allImports = root.find(j.ImportDeclaration); - if (allImports.length) { - const paths = allImports.paths(); - j(paths[paths.length - 1]).insertAfter(newImport); - } else { - root.get().node.program.body.unshift(newImport); - } - return true; - } - - // Inline `type` keyword so this works under verbatimModuleSyntax / - // consistent-type-imports without importing a value at runtime. - const spec = j.importSpecifier(j.identifier(typeName)); - spec.importKind = 'type'; - target.node.specifiers.push(spec); - return true; -} - -// --- Main ----------------------------------------------------------------- - -module.exports = function transformer(fileInfo, api) { - const j = api.jscodeshift; - const root = j(fileInfo.source); - - if (!hasDataClientImport(j, root)) return fileInfo.source; - - const isTs = - /\.tsx?$/.test(fileInfo.path || '') || - root.find(j.TSTypeAnnotation).length > 0 || - root.find(j.TSInterfaceDeclaration).length > 0; - const state = { useTypes: isTs, neededImports: new Set() }; - - let dirty = false; - dirty = transformDenormalizeMethods(j, root, state) || dirty; - dirty = transformTypeSignatures(j, root, state) || dirty; - dirty = transformNormalizeMethods(j, root, state) || dirty; - dirty = transformNormalizeTypeSignatures(j, root, state) || dirty; - - for (const typeName of state.neededImports) { - ensureDelegateImport(j, root, typeName); - } - - return dirty ? root.toSource({ quote: 'single' }) : fileInfo.source; -}; - -module.exports.parser = 'tsx'; diff --git a/website/static/codemods/v0.18.js b/website/static/codemods/v0.18.js new file mode 120000 index 000000000000..02889bbce42f --- /dev/null +++ b/website/static/codemods/v0.18.js @@ -0,0 +1 @@ +../../../packages/codemods/src/transforms/v0.18.js \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 40ec97e9c9c6..81f36b560560 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5100,6 +5100,16 @@ __metadata: languageName: node linkType: hard +"@data-client/codemods@workspace:packages/codemods": + version: 0.0.0-use.local + resolution: "@data-client/codemods@workspace:packages/codemods" + dependencies: + jscodeshift: "npm:^17.3.0" + bin: + data-client-codemods: ./src/cli.js + languageName: unknown + linkType: soft + "@data-client/core@workspace:*, @data-client/core@workspace:^, @data-client/core@workspace:packages/core": version: 0.0.0-use.local resolution: "@data-client/core@workspace:packages/core" @@ -29102,7 +29112,6 @@ __metadata: globals: "npm:^16.0.0" jest: "npm:^30.0.0" jest-environment-jsdom: "npm:^30.0.0" - jscodeshift: "npm:^17.3.0" mkdirp: "npm:^3.0.0" nock: "npm:13.3.1" npm-run-all: "npm:^4.1.5"