From cdcdffe549d9b13b2b6fdab9a236893a6db32203 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sun, 3 Aug 2025 14:50:42 +0200 Subject: [PATCH 01/52] fea(`node-url-to-whatwg-url`): scaffold codemod --- recipes/node-url-to-whatwg-url/README.md | 57 +++++++++++++++++++ recipes/node-url-to-whatwg-url/codemod.yaml | 21 +++++++ recipes/node-url-to-whatwg-url/package.json | 24 ++++++++ .../src/import-process.ts | 17 ++++++ .../node-url-to-whatwg-url/src/url-format.ts | 15 +++++ .../node-url-to-whatwg-url/src/url-parse.ts | 20 +++++++ .../tests/import-process/expected/.gitkeep | 0 .../tests/import-process/input/.gitkeep | 0 .../tests/url-format/expected/.gitkeep | 0 .../tests/url-format/input/.gitkeep | 0 .../tests/url-parse/expected/.gitkeep | 0 .../tests/url-parse/input/.gitkeep | 0 recipes/node-url-to-whatwg-url/workflow.yaml | 55 ++++++++++++++++++ 13 files changed, 209 insertions(+) create mode 100644 recipes/node-url-to-whatwg-url/README.md create mode 100644 recipes/node-url-to-whatwg-url/codemod.yaml create mode 100644 recipes/node-url-to-whatwg-url/package.json create mode 100644 recipes/node-url-to-whatwg-url/src/import-process.ts create mode 100644 recipes/node-url-to-whatwg-url/src/url-format.ts create mode 100644 recipes/node-url-to-whatwg-url/src/url-parse.ts create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/expected/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/input/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/workflow.yaml diff --git a/recipes/node-url-to-whatwg-url/README.md b/recipes/node-url-to-whatwg-url/README.md new file mode 100644 index 00000000..d7688f56 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/README.md @@ -0,0 +1,57 @@ +# Node.js URL to WHATWG URL + +This recipe converts Node.js `url` module usage to the WHATWG URL API. It modifies code that uses the legacy `url` module to use the modern `URL` class instead. + +See [DEP0116](https://nodejs.org/api/deprecations.html#DEP0116). + +## Example + + +**`url.parse` to `new URL()`** +```js +// Before +const url = require('node:url'); +const myUrl = new url.URL('https://example.com'); +const urlAuth = legacyURL.auth; +// After +const myUrl = new URL('https://example.com'); +const urlAuth = `${myUrl.username}:${myUrl.password}`; +``` + +**`url.format` to `myUrl.toString()`** +```js +// Before +const url = require('node:url'); +url.format({ + protocol: 'https', + hostname: 'example.com', + pathname: '/some/path', + query: { + page: 1, + format: 'json', + }, +}); +// After +const myUrl = new URL('https://example.com/some/path?page=1&format=json'); +myUrl.toString(); +``` + +## Caveats + +The [`url.resolve`](https://nodejs.org/api/url.html#urlresolvefrom-to) method is not directly translatable to the WHATWG URL API. You may need to implement custom logic to handle URL resolution in your application. + +```js +function resolve(from, to) { + const resolvedUrl = new URL(to, new URL(from, 'resolve://')); + if (resolvedUrl.protocol === 'resolve:') { + // `from` is a relative URL. + const { pathname, search, hash } = resolvedUrl; + return pathname + search + hash; + } + return resolvedUrl.toString(); +} + +resolve('/one/two/three', 'four'); // '/one/two/four' +resolve('http://example.com/', '/one'); // 'http://example.com/one' +resolve('http://example.com/one', '/two'); // 'http://example.com/two' +``` diff --git a/recipes/node-url-to-whatwg-url/codemod.yaml b/recipes/node-url-to-whatwg-url/codemod.yaml new file mode 100644 index 00000000..aa86d9f9 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/codemod.yaml @@ -0,0 +1,21 @@ +schema_version: "1.0" +name: "@nodejs/node-url-to-whatwg-url" +version: 1.0.0 +description: Handle DEPDEP0116 via transforming `url.parse` to `new URL()` +author: Augustin Mauroy +license: MIT +workflow: workflow.yaml +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + +registry: + access: public + visibility: public diff --git a/recipes/node-url-to-whatwg-url/package.json b/recipes/node-url-to-whatwg-url/package.json new file mode 100644 index 00000000..15ff9d4c --- /dev/null +++ b/recipes/node-url-to-whatwg-url/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nodejs/node-url-to-whatwg-url", + "version": "1.0.0", + "description": "Handle DEPDEP0116 via transforming `url.parse` to `new URL()`", + "type": "module", + "scripts": { + "test": "node --run test:import-process.js && node --run test:url-format.js && node --run test:url-parse.js", + "test:import-process": "npx codemod@next jssg test -l typescript ./src/import-process.ts ./tests/import-process", + "test:url-format": "npx codemod@next jssg test -l typescript ./src/url-format.ts ./tests/url-format", + "test:url-parse": "npx codemod@next jssg test -l typescript ./src/url-parse.ts ./tests/url-parse" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodejs/userland-migrations.git", + "directory": "recipes/node-url-to-whatwg-url", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Augustin Mauroy", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations/tree/main/node-url-to-whatwg-url#readme", + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.3" + } +} diff --git a/recipes/node-url-to-whatwg-url/src/import-process.ts b/recipes/node-url-to-whatwg-url/src/import-process.ts new file mode 100644 index 00000000..8cf9b252 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/src/import-process.ts @@ -0,0 +1,17 @@ +import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; +import type JS from "@codemod.com/jssg-types/langs/javascript"; + +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + let hasChanges = false; + + // after the migration, `url.format` is deprecated, so we replace it with `new URL().toString()` + // check remaining usages of `url` module and replace import statements accordingly + // and require calls + + if (!hasChanges) return null; + + return rootNode.commitEdits(edits); +}; + diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts new file mode 100644 index 00000000..afd63405 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -0,0 +1,15 @@ +import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; +import type JS from "@codemod.com/jssg-types/langs/javascript"; + +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + let hasChanges = false; + + // `url.format` is deprecated, so we replace it with `new URL().toString()` + + if (!hasChanges) return null; + + return rootNode.commitEdits(edits); +}; + diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts new file mode 100644 index 00000000..29b86da1 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -0,0 +1,20 @@ +import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; +import type JS from "@codemod.com/jssg-types/langs/javascript"; + +/** + * Transforms `url.parse` usage to `new URL()`. Handles direct global access + * + * See https://nodejs.org/api/deprecations.html#DEPDEP0116 for more details. + */ +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + let hasChanges = false; + + // `url.parse` is deprecated, so we replace it with `new URL()` + + if (!hasChanges) return null; + + return rootNode.commitEdits(edits); +}; + diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/.gitkeep b/recipes/node-url-to-whatwg-url/tests/import-process/expected/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/.gitkeep b/recipes/node-url-to-whatwg-url/tests/import-process/input/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-format/expected/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-format/input/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-parse/input/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/recipes/node-url-to-whatwg-url/workflow.yaml b/recipes/node-url-to-whatwg-url/workflow.yaml new file mode 100644 index 00000000..f1b03b02 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/workflow.yaml @@ -0,0 +1,55 @@ +version: "1" +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + runtime: + type: direct + steps: + - name: Handle DEPDEP0116 via transforming `url.parse` to `new URL()` + js-ast-grep: + js_file: src/url-parse.ts + base_path: . + include: + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript + - name: Handle DEPDEP0116 via transforming `url.format` to `new URL().toString()` + js-ast-grep: + js_file: src/url-format.ts + base_path: . + include: + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript + - name: Clean up import statements + require calls + js-ast-grep: + js_file: src/import-process.ts + base_path: . + include: + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" From 3addfb3ec09293c595e439a87d12f25a9c47e9f7 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sun, 3 Aug 2025 15:26:00 +0200 Subject: [PATCH 02/52] url-parse(`node-url-to-whatwg-url`): setup `url-parse` --- biome.jsonc | 2 +- .../tests/url-parse/expected/.gitkeep | 0 .../tests/url-parse/expected/file-1.js | 12 ++++++++++++ .../tests/url-parse/expected/file-2.js | 12 ++++++++++++ .../tests/url-parse/expected/file-3.js | 12 ++++++++++++ .../tests/url-parse/expected/file-4.js | 12 ++++++++++++ .../tests/url-parse/expected/file-5.mjs | 12 ++++++++++++ .../tests/url-parse/expected/file-6.mjs | 12 ++++++++++++ .../tests/url-parse/expected/file-7.mjs | 12 ++++++++++++ .../tests/url-parse/expected/file-8.mjs | 12 ++++++++++++ .../tests/url-parse/input/.gitkeep | 0 .../tests/url-parse/input/file-1.js | 12 ++++++++++++ .../tests/url-parse/input/file-2.js | 12 ++++++++++++ .../tests/url-parse/input/file-3.js | 12 ++++++++++++ .../tests/url-parse/input/file-4.js | 12 ++++++++++++ .../tests/url-parse/input/file-5.mjs | 12 ++++++++++++ .../tests/url-parse/input/file-6.mjs | 12 ++++++++++++ .../tests/url-parse/input/file-7.mjs | 12 ++++++++++++ .../tests/url-parse/input/file-8.mjs | 12 ++++++++++++ 19 files changed, 193 insertions(+), 1 deletion(-) delete mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-1.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-2.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-3.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-4.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-5.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-6.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-7.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-8.mjs delete mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/file-1.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/file-2.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/file-3.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/file-4.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/file-5.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/file-6.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/file-7.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/file-8.mjs diff --git a/biome.jsonc b/biome.jsonc index 30993ebf..91e93aad 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,7 +1,7 @@ { "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "files": { - "includes": ["**", "!**/*.snap.cjs", "!**/fixtures/**", "!**/expected/**", "!**/input/**"] + "includes": ["**", "!**/*.snap.cjs", "!**/fixtures/**", "!**/tests/**"] }, "assist": { "actions": { "source": { "organizeImports": "off" } } }, // Rules for the linter diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-1.js b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-1.js new file mode 100644 index 00000000..2e5a4e64 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-1.js @@ -0,0 +1,12 @@ +const url = require('node:url'); + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-2.js b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-2.js new file mode 100644 index 00000000..b621e7f2 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-2.js @@ -0,0 +1,12 @@ +const nodeUrl = require('node:url'); + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-3.js b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-3.js new file mode 100644 index 00000000..063540c3 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-3.js @@ -0,0 +1,12 @@ +const { parse } = require('node:url'); + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-4.js b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-4.js new file mode 100644 index 00000000..d50c076e --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-4.js @@ -0,0 +1,12 @@ +const { parse: urlParse } = require('node:url'); + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-5.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-5.mjs new file mode 100644 index 00000000..ce1957a2 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-5.mjs @@ -0,0 +1,12 @@ +import url from 'node:url'; + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-6.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-6.mjs new file mode 100644 index 00000000..83a2cdbd --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-6.mjs @@ -0,0 +1,12 @@ +import nodeUrl from 'node:url'; + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-7.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-7.mjs new file mode 100644 index 00000000..89ae4f85 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-7.mjs @@ -0,0 +1,12 @@ +import { parse } from 'node:url'; + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-8.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-8.mjs new file mode 100644 index 00000000..c8300629 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-8.mjs @@ -0,0 +1,12 @@ +import { parse as urlParse } from 'node:url'; + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-parse/input/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-1.js b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-1.js new file mode 100644 index 00000000..aecb96d3 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-1.js @@ -0,0 +1,12 @@ +const url = require('node:url'); + +const myURL = url.parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-2.js b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-2.js new file mode 100644 index 00000000..c18e819d --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-2.js @@ -0,0 +1,12 @@ +const nodeUrl = require('node:url'); + +const myURL = nodeUrl.parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-3.js b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-3.js new file mode 100644 index 00000000..6a2540f9 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-3.js @@ -0,0 +1,12 @@ +const { parse } = require('node:url'); + +const myURL = parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-4.js b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-4.js new file mode 100644 index 00000000..83024dcb --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-4.js @@ -0,0 +1,12 @@ +const { parse: urlParse } = require('node:url'); + +const myURL = urlParse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-5.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-5.mjs new file mode 100644 index 00000000..db58b025 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-5.mjs @@ -0,0 +1,12 @@ +import url from 'node:url'; + +const myURL = url.parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-6.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-6.mjs new file mode 100644 index 00000000..07d9f974 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-6.mjs @@ -0,0 +1,12 @@ +import nodeUrl from 'node:url'; + +const myURL = nodeUrl.parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-7.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-7.mjs new file mode 100644 index 00000000..687463f6 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-7.mjs @@ -0,0 +1,12 @@ +import { parse } from 'node:url'; + +const myURL = parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-8.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-8.mjs new file mode 100644 index 00000000..2461384f --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-8.mjs @@ -0,0 +1,12 @@ +import { parse as urlParse } from 'node:url'; + +const myURL = urlParse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; From 04174b81eeea98fed6d792c280abcb7ec6ee5e71 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sun, 3 Aug 2025 16:42:21 +0200 Subject: [PATCH 03/52] WIP --- package-lock.json | 12 ++ .../src/import-process.ts | 7 +- .../node-url-to-whatwg-url/src/url-format.ts | 105 +++++++++++++++++- .../node-url-to-whatwg-url/src/url-parse.ts | 11 +- 4 files changed, 127 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2f697485..ae1f5d3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1414,6 +1414,10 @@ "resolved": "recipes/import-assertions-to-attributes", "link": true }, + "node_modules/@nodejs/node-url-to-whatwg-url": { + "resolved": "recipes/node-url-to-whatwg-url", + "link": true + }, "node_modules/@nodejs/process-main-module": { "resolved": "recipes/process-main-module", "link": true @@ -4072,6 +4076,14 @@ "@types/node": "^24.2.1" } }, + "recipes/node-url-to-whatwg-url": { + "name": "@nodejs/node-url-to-whatwg-url", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.3" + } + }, "recipes/process-main-module": { "name": "@nodejs/process-main-module", "version": "1.0.1", diff --git a/recipes/node-url-to-whatwg-url/src/import-process.ts b/recipes/node-url-to-whatwg-url/src/import-process.ts index 8cf9b252..4d086c9e 100644 --- a/recipes/node-url-to-whatwg-url/src/import-process.ts +++ b/recipes/node-url-to-whatwg-url/src/import-process.ts @@ -6,9 +6,10 @@ export default function transform(root: SgRoot): string | null { const edits: Edit[] = []; let hasChanges = false; - // after the migration, `url.format` is deprecated, so we replace it with `new URL().toString()` - // check remaining usages of `url` module and replace import statements accordingly - // and require calls + // after the migration, replacement of `url.parse` and `url.format` + // we need to check remaining usage of `url` module + // if needed, we can remove the `url` import + // we are going to use bruno's utility to resolve bindings if (!hasChanges) return null; diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index afd63405..f427405e 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -1,15 +1,114 @@ -import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; +import type { SgRoot, Edit, SgNode } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; +/** + * Transforms url.format() calls to new URL().toString() + * @param callNode The AST node representing the url.format() call + * @returns The transformed code + */ +function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { + for (const call of callNode) { + const optionsMatch = call.getMatch("OPTIONS"); + if (!optionsMatch) continue; + + // Find the object node that contains the URL options + const objectNode = optionsMatch.find({ + rule: { + kind: "object" + } + }); + + if (!objectNode) continue; + + // Extract URL components using AST traversal + const urlComponents = { + protocol: '', + hostname: '', + pathname: '', + search: '' + }; + + // Find all property pairs in the object + const pairs = objectNode.findAll({ + rule: { + kind: "pair" + } + }); + + for (const pair of pairs) { + // Get the property key + const keyNode = pair.find({ + rule: { + kind: "property_identifier" + } + }); + + // Get the string value + const valueNode = pair.find({ + rule: { + kind: "string" + } + }); + + if (keyNode && valueNode) { + const key = keyNode.text(); + // Get the string fragment (the actual content without quotes) + const stringFragment = valueNode.find({ + rule: { + kind: "string_fragment" + } + }); + + const value = stringFragment ? stringFragment.text() : valueNode.text().slice(1, -1); + + // Map the properties to URL components + if (key === 'protocol') urlComponents.protocol = value; + else if (key === 'hostname') urlComponents.hostname = value; + else if (key === 'pathname') urlComponents.pathname = value; + else if (key === 'search') urlComponents.search = value; + else console.warn(`Unknown URL option: ${key}`); + } + } + + // Construct the URL string + const urlString = `${urlComponents.protocol}://${urlComponents.hostname}${urlComponents.pathname}${urlComponents.search}`; + + // Replace the entire call with new URL().toString() + const replacement = `new URL('${urlString}').toString()`; + edits.push(call.replace(replacement)); + } +} + +/** + * Transforms `url.format` usage to `new URL().toString()`. + * + * See https://nodejs.org/api/deprecations.html#DEP0116 + * + * Handle: + * 1. `url.format(options)` → `new URL().toString()` + * 2. `format(options)` → `new URL().toString()` + * if imported with aliases + * 2. `foo.format(options)` → `new URL().toString()` + * 3. `foo(options)` → `new URL().toString()` + */ export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; let hasChanges = false; - // `url.format` is deprecated, so we replace it with `new URL().toString()` + // 1. Find all `url.format(options)` calls + const urlFormatCalls = rootNode.findAll({ + // TODO(@AugustinMauroy): use burno's utility (not merged yet) + rule: { pattern: "url.format($OPTIONS)" } + }); + + // 2. Find all `format(options)` calls + if (urlFormatCalls.length > 0) { + urlFormatToUrlToString(urlFormatCalls, edits); + hasChanges = edits.length > 0; + } if (!hasChanges) return null; return rootNode.commitEdits(edits); }; - diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index 29b86da1..e065411c 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -2,9 +2,16 @@ import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; /** - * Transforms `url.parse` usage to `new URL()`. Handles direct global access + * Transforms `url.parse` usage to `new URL()`. * - * See https://nodejs.org/api/deprecations.html#DEPDEP0116 for more details. + * See https://nodejs.org/api/deprecations.html#DEP0116 for more details. + * + * Handle: + * 1. `url.parse(urlString)` → `new URL(urlString)` + * 2. `parse(urlString)` → `new URL(urlString)` + * if imported with aliases + * 2. `foo.parse(urlString)` → `new URL(urlString)` + * 3. `foo(urlString)` → `new URL(urlString)` */ export default function transform(root: SgRoot): string | null { const rootNode = root.root(); From 4249d8e135a3c138d9e49d7599c9925af69d7c65 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 11:59:06 +0200 Subject: [PATCH 04/52] url-parse(`node-url-to-whatwg-url`): introduce --- recipes/node-url-to-whatwg-url/README.md | 5 +- recipes/node-url-to-whatwg-url/package.json | 8 +- .../src/import-process.ts | 114 ++++++++++- .../node-url-to-whatwg-url/src/url-format.ts | 180 ++++++++++++------ .../node-url-to-whatwg-url/src/url-parse.ts | 98 +++++++++- .../tests/import-process/expected/file-1.js | 1 + .../tests/import-process/expected/file-2.js | 3 + .../tests/import-process/expected/file-3.js | 1 + .../tests/import-process/expected/file-4.js | 3 + .../tests/import-process/expected/file-5.js | 1 + .../tests/import-process/expected/file-6.js | 1 + .../tests/import-process/input/file-1.js | 3 + .../tests/import-process/input/file-2.js | 3 + .../tests/import-process/input/file-3.js | 3 + .../tests/import-process/input/file-4.js | 3 + .../tests/import-process/input/file-5.js | 3 + .../tests/import-process/input/file-6.js | 3 + .../tests/url-format/expected/file-1.js | 3 + .../tests/url-format/expected/file-2.js | 3 + .../tests/url-format/expected/file-3.js | 3 + .../tests/url-format/input/file-1.js | 8 + .../tests/url-format/input/file-2.js | 9 + .../tests/url-format/input/file-3.js | 8 + 23 files changed, 398 insertions(+), 69 deletions(-) create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/expected/file-1.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/expected/file-2.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/expected/file-3.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/expected/file-4.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/expected/file-5.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/expected/file-6.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/input/file-1.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/input/file-2.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/input/file-3.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/input/file-4.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/input/file-5.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/input/file-6.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-1.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-2.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-3.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-1.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-2.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-3.js diff --git a/recipes/node-url-to-whatwg-url/README.md b/recipes/node-url-to-whatwg-url/README.md index d7688f56..3f531ad2 100644 --- a/recipes/node-url-to-whatwg-url/README.md +++ b/recipes/node-url-to-whatwg-url/README.md @@ -11,6 +11,7 @@ See [DEP0116](https://nodejs.org/api/deprecations.html#DEP0116). ```js // Before const url = require('node:url'); + const myUrl = new url.URL('https://example.com'); const urlAuth = legacyURL.auth; // After @@ -22,6 +23,7 @@ const urlAuth = `${myUrl.username}:${myUrl.password}`; ```js // Before const url = require('node:url'); + url.format({ protocol: 'https', hostname: 'example.com', @@ -32,8 +34,7 @@ url.format({ }, }); // After -const myUrl = new URL('https://example.com/some/path?page=1&format=json'); -myUrl.toString(); +const myUrl = new URL('https://example.com/some/path?page=1&format=json').toString(); ``` ## Caveats diff --git a/recipes/node-url-to-whatwg-url/package.json b/recipes/node-url-to-whatwg-url/package.json index 15ff9d4c..dfa865bb 100644 --- a/recipes/node-url-to-whatwg-url/package.json +++ b/recipes/node-url-to-whatwg-url/package.json @@ -4,10 +4,10 @@ "description": "Handle DEPDEP0116 via transforming `url.parse` to `new URL()`", "type": "module", "scripts": { - "test": "node --run test:import-process.js && node --run test:url-format.js && node --run test:url-parse.js", - "test:import-process": "npx codemod@next jssg test -l typescript ./src/import-process.ts ./tests/import-process", - "test:url-format": "npx codemod@next jssg test -l typescript ./src/url-format.ts ./tests/url-format", - "test:url-parse": "npx codemod@next jssg test -l typescript ./src/url-parse.ts ./tests/url-parse" + "test": "node --run test:import-process && node --run test:url-format && node --run test:url-parse", + "test:import-process": "npx codemod@next jssg test -l typescript ./src/import-process.ts ./tests/ --filter import-process", + "test:url-format": "npx codemod@next jssg test -l typescript ./src/url-format.ts ./tests/ --filter url-format", + "test:url-parse": "npx codemod@next jssg test -l typescript ./src/url-parse.ts ./tests/ --filter url-parse" }, "repository": { "type": "git", diff --git a/recipes/node-url-to-whatwg-url/src/import-process.ts b/recipes/node-url-to-whatwg-url/src/import-process.ts index 4d086c9e..f657b35e 100644 --- a/recipes/node-url-to-whatwg-url/src/import-process.ts +++ b/recipes/node-url-to-whatwg-url/src/import-process.ts @@ -1,5 +1,9 @@ -import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; +import type { SgRoot, Edit, Range } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; +import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; +import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; +import { removeLines } from "@nodejs/codemod-utils/ast-grep/remove-lines"; +// Clean up unused imports/requires from 'node:url' after transforms using shared utils export default function transform(root: SgRoot): string | null { const rootNode = root.root(); @@ -11,8 +15,114 @@ export default function transform(root: SgRoot): string | null { // if needed, we can remove the `url` import // we are going to use bruno's utility to resolve bindings + const isBindingUsed = (name: string): boolean => { + const refs = rootNode.findAll({ rule: { pattern: name } }); + // Heuristic: declaration counts as one; any other usage yields > 1 + return refs.length > 1; + }; + + const linesToRemove: Range[] = []; + + // 1) ES Module imports: import ... from 'node:url' + // @ts-ignore - ast-grep types vs jssg types + const esmImports = getNodeImportStatements(root, "url"); + + for (const imp of esmImports) { + // Try namespace/default binding + const clause = imp.find({ rule: { kind: "import_clause" } }); + let removed = false; + if (clause) { + // Namespace import like: import * as url from 'node:url' + const nsId = clause.find({ rule: { kind: "namespace_import" } })?.find({ rule: { kind: "identifier" } }); + if (nsId && !isBindingUsed(nsId.text())) { + edits.push(imp.replace("")); + removed = true; + } + if (removed) continue; + + // Named imports bucket + const specs = clause.findAll({ rule: { kind: "import_specifier" } }); + + // Default import like: import url from 'node:url' (only when not a named or namespace import) + if (specs.length === 0 && !nsId) { + const defaultId = clause.find({ rule: { kind: "identifier" } }); + if (defaultId && !isBindingUsed(defaultId.text())) { + edits.push(imp.replace("")); + removed = true; + } + if (removed) continue; + } + + // Named imports: import { a, b as c } from 'node:url' + if (specs.length > 0) { + const keepTexts: string[] = []; + for (const spec of specs) { + const text = spec.text().trim(); + // If alias form exists, use alias as the binding name + const bindingName = text.includes(" as ") ? text.split(/\s+as\s+/)[1] : text; + if (bindingName && isBindingUsed(bindingName)) keepTexts.push(text); + } + if (keepTexts.length === 0) { + linesToRemove.push(imp.range()); + } else if (keepTexts.length !== specs.length) { + const namedImportsNode = clause.find({ rule: { kind: "named_imports" } }); + if (namedImportsNode) edits.push(namedImportsNode.replace(`{ ${keepTexts.join(", ")} }`)); + } + } + } + } + + // 2) CommonJS requires: const ... = require('node:url') + // @ts-ignore - ast-grep types vs jssg types + const requireDecls = getNodeRequireCalls(root, "url"); + + for (const decl of requireDecls) { + // Namespace require: const url = require('node:url') + const id = decl.find({ rule: { kind: "identifier" } }); + const hasObjectPattern = decl.find({ rule: { kind: "object_pattern" } }); + + if (id && !hasObjectPattern) { + if (!isBindingUsed(id.text())) linesToRemove.push(decl.parent().range()); + continue; + } + + // Destructured require: const { parse, format: fmt } = require('node:url') + if (hasObjectPattern) { + const names: string[] = []; + // Shorthand bindings + const shorts = decl.findAll({ rule: { kind: "shorthand_property_identifier_pattern" } }); + for (const s of shorts) names.push(s.text()); + // Aliased bindings (pair_pattern) => use the identifier name (value) + const pairs = decl.findAll({ rule: { kind: "pair_pattern" } }); + for (const pair of pairs) { + const aliasId = pair.find({ rule: { kind: "identifier" } }); + if (aliasId) names.push(aliasId.text()); + } + + const usedTexts: string[] = []; + for (const s of shorts) if (isBindingUsed(s.text())) usedTexts.push(s.text()); + for (const pair of pairs) { + const aliasId = pair.find({ rule: { kind: "identifier" } }); + if (aliasId && isBindingUsed(aliasId.text())) usedTexts.push(pair.text()); // keep full spec text + } + + if (usedTexts.length === 0) { + linesToRemove.push(decl.parent().range()); + } else if (usedTexts.length !== names.length) { + const objPat = decl.find({ rule: { kind: "object_pattern" } }); + if (objPat) edits.push(objPat.replace(`{ ${usedTexts.join(", ")} }`)); + } + } + } + + hasChanges = edits.length > 0 || linesToRemove.length > 0; + if (!hasChanges) return null; - return rootNode.commitEdits(edits); + // Apply edits, remove whole lines ranges, then normalize leading whitespace only + let source = rootNode.commitEdits(edits); + source = removeLines(source, linesToRemove); + source = source.replace(/^\n+/, ""); + return source; }; diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index f427405e..e21fdd18 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -12,68 +12,125 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { if (!optionsMatch) continue; // Find the object node that contains the URL options - const objectNode = optionsMatch.find({ - rule: { - kind: "object" - } - }); - + const objectNode = optionsMatch.find({ rule: { kind: "object" } }); if (!objectNode) continue; - // Extract URL components using AST traversal - const urlComponents = { - protocol: '', - hostname: '', - pathname: '', - search: '' - }; - - // Find all property pairs in the object - const pairs = objectNode.findAll({ - rule: { - kind: "pair" + const urlState: { + protocol?: string; + auth?: string; // user:pass + host?: string; // host:port + hostname?: string; + port?: string; + pathname?: string; + search?: string; // ?a=b + hash?: string; // #frag + queryParams?: Array<[string, string]>; + } = {}; + + const pairs = objectNode.findAll({ rule: { kind: "pair" } }); + + const getLiteralText = (node: SgNode | null | undefined): string | undefined => { + if (!node) return undefined; + const kind = node.kind(); + if (kind === "string") { + const frag = node.find({ rule: { kind: "string_fragment" } }); + return frag ? frag.text() : node.text().slice(1, -1); } - }); + if (kind === "number") return node.text(); + if (kind === "true" || kind === "false") return node.text(); + return undefined; + }; for (const pair of pairs) { - // Get the property key - const keyNode = pair.find({ - rule: { - kind: "property_identifier" + const keyNode = pair.find({ rule: { kind: "property_identifier" } }); + const key = keyNode?.text(); + if (!key) continue; + + if (key === "query") { + // Collect query object literals into key=value + const queryObj = pair.find({ rule: { kind: "object" } }); + if (queryObj) { + const qpairs = queryObj.findAll({ rule: { kind: "pair" } }); + const list: Array<[string, string]> = []; + for (const qp of qpairs) { + const qkeyNode = qp.find({ rule: { kind: "property_identifier" } }); + const qvalLiteral = getLiteralText(qp.find({ rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] } })); + if (qkeyNode && qvalLiteral !== undefined) list.push([qkeyNode.text(), qvalLiteral]); + } + urlState.queryParams = list; } - }); + continue; + } - // Get the string value - const valueNode = pair.find({ - rule: { - kind: "string" + // value might be string/number/bool + const valueLiteral = getLiteralText(pair.find({ rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] } })); + if (valueLiteral === undefined) continue; + + switch (key) { + case "protocol": { + // normalize without trailing ':' + urlState.protocol = valueLiteral.replace(/:$/, ""); + break; } - }); - - if (keyNode && valueNode) { - const key = keyNode.text(); - // Get the string fragment (the actual content without quotes) - const stringFragment = valueNode.find({ - rule: { - kind: "string_fragment" - } - }); + case "auth": { + urlState.auth = valueLiteral; // 'user:pass' + break; + } + case "host": { + urlState.host = valueLiteral; // 'example.com:8080' + break; + } + case "hostname": { + urlState.hostname = valueLiteral; + break; + } + case "port": { + urlState.port = valueLiteral; + break; + } + case "pathname": { + urlState.pathname = valueLiteral; + break; + } + case "search": { + urlState.search = valueLiteral; + break; + } + case "hash": { + urlState.hash = valueLiteral; + break; + } + default: + // ignore unknown options in this simple mapping + break; + } + } + + const proto = urlState.protocol ?? ""; + const auth = urlState.auth ? `${urlState.auth}@` : ""; + let host = urlState.host; + if (!host) { + if (urlState.hostname && urlState.port) host = `${urlState.hostname}:${urlState.port}`; + else if (urlState.hostname) host = urlState.hostname; + else host = ""; + } - const value = stringFragment ? stringFragment.text() : valueNode.text().slice(1, -1); + let pathname = urlState.pathname ?? ""; + if (pathname && !pathname.startsWith("/")) pathname = `/${pathname}`; - // Map the properties to URL components - if (key === 'protocol') urlComponents.protocol = value; - else if (key === 'hostname') urlComponents.hostname = value; - else if (key === 'pathname') urlComponents.pathname = value; - else if (key === 'search') urlComponents.search = value; - else console.warn(`Unknown URL option: ${key}`); - } + let search = urlState.search ?? ""; + if (!search && urlState.queryParams && urlState.queryParams.length > 0) { + const qs = urlState.queryParams.map(([k, v]) => `${k}=${v}`).join("&"); + search = `?${qs}`; } + if (search && !search.startsWith("?")) search = `?${search}`; + + let hash = urlState.hash ?? ""; + if (hash && !hash.startsWith("#")) hash = `#${hash}`; - // Construct the URL string - const urlString = `${urlComponents.protocol}://${urlComponents.hostname}${urlComponents.pathname}${urlComponents.search}`; + const base = proto ? `${proto}://` : ""; + const urlString = `${base}${auth}${host}${pathname}${search}${hash}`; - // Replace the entire call with new URL().toString() const replacement = `new URL('${urlString}').toString()`; edits.push(call.replace(replacement)); } @@ -96,18 +153,23 @@ export default function transform(root: SgRoot): string | null { const edits: Edit[] = []; let hasChanges = false; - // 1. Find all `url.format(options)` calls - const urlFormatCalls = rootNode.findAll({ - // TODO(@AugustinMauroy): use burno's utility (not merged yet) - rule: { pattern: "url.format($OPTIONS)" } - }); - - // 2. Find all `format(options)` calls - if (urlFormatCalls.length > 0) { - urlFormatToUrlToString(urlFormatCalls, edits); - hasChanges = edits.length > 0; + // Look for various ways format can be referenced + const patterns = [ + "url.format($OPTIONS)", + "nodeUrl.format($OPTIONS)", + "format($OPTIONS)", + "urlFormat($OPTIONS)" + ]; + + for (const pattern of patterns) { + const calls = rootNode.findAll({ rule: { pattern } }); + if (calls.length > 0) { + urlFormatToUrlToString(calls, edits); + } } + hasChanges = edits.length > 0; + if (!hasChanges) return null; return rootNode.commitEdits(edits); diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index e065411c..610d0bdf 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -1,4 +1,4 @@ -import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; +import type { SgRoot, Edit, SgNode } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; /** @@ -16,10 +16,104 @@ import type JS from "@codemod.com/jssg-types/langs/javascript"; export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; - let hasChanges = false; // `url.parse` is deprecated, so we replace it with `new URL()` + // Safety: only run on files that import/require node:url + const hasNodeUrlImport = + rootNode.find({ rule: { pattern: "require('node:url')" } }) || + rootNode.find({ rule: { pattern: 'require("node:url")' } }) || + rootNode.find({ rule: { pattern: "import $_ from 'node:url'" } }) || + rootNode.find({ rule: { pattern: 'import $_ from "node:url"' } }) || + rootNode.find({ rule: { pattern: "import { $_ } from 'node:url'" } }) || + rootNode.find({ rule: { pattern: 'import { $_ } from "node:url"' } }); + + if (!hasNodeUrlImport) { + return null; + } + + // 1) Replace parse calls with new URL() + const parseCallPatterns = [ + // member calls on typical identifiers + "url.parse($ARG)", + "nodeUrl.parse($ARG)", + // bare identifier (named import) + "parse($ARG)", + // common alias used in tests + "urlParse($ARG)" + ]; + + for (const pattern of parseCallPatterns) { + const calls = rootNode.findAll({ rule: { pattern } }); + for (const call of calls) { + const arg = call.getMatch("ARG") as SgNode | undefined; + if (!arg) continue; + const replacement = `new URL(${arg.text()})`; + edits.push(call.replace(replacement)); + } + } + + // 2) Transform legacy properties on URL object + // - auth => `${obj.username}:${obj.password}` + // - path => `${obj.pathname}${obj.search}` + // - hostname => obj.hostname.replace(/^[\[|\]]$/, '') (strip square brackets) + + // Property access: obj.auth -> `${obj.username}:${obj.password}` + const authAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.auth" } }); + for (const node of authAccesses) { + const base = node.getMatch("OBJ") as SgNode | undefined; + if (!base) continue; + const replacement = '`${' + base.text() + '.username}:${' + base.text() + '.password}`'; + edits.push(node.replace(replacement)); + } + + // Destructuring: const { auth } = obj -> const auth = `${obj.username}:${obj.password}` + const authDestructures = rootNode.findAll({ rule: { pattern: "const { auth } = $OBJ" } }); + for (const node of authDestructures) { + const base = node.getMatch("OBJ") as SgNode | undefined; + if (!base) continue; + const replacement = 'const auth = ' + '`${' + base.text() + '.username}:${' + base.text() + '.password}`' + ';'; + edits.push(node.replace(replacement)); + } + + // Property access: obj.path -> `${obj.pathname}${obj.search}` + const pathAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.path" } }); + for (const node of pathAccesses) { + const base = node.getMatch("OBJ") as SgNode | undefined; + if (!base) continue; + const replacement = '`${' + base.text() + '.pathname}${' + base.text() + '.search}`'; + edits.push(node.replace(replacement)); + } + + // Destructuring: const { path } = obj -> const path = `${obj.pathname}${obj.search}` + const pathDestructures = rootNode.findAll({ rule: { pattern: "const { path } = $OBJ" } }); + for (const node of pathDestructures) { + const base = node.getMatch("OBJ") as SgNode | undefined; + if (!base) continue; + const replacement = 'const path = ' + '`${' + base.text() + '.pathname}${' + base.text() + '.search}`' + ';'; + edits.push(node.replace(replacement)); + } + + // Property access: obj.hostname -> obj.hostname.replace(/^\[|\]$/, '') + const hostnameAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.hostname" } }); + for (const node of hostnameAccesses) { + const base = node.getMatch("OBJ") as SgNode | undefined; + if (!base) continue; + const replacement = base.text() + ".hostname.replace(/^\\[|\\]$/, '')"; + edits.push(node.replace(replacement)); + } + + // Destructuring: const { hostname } = obj -> const hostname = obj.hostname.replace(/^\[|\]$/, '') + const hostnameDestructures = rootNode.findAll({ rule: { pattern: "const { hostname } = $OBJ" } }); + for (const node of hostnameDestructures) { + const base = node.getMatch("OBJ") as SgNode | undefined; + if (!base) continue; + const replacement = 'const hostname = ' + base.text() + ".hostname.replace(/^\\[|\\]$/, '')" + ';'; + edits.push(node.replace(replacement)); + } + + const hasChanges = edits.length > 0; + if (!hasChanges) return null; return rootNode.commitEdits(edits); diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-1.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-1.js new file mode 100644 index 00000000..943c458c --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-1.js @@ -0,0 +1 @@ +const x = 1; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-2.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-2.js new file mode 100644 index 00000000..1b094505 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-2.js @@ -0,0 +1,3 @@ +import { format as fmt } from 'node:url'; + +console.log(fmt); diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-3.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-3.js new file mode 100644 index 00000000..1905b485 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-3.js @@ -0,0 +1 @@ +function foo() { return 1; } diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-4.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-4.js new file mode 100644 index 00000000..8563cef9 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-4.js @@ -0,0 +1,3 @@ +const { format: fmt } = require('node:url'); + +fmt('x'); diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-5.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-5.js new file mode 100644 index 00000000..2cc629c7 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-5.js @@ -0,0 +1 @@ +export const v = 42; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-6.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-6.js new file mode 100644 index 00000000..4321ad8b --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-6.js @@ -0,0 +1 @@ +doStuff(); diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-1.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-1.js new file mode 100644 index 00000000..97a850a2 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-1.js @@ -0,0 +1,3 @@ +import url from 'node:url'; + +const x = 1; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-2.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-2.js new file mode 100644 index 00000000..b3025e60 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-2.js @@ -0,0 +1,3 @@ +import { parse, format as fmt } from 'node:url'; + +console.log(fmt); diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-3.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-3.js new file mode 100644 index 00000000..4dc78c3c --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-3.js @@ -0,0 +1,3 @@ +const url = require('node:url'); + +function foo() { return 1; } diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-4.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-4.js new file mode 100644 index 00000000..25e5c4ac --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-4.js @@ -0,0 +1,3 @@ +const { parse, format: fmt } = require('node:url'); + +fmt('x'); diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-5.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-5.js new file mode 100644 index 00000000..71d51251 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-5.js @@ -0,0 +1,3 @@ +import * as url from 'node:url'; + +export const v = 42; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-6.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-6.js new file mode 100644 index 00000000..e6769006 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-6.js @@ -0,0 +1,3 @@ +const { parse } = require('node:url'); + +doStuff(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-1.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-1.js new file mode 100644 index 00000000..dc398267 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-1.js @@ -0,0 +1,3 @@ +const url = require('node:url'); + +const str = new URL('https://example.com/some/path?page=1').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-2.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-2.js new file mode 100644 index 00000000..5be5dd20 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-2.js @@ -0,0 +1,3 @@ +const { format } = require('node:url'); + +const a = new URL('https://example.com:8080/p?page=1&format=json#frag').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-3.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-3.js new file mode 100644 index 00000000..87dd3d98 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-3.js @@ -0,0 +1,3 @@ +const nodeUrl = require('node:url'); + +const b = new URL('http://example.com:3000/x').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-1.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-1.js new file mode 100644 index 00000000..e95d6e40 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-1.js @@ -0,0 +1,8 @@ +const url = require('node:url'); + +const str = url.format({ + protocol: 'https', + hostname: 'example.com', + pathname: '/some/path', + search: '?page=1' +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-2.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-2.js new file mode 100644 index 00000000..4f33ebd9 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-2.js @@ -0,0 +1,9 @@ +const { format } = require('node:url'); + +const a = format({ + protocol: 'https:', + host: 'example.com:8080', + pathname: 'p', + query: { page: '1', format: 'json' }, + hash: 'frag', +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-3.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-3.js new file mode 100644 index 00000000..ef9bffca --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-3.js @@ -0,0 +1,8 @@ +const nodeUrl = require('node:url'); + +const b = nodeUrl.format({ + protocol: 'http', + hostname: 'example.com', + port: '3000', + pathname: '/x', +}); From 873775a3c2aa9265412b32c6a10e8c4cd9435496 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 13:50:21 +0200 Subject: [PATCH 05/52] clean --- .../src/import-process.ts | 5 +- .../node-url-to-whatwg-url/src/url-format.ts | 37 +++--- .../node-url-to-whatwg-url/src/url-parse.ts | 123 +++++++++--------- .../tests/import-process/expected/.gitkeep | 0 .../tests/import-process/input/.gitkeep | 0 .../tests/url-format/expected/.gitkeep | 0 .../tests/url-format/input/.gitkeep | 0 7 files changed, 84 insertions(+), 81 deletions(-) delete mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/expected/.gitkeep delete mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/input/.gitkeep delete mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/.gitkeep delete mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/.gitkeep diff --git a/recipes/node-url-to-whatwg-url/src/import-process.ts b/recipes/node-url-to-whatwg-url/src/import-process.ts index f657b35e..dd7646b4 100644 --- a/recipes/node-url-to-whatwg-url/src/import-process.ts +++ b/recipes/node-url-to-whatwg-url/src/import-process.ts @@ -8,7 +8,6 @@ import { removeLines } from "@nodejs/codemod-utils/ast-grep/remove-lines"; export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; - let hasChanges = false; // after the migration, replacement of `url.parse` and `url.format` // we need to check remaining usage of `url` module @@ -115,9 +114,7 @@ export default function transform(root: SgRoot): string | null { } } - hasChanges = edits.length > 0 || linesToRemove.length > 0; - - if (!hasChanges) return null; + if (edits.length < 0 || linesToRemove.length < 0) return null; // Apply edits, remove whole lines ranges, then normalize leading whitespace only let source = rootNode.commitEdits(edits); diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index e21fdd18..0468b42e 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -1,6 +1,23 @@ import type { SgRoot, Edit, SgNode } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; +/** + * Get the literal text value of a node, if it exists. + * @param node The node to extract the literal text from + * @returns The literal text value, or undefined if not found + */ +const getLiteralText = (node: SgNode | null | undefined): string | undefined => { + const kind = node.kind(); + + if (kind === "string") { + const frag = node.find({ rule: { kind: "string_fragment" } }); + return frag ? frag.text() : node.text().slice(1, -1); + } + if (kind === "number") return node.text(); + if (kind === "true" || kind === "false") return node.text(); + return undefined; +}; + /** * Transforms url.format() calls to new URL().toString() * @param callNode The AST node representing the url.format() call @@ -29,18 +46,6 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { const pairs = objectNode.findAll({ rule: { kind: "pair" } }); - const getLiteralText = (node: SgNode | null | undefined): string | undefined => { - if (!node) return undefined; - const kind = node.kind(); - if (kind === "string") { - const frag = node.find({ rule: { kind: "string_fragment" } }); - return frag ? frag.text() : node.text().slice(1, -1); - } - if (kind === "number") return node.text(); - if (kind === "true" || kind === "false") return node.text(); - return undefined; - }; - for (const pair of pairs) { const keyNode = pair.find({ rule: { kind: "property_identifier" } }); const key = keyNode?.text(); @@ -151,7 +156,6 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; - let hasChanges = false; // Look for various ways format can be referenced const patterns = [ @@ -163,14 +167,13 @@ export default function transform(root: SgRoot): string | null { for (const pattern of patterns) { const calls = rootNode.findAll({ rule: { pattern } }); - if (calls.length > 0) { + + if (calls.length) { urlFormatToUrlToString(calls, edits); } } - hasChanges = edits.length > 0; - - if (!hasChanges) return null; + if (!edits.length) return null; return rootNode.commitEdits(edits); }; diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index 610d0bdf..ed70535d 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -1,4 +1,4 @@ -import type { SgRoot, Edit, SgNode } from "@codemod.com/jssg-types/main"; +import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; /** @@ -17,8 +17,6 @@ export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; - // `url.parse` is deprecated, so we replace it with `new URL()` - // Safety: only run on files that import/require node:url const hasNodeUrlImport = rootNode.find({ rule: { pattern: "require('node:url')" } }) || @@ -45,8 +43,9 @@ export default function transform(root: SgRoot): string | null { for (const pattern of parseCallPatterns) { const calls = rootNode.findAll({ rule: { pattern } }); + for (const call of calls) { - const arg = call.getMatch("ARG") as SgNode | undefined; + const arg = call.getMatch("ARG"); if (!arg) continue; const replacement = `new URL(${arg.text()})`; edits.push(call.replace(replacement)); @@ -59,62 +58,66 @@ export default function transform(root: SgRoot): string | null { // - hostname => obj.hostname.replace(/^[\[|\]]$/, '') (strip square brackets) // Property access: obj.auth -> `${obj.username}:${obj.password}` - const authAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.auth" } }); - for (const node of authAccesses) { - const base = node.getMatch("OBJ") as SgNode | undefined; - if (!base) continue; - const replacement = '`${' + base.text() + '.username}:${' + base.text() + '.password}`'; - edits.push(node.replace(replacement)); - } - - // Destructuring: const { auth } = obj -> const auth = `${obj.username}:${obj.password}` - const authDestructures = rootNode.findAll({ rule: { pattern: "const { auth } = $OBJ" } }); - for (const node of authDestructures) { - const base = node.getMatch("OBJ") as SgNode | undefined; - if (!base) continue; - const replacement = 'const auth = ' + '`${' + base.text() + '.username}:${' + base.text() + '.password}`' + ';'; - edits.push(node.replace(replacement)); - } - - // Property access: obj.path -> `${obj.pathname}${obj.search}` - const pathAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.path" } }); - for (const node of pathAccesses) { - const base = node.getMatch("OBJ") as SgNode | undefined; - if (!base) continue; - const replacement = '`${' + base.text() + '.pathname}${' + base.text() + '.search}`'; - edits.push(node.replace(replacement)); - } - - // Destructuring: const { path } = obj -> const path = `${obj.pathname}${obj.search}` - const pathDestructures = rootNode.findAll({ rule: { pattern: "const { path } = $OBJ" } }); - for (const node of pathDestructures) { - const base = node.getMatch("OBJ") as SgNode | undefined; - if (!base) continue; - const replacement = 'const path = ' + '`${' + base.text() + '.pathname}${' + base.text() + '.search}`' + ';'; - edits.push(node.replace(replacement)); - } - - // Property access: obj.hostname -> obj.hostname.replace(/^\[|\]$/, '') - const hostnameAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.hostname" } }); - for (const node of hostnameAccesses) { - const base = node.getMatch("OBJ") as SgNode | undefined; - if (!base) continue; - const replacement = base.text() + ".hostname.replace(/^\\[|\\]$/, '')"; - edits.push(node.replace(replacement)); - } - - // Destructuring: const { hostname } = obj -> const hostname = obj.hostname.replace(/^\[|\]$/, '') - const hostnameDestructures = rootNode.findAll({ rule: { pattern: "const { hostname } = $OBJ" } }); - for (const node of hostnameDestructures) { - const base = node.getMatch("OBJ") as SgNode | undefined; - if (!base) continue; - const replacement = 'const hostname = ' + base.text() + ".hostname.replace(/^\\[|\\]$/, '')" + ';'; - edits.push(node.replace(replacement)); - } - - const hasChanges = edits.length > 0; - - if (!hasChanges) return null; + const authAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.auth" } }); + for (const node of authAccesses) { + const base = node.getMatch("OBJ"); + if (!base) continue; + + const replacement = `\`\${${base.text()}.username}:\${${base.text()}.password}\``; + edits.push(node.replace(replacement)); + } + + // Destructuring: const { auth } = obj -> const auth = `${obj.username}:${obj.password}` + const authDestructures = rootNode.findAll({ rule: { pattern: "const { auth } = $OBJ" } }); + for (const node of authDestructures) { + const base = node.getMatch("OBJ"); + if (!base) continue; + + const replacement = `const auth = \`\${${base.text()}.username}:\${${base.text()}.password}\`;`; + edits.push(node.replace(replacement)); + } + + // Property access: obj.path -> `${obj.pathname}${obj.search}` + const pathAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.path" } }); + for (const node of pathAccesses) { + const base = node.getMatch("OBJ"); + if (!base) continue; + + const replacement = `\`\${${base.text()}.pathname}\${${base.text()}.search}\``; + edits.push(node.replace(replacement)); + } + + // Destructuring: const { path } = obj -> const path = `${obj.pathname}${obj.search}` + const pathDestructures = rootNode.findAll({ rule: { pattern: "const { path } = $OBJ" } }); + for (const node of pathDestructures) { + const base = node.getMatch("OBJ"); + if (!base) continue; + + const replacement = `const path = \`\${${base.text()}.pathname}\${${base.text()}.search}\`;`; + edits.push(node.replace(replacement)); + } + + // Property access: obj.hostname -> obj.hostname.replace(/^\[|\]$/, '') + const hostnameAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.hostname" } }); + for (const node of hostnameAccesses) { + const base = node.getMatch("OBJ"); + if (!base) continue; + + const replacement = `${base.text()}.hostname.replace(/^\\[|\\]$/, '')`; + edits.push(node.replace(replacement)); + } + + // Destructuring: const { hostname } = obj -> const hostname = obj.hostname.replace(/^\[|\]$/, '') + const hostnameDestructures = rootNode.findAll({ rule: { pattern: "const { hostname } = $OBJ" } }); + for (const node of hostnameDestructures) { + const base = node.getMatch("OBJ"); + if (!base) continue; + + const replacement = `const hostname = ${base.text()}.hostname.replace(/^\\[|\\]$/, '');`; + edits.push(node.replace(replacement)); + } + + if (!edits.length) return null; return rootNode.commitEdits(edits); }; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/.gitkeep b/recipes/node-url-to-whatwg-url/tests/import-process/expected/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/.gitkeep b/recipes/node-url-to-whatwg-url/tests/import-process/input/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-format/expected/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-format/input/.gitkeep deleted file mode 100644 index e69de29b..00000000 From 4cf1c36f80e788f2b8f65159bf2502d740452fc3 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 13:54:54 +0200 Subject: [PATCH 06/52] WIP --- .../node-url-to-whatwg-url/src/url-format.ts | 10 ++ .../node-url-to-whatwg-url/src/url-parse.ts | 91 +++++++++---------- 2 files changed, 54 insertions(+), 47 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index 0468b42e..e9b0a7b6 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -1,5 +1,7 @@ import type { SgRoot, Edit, SgNode } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; +import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; +import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; /** * Get the literal text value of a node, if it exists. @@ -157,7 +159,15 @@ export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; + // Safety: only run on files that import/require node:url + const hasNodeUrlImport = + getNodeImportStatements(root, "url").length > 0 || + getNodeRequireCalls(root, "url").length > 0; + + if (!hasNodeUrlImport) return null; + // Look for various ways format can be referenced + // todo(@AugustinMauroy): use resolveBindingPath const patterns = [ "url.format($OPTIONS)", "nodeUrl.format($OPTIONS)", diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index ed70535d..b879b08e 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -1,3 +1,5 @@ +import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; +import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; @@ -14,50 +16,46 @@ import type JS from "@codemod.com/jssg-types/langs/javascript"; * 3. `foo(urlString)` → `new URL(urlString)` */ export default function transform(root: SgRoot): string | null { - const rootNode = root.root(); - const edits: Edit[] = []; - - // Safety: only run on files that import/require node:url - const hasNodeUrlImport = - rootNode.find({ rule: { pattern: "require('node:url')" } }) || - rootNode.find({ rule: { pattern: 'require("node:url")' } }) || - rootNode.find({ rule: { pattern: "import $_ from 'node:url'" } }) || - rootNode.find({ rule: { pattern: 'import $_ from "node:url"' } }) || - rootNode.find({ rule: { pattern: "import { $_ } from 'node:url'" } }) || - rootNode.find({ rule: { pattern: 'import { $_ } from "node:url"' } }); - - if (!hasNodeUrlImport) { - return null; - } - - // 1) Replace parse calls with new URL() - const parseCallPatterns = [ - // member calls on typical identifiers - "url.parse($ARG)", - "nodeUrl.parse($ARG)", - // bare identifier (named import) - "parse($ARG)", - // common alias used in tests - "urlParse($ARG)" - ]; - - for (const pattern of parseCallPatterns) { - const calls = rootNode.findAll({ rule: { pattern } }); - - for (const call of calls) { - const arg = call.getMatch("ARG"); - if (!arg) continue; - const replacement = `new URL(${arg.text()})`; - edits.push(call.replace(replacement)); - } - } - - // 2) Transform legacy properties on URL object - // - auth => `${obj.username}:${obj.password}` - // - path => `${obj.pathname}${obj.search}` - // - hostname => obj.hostname.replace(/^[\[|\]]$/, '') (strip square brackets) - - // Property access: obj.auth -> `${obj.username}:${obj.password}` + const rootNode = root.root(); + const edits: Edit[] = []; + + // Safety: only run on files that import/require node:url + const hasNodeUrlImport = + getNodeImportStatements(root, "url").length > 0 || + getNodeRequireCalls(root, "url").length > 0; + + if (!hasNodeUrlImport) return null; + + // 1) Replace parse calls with new URL() + // todo(@AugustinMauroy): use resolveBindingPath + const parseCallPatterns = [ + // member calls on typical identifiers + "url.parse($ARG)", + "nodeUrl.parse($ARG)", + // bare identifier (named import) + "parse($ARG)", + // common alias used in tests + "urlParse($ARG)" + ]; + + for (const pattern of parseCallPatterns) { + const calls = rootNode.findAll({ rule: { pattern } }); + + for (const call of calls) { + const arg = call.getMatch("ARG"); + if (!arg) continue; + + const replacement = `new URL(${arg.text()})`; + edits.push(call.replace(replacement)); + } + } + + // 2) Transform legacy properties on URL object + // - auth => `${obj.username}:${obj.password}` + // - path => `${obj.pathname}${obj.search}` + // - hostname => obj.hostname.replace(/^[\[|\]]$/, '') (strip square brackets) + + // Property access: obj.auth -> `${obj.username}:${obj.password}` const authAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.auth" } }); for (const node of authAccesses) { const base = node.getMatch("OBJ"); @@ -117,8 +115,7 @@ export default function transform(root: SgRoot): string | null { edits.push(node.replace(replacement)); } - if (!edits.length) return null; + if (!edits.length) return null; - return rootNode.commitEdits(edits); + return rootNode.commitEdits(edits); }; - From ab1801744b197dee27c999499608dc0ffd6408f5 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 13:57:21 +0200 Subject: [PATCH 07/52] fix: ts --- recipes/node-url-to-whatwg-url/src/url-format.ts | 2 ++ recipes/node-url-to-whatwg-url/src/url-parse.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index e9b0a7b6..55f7592d 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -161,7 +161,9 @@ export default function transform(root: SgRoot): string | null { // Safety: only run on files that import/require node:url const hasNodeUrlImport = + // @ts-ignore getNodeImportStatements(root, "url").length > 0 || + // @ts-ignore getNodeRequireCalls(root, "url").length > 0; if (!hasNodeUrlImport) return null; diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index b879b08e..c22d415b 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -21,8 +21,10 @@ export default function transform(root: SgRoot): string | null { // Safety: only run on files that import/require node:url const hasNodeUrlImport = + // @ts-ignore getNodeImportStatements(root, "url").length > 0 || - getNodeRequireCalls(root, "url").length > 0; + // @ts-ignore + getNodeRequireCalls(root, "url").length > 0; if (!hasNodeUrlImport) return null; From 4b60d0365ae56c6b846e0bca635f5efc94737161 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 14:19:13 +0200 Subject: [PATCH 08/52] fix: windows --- .../src/import-process.ts | 223 +++++++++--------- 1 file changed, 106 insertions(+), 117 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/src/import-process.ts b/recipes/node-url-to-whatwg-url/src/import-process.ts index dd7646b4..14cf6be8 100644 --- a/recipes/node-url-to-whatwg-url/src/import-process.ts +++ b/recipes/node-url-to-whatwg-url/src/import-process.ts @@ -3,123 +3,112 @@ import type JS from "@codemod.com/jssg-types/langs/javascript"; import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; import { removeLines } from "@nodejs/codemod-utils/ast-grep/remove-lines"; -// Clean up unused imports/requires from 'node:url' after transforms using shared utils +/** + * Clean up unused imports/requires from 'node:url' after transforms using shared utils + */ export default function transform(root: SgRoot): string | null { - const rootNode = root.root(); - const edits: Edit[] = []; - - // after the migration, replacement of `url.parse` and `url.format` - // we need to check remaining usage of `url` module - // if needed, we can remove the `url` import - // we are going to use bruno's utility to resolve bindings - - const isBindingUsed = (name: string): boolean => { - const refs = rootNode.findAll({ rule: { pattern: name } }); - // Heuristic: declaration counts as one; any other usage yields > 1 - return refs.length > 1; - }; - - const linesToRemove: Range[] = []; - - // 1) ES Module imports: import ... from 'node:url' - // @ts-ignore - ast-grep types vs jssg types - const esmImports = getNodeImportStatements(root, "url"); - - for (const imp of esmImports) { - // Try namespace/default binding - const clause = imp.find({ rule: { kind: "import_clause" } }); - let removed = false; - if (clause) { - // Namespace import like: import * as url from 'node:url' - const nsId = clause.find({ rule: { kind: "namespace_import" } })?.find({ rule: { kind: "identifier" } }); - if (nsId && !isBindingUsed(nsId.text())) { - edits.push(imp.replace("")); - removed = true; - } - if (removed) continue; - - // Named imports bucket - const specs = clause.findAll({ rule: { kind: "import_specifier" } }); - - // Default import like: import url from 'node:url' (only when not a named or namespace import) - if (specs.length === 0 && !nsId) { - const defaultId = clause.find({ rule: { kind: "identifier" } }); - if (defaultId && !isBindingUsed(defaultId.text())) { - edits.push(imp.replace("")); - removed = true; - } - if (removed) continue; - } - - // Named imports: import { a, b as c } from 'node:url' - if (specs.length > 0) { - const keepTexts: string[] = []; - for (const spec of specs) { - const text = spec.text().trim(); - // If alias form exists, use alias as the binding name - const bindingName = text.includes(" as ") ? text.split(/\s+as\s+/)[1] : text; - if (bindingName && isBindingUsed(bindingName)) keepTexts.push(text); - } - if (keepTexts.length === 0) { - linesToRemove.push(imp.range()); - } else if (keepTexts.length !== specs.length) { - const namedImportsNode = clause.find({ rule: { kind: "named_imports" } }); - if (namedImportsNode) edits.push(namedImportsNode.replace(`{ ${keepTexts.join(", ")} }`)); - } - } - } - } - - // 2) CommonJS requires: const ... = require('node:url') - // @ts-ignore - ast-grep types vs jssg types - const requireDecls = getNodeRequireCalls(root, "url"); - - for (const decl of requireDecls) { - // Namespace require: const url = require('node:url') - const id = decl.find({ rule: { kind: "identifier" } }); - const hasObjectPattern = decl.find({ rule: { kind: "object_pattern" } }); - - if (id && !hasObjectPattern) { - if (!isBindingUsed(id.text())) linesToRemove.push(decl.parent().range()); - continue; - } - - // Destructured require: const { parse, format: fmt } = require('node:url') - if (hasObjectPattern) { - const names: string[] = []; - // Shorthand bindings - const shorts = decl.findAll({ rule: { kind: "shorthand_property_identifier_pattern" } }); - for (const s of shorts) names.push(s.text()); - // Aliased bindings (pair_pattern) => use the identifier name (value) - const pairs = decl.findAll({ rule: { kind: "pair_pattern" } }); - for (const pair of pairs) { - const aliasId = pair.find({ rule: { kind: "identifier" } }); - if (aliasId) names.push(aliasId.text()); - } - - const usedTexts: string[] = []; - for (const s of shorts) if (isBindingUsed(s.text())) usedTexts.push(s.text()); - for (const pair of pairs) { - const aliasId = pair.find({ rule: { kind: "identifier" } }); - if (aliasId && isBindingUsed(aliasId.text())) usedTexts.push(pair.text()); // keep full spec text - } - - if (usedTexts.length === 0) { - linesToRemove.push(decl.parent().range()); - } else if (usedTexts.length !== names.length) { - const objPat = decl.find({ rule: { kind: "object_pattern" } }); - if (objPat) edits.push(objPat.replace(`{ ${usedTexts.join(", ")} }`)); - } - } - } - - if (edits.length < 0 || linesToRemove.length < 0) return null; - - // Apply edits, remove whole lines ranges, then normalize leading whitespace only - let source = rootNode.commitEdits(edits); - source = removeLines(source, linesToRemove); - source = source.replace(/^\n+/, ""); - return source; + const rootNode = root.root(); + const edits: Edit[] = []; + + const isBindingUsed = (name: string): boolean => { + const refs = rootNode.findAll({ rule: { pattern: name } }); + // Heuristic: declaration counts as one; any other usage yields > 1 + return refs.length > 1; + }; + + const linesToRemove: Range[] = []; + + // 1) ES Module imports: import ... from 'node:url' + // @ts-ignore - ast-grep types vs jssg types + const esmImports = getNodeImportStatements(root, "url"); + + for (const imp of esmImports) { + const clause = imp.find({ rule: { kind: "import_clause" } }); + let removed = false; + if (clause) { + const nsId = clause.find({ rule: { kind: "namespace_import" } })?.find({ rule: { kind: "identifier" } }); + if (nsId && !isBindingUsed(nsId.text())) { + linesToRemove.push(imp.range()); + removed = true; + } + if (removed) continue; + + const specs = clause.findAll({ rule: { kind: "import_specifier" } }); + + if (specs.length === 0 && !nsId) { + const defaultId = clause.find({ rule: { kind: "identifier" } }); + if (defaultId && !isBindingUsed(defaultId.text())) { + linesToRemove.push(imp.range()); + removed = true; + } + if (removed) continue; + } + + if (specs.length > 0) { + const keepTexts: string[] = []; + for (const spec of specs) { + const text = spec.text().trim(); + const bindingName = text.includes(" as ") ? text.split(/\s+as\s+/)[1] : text; + if (bindingName && isBindingUsed(bindingName)) keepTexts.push(text); + } + if (keepTexts.length === 0) { + linesToRemove.push(imp.range()); + } else if (keepTexts.length !== specs.length) { + const namedImportsNode = clause.find({ rule: { kind: "named_imports" } }); + if (namedImportsNode) edits.push(namedImportsNode.replace(`{ ${keepTexts.join(", ")} }`)); + } + } + } + } + + // 2) CommonJS requires: const ... = require('node:url') + // @ts-ignore - ast-grep types vs jssg types + const requireDecls = getNodeRequireCalls(root, "url"); + + for (const decl of requireDecls) { + const id = decl.find({ rule: { kind: "identifier" } }); + const hasObjectPattern = decl.find({ rule: { kind: "object_pattern" } }); + + if (id && !hasObjectPattern) { + if (!isBindingUsed(id.text())) linesToRemove.push(decl.parent().range()); + continue; + } + + if (hasObjectPattern) { + const names: string[] = []; + const shorts = decl.findAll({ rule: { kind: "shorthand_property_identifier_pattern" } }); + for (const s of shorts) names.push(s.text()); + const pairs = decl.findAll({ rule: { kind: "pair_pattern" } }); + for (const pair of pairs) { + const aliasId = pair.find({ rule: { kind: "identifier" } }); + if (aliasId) names.push(aliasId.text()); + } + + const usedTexts: string[] = []; + for (const s of shorts) if (isBindingUsed(s.text())) usedTexts.push(s.text()); + for (const pair of pairs) { + const aliasId = pair.find({ rule: { kind: "identifier" } }); + if (aliasId && isBindingUsed(aliasId.text())) usedTexts.push(pair.text()); + } + + if (usedTexts.length === 0) { + linesToRemove.push(decl.parent().range()); + } else if (usedTexts.length !== names.length) { + const objPat = decl.find({ rule: { kind: "object_pattern" } }); + if (objPat) edits.push(objPat.replace(`{ ${usedTexts.join(", ")} }`)); + } + } + } + + if (edits.length === 0 && linesToRemove.length === 0) return null; + + let source = rootNode.commitEdits(edits); + + source = removeLines(source, linesToRemove.map(range => ({ + start: { line: range.start.line, column: 0, index: 0 }, + end: { line: range.end.line + 1, column: 0, index: 0 } + }))); + + return source; }; - From b289b2d74b96f5d09f0ac785eb03424b0a755147 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 14:36:27 +0200 Subject: [PATCH 09/52] use resolve utility --- .../node-url-to-whatwg-url/src/url-format.ts | 25 +++++++++------- .../node-url-to-whatwg-url/src/url-parse.ts | 29 ++++++++++--------- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index 55f7592d..fc900b83 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -2,6 +2,7 @@ import type { SgRoot, Edit, SgNode } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; +import { resolveBindingPath } from "@nodejs/codemod-utils/ast-grep/resolve-binding-path"; /** * Get the literal text value of a node, if it exists. @@ -169,20 +170,24 @@ export default function transform(root: SgRoot): string | null { if (!hasNodeUrlImport) return null; // Look for various ways format can be referenced - // todo(@AugustinMauroy): use resolveBindingPath - const patterns = [ - "url.format($OPTIONS)", - "nodeUrl.format($OPTIONS)", - "format($OPTIONS)", - "urlFormat($OPTIONS)" - ]; + // Build patterns using resolveBindingPath for both import and require forms + // @ts-ignore - type difference between jssg and ast-grep wrappers + const importNodes = getNodeImportStatements(root, "url"); + // @ts-ignore - type difference between jssg and ast-grep wrappers + const requireNodes = getNodeRequireCalls(root, "url"); + const patterns = new Set(); + + for (const node of [...importNodes, ...requireNodes]) { + // @ts-ignore - helper accepts ast-grep SgNode; runtime compatible + const binding = resolveBindingPath(node, "$.format"); + if (!binding) continue; + patterns.add(`${binding}($OPTIONS)`); + } for (const pattern of patterns) { const calls = rootNode.findAll({ rule: { pattern } }); - if (calls.length) { - urlFormatToUrlToString(calls, edits); - } + if (calls.length) urlFormatToUrlToString(calls, edits); } if (!edits.length) return null; diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index c22d415b..9a5316cf 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -1,7 +1,8 @@ -import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; -import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; +import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; +import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; +import { resolveBindingPath } from "@nodejs/codemod-utils/ast-grep/resolve-binding-path"; /** * Transforms `url.parse` usage to `new URL()`. @@ -28,17 +29,19 @@ export default function transform(root: SgRoot): string | null { if (!hasNodeUrlImport) return null; - // 1) Replace parse calls with new URL() - // todo(@AugustinMauroy): use resolveBindingPath - const parseCallPatterns = [ - // member calls on typical identifiers - "url.parse($ARG)", - "nodeUrl.parse($ARG)", - // bare identifier (named import) - "parse($ARG)", - // common alias used in tests - "urlParse($ARG)" - ]; + // 1) Replace parse calls with new URL() using binding-aware patterns + // @ts-ignore - type difference between jssg and ast-grep wrappers + const importNodes = getNodeImportStatements(root, "url"); + // @ts-ignore - type difference between jssg and ast-grep wrappers + const requireNodes = getNodeRequireCalls(root, "url"); + const parseCallPatterns = new Set(); + + for (const node of [...importNodes, ...requireNodes]) { + // @ts-ignore resolve across wrappers + const binding = resolveBindingPath(node, "$.parse"); + if (!binding) continue; + parseCallPatterns.add(`${binding}($ARG)`); + } for (const pattern of parseCallPatterns) { const calls = rootNode.findAll({ rule: { pattern } }); From 50193e029925874eb2f05bc0e8665330283b969a Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 20:49:51 +0200 Subject: [PATCH 10/52] add more supported case for `url-format` --- .../node-url-to-whatwg-url/src/url-format.ts | 213 ++++++++++++++---- .../tests/url-format/expected/file-1.js | 6 + .../tests/url-format/input/file-1.js | 19 +- 3 files changed, 189 insertions(+), 49 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index fc900b83..93de6642 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -10,6 +10,7 @@ import { resolveBindingPath } from "@nodejs/codemod-utils/ast-grep/resolve-bindi * @returns The literal text value, or undefined if not found */ const getLiteralText = (node: SgNode | null | undefined): string | undefined => { + if (!node) return undefined; const kind = node.kind(); if (kind === "string") { @@ -23,8 +24,8 @@ const getLiteralText = (node: SgNode | null | undefined): string | undefined /** * Transforms url.format() calls to new URL().toString() - * @param callNode The AST node representing the url.format() call - * @returns The transformed code + * @param callNode The AST nodes representing the url.format() calls + * @param edits The edits collector */ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { for (const call of callNode) { @@ -35,18 +36,42 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { const objectNode = optionsMatch.find({ rule: { kind: "object" } }); if (!objectNode) continue; + type V = { literal: true; text: string } | { literal: false; code: string }; const urlState: { - protocol?: string; - auth?: string; // user:pass - host?: string; // host:port - hostname?: string; - port?: string; - pathname?: string; - search?: string; // ?a=b - hash?: string; // #frag + protocol?: V; + auth?: V; // user:pass + host?: V; // host:port + hostname?: V; + port?: V; + pathname?: V; + search?: V; // ?a=b + hash?: V; // #frag queryParams?: Array<[string, string]>; } = {}; + const getValue = (pair: SgNode): V | undefined => { + // string/number/bool + const litNode = pair.find({ + rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] }, + }); + const lit = getLiteralText(litNode); + if (lit !== undefined) return { literal: true, text: lit }; + + // identifier value + const idNode = pair.find({ rule: { kind: "identifier" } }); + if (idNode) return { literal: false, code: idNode.text() }; + + // shorthand property + const shorthand = pair.find({ rule: { kind: "shorthand_property_identifier" } }); + if (shorthand) return { literal: false, code: shorthand.text() }; + + // template string value + const template = pair.find({ rule: { kind: "template_string" } }); + if (template) return { literal: false, code: template.text() }; + + return undefined; + }; + const pairs = objectNode.findAll({ rule: { kind: "pair" } }); for (const pair of pairs) { @@ -62,7 +87,9 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { const list: Array<[string, string]> = []; for (const qp of qpairs) { const qkeyNode = qp.find({ rule: { kind: "property_identifier" } }); - const qvalLiteral = getLiteralText(qp.find({ rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] } })); + const qvalLiteral = getLiteralText( + qp.find({ rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] } }), + ); if (qkeyNode && qvalLiteral !== undefined) list.push([qkeyNode.text(), qvalLiteral]); } urlState.queryParams = list; @@ -70,42 +97,42 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { continue; } - // value might be string/number/bool - const valueLiteral = getLiteralText(pair.find({ rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] } })); - if (valueLiteral === undefined) continue; + // value might be literal or identifier/shorthand/template + const val = getValue(pair); + if (!val) continue; switch (key) { case "protocol": { - // normalize without trailing ':' - urlState.protocol = valueLiteral.replace(/:$/, ""); + if (val.literal) urlState.protocol = { literal: true, text: val.text.replace(/:$/, "") }; + else urlState.protocol = val; break; } case "auth": { - urlState.auth = valueLiteral; // 'user:pass' + urlState.auth = val; break; } case "host": { - urlState.host = valueLiteral; // 'example.com:8080' + urlState.host = val; break; } case "hostname": { - urlState.hostname = valueLiteral; + urlState.hostname = val; break; } case "port": { - urlState.port = valueLiteral; + urlState.port = val; break; } case "pathname": { - urlState.pathname = valueLiteral; + urlState.pathname = val; break; } case "search": { - urlState.search = valueLiteral; + urlState.search = val; break; } case "hash": { - urlState.hash = valueLiteral; + urlState.hash = val; break; } default: @@ -114,32 +141,125 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { } } - const proto = urlState.protocol ?? ""; - const auth = urlState.auth ? `${urlState.auth}@` : ""; - let host = urlState.host; - if (!host) { - if (urlState.hostname && urlState.port) host = `${urlState.hostname}:${urlState.port}`; - else if (urlState.hostname) host = urlState.hostname; - else host = ""; + // Also handle shorthand properties like `{ search }` + const shorthands = objectNode.findAll({ rule: { kind: "shorthand_property_identifier" } }); + for (const sh of shorthands) { + const name = sh.text(); + const v: V = { literal: false, code: name }; + switch (name) { + case "protocol": + urlState.protocol = v; + break; + case "auth": + urlState.auth = v; + break; + case "host": + urlState.host = v; + break; + case "hostname": + urlState.hostname = v; + break; + case "port": + urlState.port = v; + break; + case "pathname": + urlState.pathname = v; + break; + case "search": + urlState.search = v; + break; + case "hash": + urlState.hash = v; + break; + default: + break; + } } - let pathname = urlState.pathname ?? ""; - if (pathname && !pathname.startsWith("/")) pathname = `/${pathname}`; + // Build output segments + type Seg = { type: "lit"; text: string } | { type: "expr"; code: string }; + const segs: Seg[] = []; + const pushVal = (v?: V) => { + if (!v) return; + if (v.literal) { + if (v.text) segs.push({ type: "lit", text: v.text }); + } else { + // v is the non-literal branch here + segs.push({ type: "expr", code: (v as Extract).code }); + } + }; - let search = urlState.search ?? ""; - if (!search && urlState.queryParams && urlState.queryParams.length > 0) { + // protocol:// + if (urlState.protocol) { + pushVal(urlState.protocol); + segs.push({ type: "lit", text: "://" }); + } + + // auth@ + if (urlState.auth) { + pushVal(urlState.auth); + segs.push({ type: "lit", text: "@" }); + } + + // host or hostname[:port] + if (urlState.host) { + pushVal(urlState.host); + } else { + if (urlState.hostname) pushVal(urlState.hostname); + if (urlState.port) { + if (urlState.hostname) segs.push({ type: "lit", text: ":" }); + pushVal(urlState.port); + } + } + + // pathname + if (urlState.pathname) { + const p = urlState.pathname; + if (p.literal) { + const text = p.text && !p.text.startsWith("/") ? `/${p.text}` : p.text; + if (text) segs.push({ type: "lit", text }); + } else { + pushVal(p); + } + } + + // search or build from query + if (urlState.search) { + const s = urlState.search; + if (s.literal) { + const text = s.text ? (s.text.startsWith("?") ? s.text : `?${s.text}`) : ""; + if (text) segs.push({ type: "lit", text }); + } else { + pushVal(s); + } + } else if (urlState.queryParams && urlState.queryParams.length > 0) { const qs = urlState.queryParams.map(([k, v]) => `${k}=${v}`).join("&"); - search = `?${qs}`; + if (qs) segs.push({ type: "lit", text: `?${qs}` }); + } + + // hash + if (urlState.hash) { + const h = urlState.hash; + if (h.literal) { + const text = h.text ? (h.text.startsWith("#") ? h.text : `#${h.text}`) : ""; + if (text) segs.push({ type: "lit", text }); + } else { + pushVal(h); + } } - if (search && !search.startsWith("?")) search = `?${search}`; - let hash = urlState.hash ?? ""; - if (hash && !hash.startsWith("#")) hash = `#${hash}`; + if (!segs.length) continue; - const base = proto ? `${proto}://` : ""; - const urlString = `${base}${auth}${host}${pathname}${search}${hash}`; + const hasExpr = segs.some((s) => s.type === "expr"); + let finalExpr: string; + if (hasExpr) { + const esc = (s: string) => s.replace(/`/g, "\\`").replace(/\\\$/g, "\\\\$").replace(/\$\{/g, "\\${"); + finalExpr = `\`${segs.map((s) => (s.type === "lit" ? esc(s.text) : `\${${s.code}}`)).join("")}\``; + } else { + finalExpr = `'${segs.map((s) => (s.type === "lit" ? s.text : "")).join("")}'`; + } - const replacement = `new URL('${urlString}').toString()`; + const replacement = `new URL(${finalExpr}).toString()`; edits.push(call.replace(replacement)); } } @@ -169,8 +289,7 @@ export default function transform(root: SgRoot): string | null { if (!hasNodeUrlImport) return null; - // Look for various ways format can be referenced - // Build patterns using resolveBindingPath for both import and require forms + // Look for various ways format can be referenced; build binding-aware patterns // @ts-ignore - type difference between jssg and ast-grep wrappers const importNodes = getNodeImportStatements(root, "url"); // @ts-ignore - type difference between jssg and ast-grep wrappers @@ -180,10 +299,14 @@ export default function transform(root: SgRoot): string | null { for (const node of [...importNodes, ...requireNodes]) { // @ts-ignore - helper accepts ast-grep SgNode; runtime compatible const binding = resolveBindingPath(node, "$.format"); - if (!binding) continue; - patterns.add(`${binding}($OPTIONS)`); + if (binding) patterns.add(`${binding}($OPTIONS)`); } + // Fallbacks for common names and tests + ["url.format($OPTIONS)", "nodeUrl.format($OPTIONS)", "format($OPTIONS)", "urlFormat($OPTIONS)"].forEach((p) => + patterns.add(p), + ); + for (const pattern of patterns) { const calls = rootNode.findAll({ rule: { pattern } }); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-1.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-1.js index dc398267..b6f28419 100644 --- a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-1.js +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-1.js @@ -1,3 +1,9 @@ const url = require('node:url'); const str = new URL('https://example.com/some/path?page=1').toString(); + +const foo = 'https'; + +const search = '?page=1'; + +const str2 = new URL(`${foo}://example.com/some/path${search}`).toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-1.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-1.js index e95d6e40..c33c1224 100644 --- a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-1.js +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-1.js @@ -1,8 +1,19 @@ const url = require('node:url'); const str = url.format({ - protocol: 'https', - hostname: 'example.com', - pathname: '/some/path', - search: '?page=1' + protocol: 'https', + hostname: 'example.com', + pathname: '/some/path', + search: '?page=1' +}); + +const foo = 'https'; + +const search = '?page=1'; + +const str2 = url.format({ + protocol: foo, + hostname: 'example.com', + pathname: '/some/path', + search }); From 264a1f2ad933a7618334dc5ad4dd0ba8431661c5 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 20:57:39 +0200 Subject: [PATCH 11/52] test: add more case --- .../tests/url-format/expected/file-4.mjs | 3 +++ .../tests/url-format/expected/file-5.mjs | 3 +++ .../tests/url-format/expected/file-6.js | 3 +++ .../tests/url-format/expected/file-7.js | 6 ++++++ .../tests/url-format/expected/file-8.js | 3 +++ .../tests/url-format/input/file-4.mjs | 7 +++++++ .../tests/url-format/input/file-5.mjs | 8 ++++++++ .../tests/url-format/input/file-6.js | 8 ++++++++ .../tests/url-format/input/file-7.js | 11 +++++++++++ .../tests/url-format/input/file-8.js | 8 ++++++++ 10 files changed, 60 insertions(+) create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-4.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-5.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-6.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-7.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-8.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-4.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-5.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-6.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-7.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-8.js diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-4.mjs b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-4.mjs new file mode 100644 index 00000000..56dc93a6 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-4.mjs @@ -0,0 +1,3 @@ +import url from 'node:url'; + +const a = new URL('https://example.com/esm-default').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-5.mjs b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-5.mjs new file mode 100644 index 00000000..8fafd63c --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-5.mjs @@ -0,0 +1,3 @@ +import { format as fmt } from 'node:url'; + +const b = new URL('http://example.org:8080/path?q=1').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-6.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-6.js new file mode 100644 index 00000000..54c9109f --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-6.js @@ -0,0 +1,3 @@ +const { format } = require('node:url'); + +const c = new URL('https://user:pass@example.com/has-auth').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-7.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-7.js new file mode 100644 index 00000000..65210f4e --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-7.js @@ -0,0 +1,6 @@ +const { format } = require('node:url'); + +const search = 'page=2'; +const proto = 'https:'; + +const d = new URL(`${proto}://example.com:443/norm${search}`).toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-8.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-8.js new file mode 100644 index 00000000..8e28a1fc --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-8.js @@ -0,0 +1,3 @@ +const { format } = require('node:url'); + +const e = new URL('https://example.com:3000/no-leading-slash').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-4.mjs b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-4.mjs new file mode 100644 index 00000000..d6a5b131 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-4.mjs @@ -0,0 +1,7 @@ +import url from 'node:url'; + +const a = url.format({ + protocol: 'https', + hostname: 'example.com', + pathname: '/esm-default', +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-5.mjs b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-5.mjs new file mode 100644 index 00000000..c6905fbf --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-5.mjs @@ -0,0 +1,8 @@ +import { format as fmt } from 'node:url'; + +const b = fmt({ + protocol: 'http', + host: 'example.org:8080', + pathname: 'path', + search: 'q=1', +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-6.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-6.js new file mode 100644 index 00000000..1477a48a --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-6.js @@ -0,0 +1,8 @@ +const { format } = require('node:url'); + +const c = format({ + protocol: 'https', + auth: 'user:pass', + hostname: 'example.com', + pathname: '/has-auth', +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-7.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-7.js new file mode 100644 index 00000000..862906a9 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-7.js @@ -0,0 +1,11 @@ +const { format } = require('node:url'); + +const search = 'page=2'; +const proto = 'https:'; + +const d = format({ + protocol: proto, + host: 'example.com:443', + pathname: '/norm', + search, +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-8.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-8.js new file mode 100644 index 00000000..7c6ff694 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-8.js @@ -0,0 +1,8 @@ +const { format } = require('node:url'); + +const e = format({ + protocol: 'https', + hostname: 'example.com', + port: '3000', + pathname: 'no-leading-slash', +}); From c60c073a639f75218d817422d1a04388cf2426c4 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 21:38:09 +0200 Subject: [PATCH 12/52] add support of semicolon --- recipes/node-url-to-whatwg-url/src/url-format.ts | 6 ++++-- recipes/node-url-to-whatwg-url/src/url-parse.ts | 14 ++++++++------ .../tests/url-format/expected/file-10.js | 3 +++ .../tests/url-format/expected/file-9.js | 3 +++ .../tests/url-format/input/file-10.js | 9 +++++++++ .../tests/url-format/input/file-9.js | 8 ++++++++ .../tests/url-parse/expected/file-9.js | 12 ++++++++++++ .../tests/url-parse/input/file-9.js | 12 ++++++++++++ 8 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-10.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-9.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-10.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-9.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-9.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/file-9.js diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index 93de6642..027e4ea2 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -259,8 +259,10 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { finalExpr = `'${segs.map((s) => (s.type === "lit" ? s.text : "")).join("")}'`; } - const replacement = `new URL(${finalExpr}).toString()`; - edits.push(call.replace(replacement)); + // Include semicolon if original statement had one + const hadSemi = /;\s*$/.test(call.text()); + const replacement = `new URL(${finalExpr}).toString()${hadSemi ? ';' : ''}`; + edits.push(call.replace(replacement)); } } diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index 9a5316cf..f62ebf56 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -75,8 +75,9 @@ export default function transform(root: SgRoot): string | null { for (const node of authDestructures) { const base = node.getMatch("OBJ"); if (!base) continue; - - const replacement = `const auth = \`\${${base.text()}.username}:\${${base.text()}.password}\`;`; + const hadSemi = /;\s*$/.test(node.text()); + const name = base.text(); + const replacement = `const auth = \`${'${'}${name}.username${'}'}:${'${'}${name}.password${'}'}\`${hadSemi ? ';' : ''}`; edits.push(node.replace(replacement)); } @@ -95,8 +96,9 @@ export default function transform(root: SgRoot): string | null { for (const node of pathDestructures) { const base = node.getMatch("OBJ"); if (!base) continue; - - const replacement = `const path = \`\${${base.text()}.pathname}\${${base.text()}.search}\`;`; + const hadSemi = /;\s*$/.test(node.text()); + const name = base.text(); + const replacement = `const path = \`${'${'}${name}.pathname${'}'}${'${'}${name}.search${'}'}\`${hadSemi ? ';' : ''}`; edits.push(node.replace(replacement)); } @@ -115,8 +117,8 @@ export default function transform(root: SgRoot): string | null { for (const node of hostnameDestructures) { const base = node.getMatch("OBJ"); if (!base) continue; - - const replacement = `const hostname = ${base.text()}.hostname.replace(/^\\[|\\]$/, '');`; + const hadSemi = /;\s*$/.test(node.text()); + const replacement = `const hostname = ${base.text()}.hostname.replace(/^\\[|\\]$/, '')${hadSemi ? ';' : ''}`; edits.push(node.replace(replacement)); } diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-10.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-10.js new file mode 100644 index 00000000..5f942c25 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-10.js @@ -0,0 +1,3 @@ +const { format } = require('node:url') + +const a = new URL('https://example.com:8080/p?page=1&format=json#frag').toString() diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-9.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-9.js new file mode 100644 index 00000000..18a33bea --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-9.js @@ -0,0 +1,3 @@ +const url = require('node:url') + +const str = new URL('https://example.com/some/path?page=1').toString() diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-10.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-10.js new file mode 100644 index 00000000..f69be481 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-10.js @@ -0,0 +1,9 @@ +const { format } = require('node:url') + +const a = format({ + protocol: 'https:', + host: 'example.com:8080', + pathname: 'p', + query: { page: '1', format: 'json' }, + hash: 'frag', +}) diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-9.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-9.js new file mode 100644 index 00000000..83897892 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-9.js @@ -0,0 +1,8 @@ +const url = require('node:url') + +const str = url.format({ + protocol: 'https', + hostname: 'example.com', + pathname: '/some/path', + search: '?page=1' +}) diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-9.js b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-9.js new file mode 100644 index 00000000..c861e91b --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-9.js @@ -0,0 +1,12 @@ +const url = require('node:url') + +const myURL = new URL('https://example.com/path?query=string#hash') + +const auth = `${myURL.username}:${myURL.password}` +const urlAuth = `${myURL.username}:${myURL.password}` + +const path = `${myURL.pathname}${myURL.search}` +const urlPath = `${myURL.pathname}${myURL.search}` + +const hostname = myURL.hostname.replace(/^\[|\]$/, '') +const urlHostname = myURL.hostname.replace(/^\[|\]$/, '') diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-9.js b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-9.js new file mode 100644 index 00000000..66f4ac90 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-9.js @@ -0,0 +1,12 @@ +const url = require('node:url') + +const myURL = url.parse('https://example.com/path?query=string#hash') + +const { auth } = myURL +const urlAuth = myURL.auth + +const { path } = myURL +const urlPath = myURL.path + +const { hostname } = myURL +const urlHostname = myURL.hostname From c5ce549c01af0aa713cefc6107db617a376e2f02 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 21:45:14 +0200 Subject: [PATCH 13/52] Update package.json --- recipes/node-url-to-whatwg-url/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/recipes/node-url-to-whatwg-url/package.json b/recipes/node-url-to-whatwg-url/package.json index dfa865bb..670200e0 100644 --- a/recipes/node-url-to-whatwg-url/package.json +++ b/recipes/node-url-to-whatwg-url/package.json @@ -4,7 +4,7 @@ "description": "Handle DEPDEP0116 via transforming `url.parse` to `new URL()`", "type": "module", "scripts": { - "test": "node --run test:import-process && node --run test:url-format && node --run test:url-parse", + "test": "node --run test:import-process; node --run test:url-format; node --run test:url-parse", "test:import-process": "npx codemod@next jssg test -l typescript ./src/import-process.ts ./tests/ --filter import-process", "test:url-format": "npx codemod@next jssg test -l typescript ./src/url-format.ts ./tests/ --filter url-format", "test:url-parse": "npx codemod@next jssg test -l typescript ./src/url-parse.ts ./tests/ --filter url-parse" From cedf9bc11dbb1707646c0f7909c20eccf4a1bcbc Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 21:48:30 +0200 Subject: [PATCH 14/52] Revert "Update package.json" This reverts commit c5ce549c01af0aa713cefc6107db617a376e2f02. --- recipes/node-url-to-whatwg-url/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/recipes/node-url-to-whatwg-url/package.json b/recipes/node-url-to-whatwg-url/package.json index 670200e0..dfa865bb 100644 --- a/recipes/node-url-to-whatwg-url/package.json +++ b/recipes/node-url-to-whatwg-url/package.json @@ -4,7 +4,7 @@ "description": "Handle DEPDEP0116 via transforming `url.parse` to `new URL()`", "type": "module", "scripts": { - "test": "node --run test:import-process; node --run test:url-format; node --run test:url-parse", + "test": "node --run test:import-process && node --run test:url-format && node --run test:url-parse", "test:import-process": "npx codemod@next jssg test -l typescript ./src/import-process.ts ./tests/ --filter import-process", "test:url-format": "npx codemod@next jssg test -l typescript ./src/url-format.ts ./tests/ --filter url-format", "test:url-parse": "npx codemod@next jssg test -l typescript ./src/url-parse.ts ./tests/ --filter url-parse" From 0adc0238aee115b528ac2725a0f9d32fab46a5ce Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 13 Aug 2025 00:23:06 +0200 Subject: [PATCH 15/52] update --- recipes/node-url-to-whatwg-url/README.md | 19 +++++++++++++------ recipes/node-url-to-whatwg-url/codemod.yaml | 2 +- recipes/node-url-to-whatwg-url/package.json | 8 ++++---- recipes/node-url-to-whatwg-url/workflow.yaml | 15 +++++++++------ 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/README.md b/recipes/node-url-to-whatwg-url/README.md index 3f531ad2..a3b98a54 100644 --- a/recipes/node-url-to-whatwg-url/README.md +++ b/recipes/node-url-to-whatwg-url/README.md @@ -6,22 +6,26 @@ See [DEP0116](https://nodejs.org/api/deprecations.html#DEP0116). ## Example +### `url.parse` to `new URL()` -**`url.parse` to `new URL()`** +**Before:** ```js -// Before const url = require('node:url'); const myUrl = new url.URL('https://example.com'); const urlAuth = legacyURL.auth; -// After +``` + +**After:** +```js const myUrl = new URL('https://example.com'); const urlAuth = `${myUrl.username}:${myUrl.password}`; ``` -**`url.format` to `myUrl.toString()`** +### `url.format` to `myUrl.toString() + +**Before:** ```js -// Before const url = require('node:url'); url.format({ @@ -33,7 +37,10 @@ url.format({ format: 'json', }, }); -// After +``` + +**After:** +```js const myUrl = new URL('https://example.com/some/path?page=1&format=json').toString(); ``` diff --git a/recipes/node-url-to-whatwg-url/codemod.yaml b/recipes/node-url-to-whatwg-url/codemod.yaml index aa86d9f9..15a429b8 100644 --- a/recipes/node-url-to-whatwg-url/codemod.yaml +++ b/recipes/node-url-to-whatwg-url/codemod.yaml @@ -1,7 +1,7 @@ schema_version: "1.0" name: "@nodejs/node-url-to-whatwg-url" version: 1.0.0 -description: Handle DEPDEP0116 via transforming `url.parse` to `new URL()` +description: Handle DEP0116 via transforming `url.parse` to `new URL()` author: Augustin Mauroy license: MIT workflow: workflow.yaml diff --git a/recipes/node-url-to-whatwg-url/package.json b/recipes/node-url-to-whatwg-url/package.json index dfa865bb..9c149108 100644 --- a/recipes/node-url-to-whatwg-url/package.json +++ b/recipes/node-url-to-whatwg-url/package.json @@ -1,13 +1,13 @@ { "name": "@nodejs/node-url-to-whatwg-url", "version": "1.0.0", - "description": "Handle DEPDEP0116 via transforming `url.parse` to `new URL()`", + "description": "Handle DEP0116 via transforming `url.parse` to `new URL()`", "type": "module", "scripts": { "test": "node --run test:import-process && node --run test:url-format && node --run test:url-parse", - "test:import-process": "npx codemod@next jssg test -l typescript ./src/import-process.ts ./tests/ --filter import-process", - "test:url-format": "npx codemod@next jssg test -l typescript ./src/url-format.ts ./tests/ --filter url-format", - "test:url-parse": "npx codemod@next jssg test -l typescript ./src/url-parse.ts ./tests/ --filter url-parse" + "test:import-process": "npx codemod jssg test -l typescript ./src/import-process.ts ./tests/ --filter import-process", + "test:url-format": "npx codemod jssg test -l typescript ./src/url-format.ts ./tests/ --filter url-format", + "test:url-parse": "npx codemod jssg test -l typescript ./src/url-parse.ts ./tests/ --filter url-parse" }, "repository": { "type": "git", diff --git a/recipes/node-url-to-whatwg-url/workflow.yaml b/recipes/node-url-to-whatwg-url/workflow.yaml index f1b03b02..2bc23591 100644 --- a/recipes/node-url-to-whatwg-url/workflow.yaml +++ b/recipes/node-url-to-whatwg-url/workflow.yaml @@ -1,4 +1,7 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod-com/codemod/refs/heads/main/schemas/workflow.json + version: "1" + nodes: - id: apply-transforms name: Apply AST Transformations @@ -11,11 +14,11 @@ nodes: js_file: src/url-parse.ts base_path: . include: + - "**/*.cjs" + - "**/*.cts" - "**/*.js" - "**/*.jsx" - "**/*.mjs" - - "**/*.cjs" - - "**/*.cts" - "**/*.mts" - "**/*.ts" - "**/*.tsx" @@ -27,11 +30,11 @@ nodes: js_file: src/url-format.ts base_path: . include: + - "**/*.cjs" + - "**/*.cts" - "**/*.js" - "**/*.jsx" - "**/*.mjs" - - "**/*.cjs" - - "**/*.cts" - "**/*.mts" - "**/*.ts" - "**/*.tsx" @@ -43,11 +46,11 @@ nodes: js_file: src/import-process.ts base_path: . include: + - "**/*.cjs" + - "**/*.cts" - "**/*.js" - "**/*.jsx" - "**/*.mjs" - - "**/*.cjs" - - "**/*.cts" - "**/*.mts" - "**/*.ts" - "**/*.tsx" From 599fff46e5dccad61ca0a6afe07097b1e435e87e Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sun, 3 Aug 2025 14:50:42 +0200 Subject: [PATCH 16/52] fea(`node-url-to-whatwg-url`): scaffold codemod --- recipes/node-url-to-whatwg-url/README.md | 57 +++++++++++++++++++ recipes/node-url-to-whatwg-url/codemod.yaml | 21 +++++++ recipes/node-url-to-whatwg-url/package.json | 24 ++++++++ .../src/import-process.ts | 17 ++++++ .../node-url-to-whatwg-url/src/url-format.ts | 15 +++++ .../node-url-to-whatwg-url/src/url-parse.ts | 20 +++++++ .../tests/import-process/expected/.gitkeep | 0 .../tests/import-process/input/.gitkeep | 0 .../tests/url-format/expected/.gitkeep | 0 .../tests/url-format/input/.gitkeep | 0 .../tests/url-parse/expected/.gitkeep | 0 .../tests/url-parse/input/.gitkeep | 0 recipes/node-url-to-whatwg-url/workflow.yaml | 55 ++++++++++++++++++ 13 files changed, 209 insertions(+) create mode 100644 recipes/node-url-to-whatwg-url/README.md create mode 100644 recipes/node-url-to-whatwg-url/codemod.yaml create mode 100644 recipes/node-url-to-whatwg-url/package.json create mode 100644 recipes/node-url-to-whatwg-url/src/import-process.ts create mode 100644 recipes/node-url-to-whatwg-url/src/url-format.ts create mode 100644 recipes/node-url-to-whatwg-url/src/url-parse.ts create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/expected/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/input/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/workflow.yaml diff --git a/recipes/node-url-to-whatwg-url/README.md b/recipes/node-url-to-whatwg-url/README.md new file mode 100644 index 00000000..d7688f56 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/README.md @@ -0,0 +1,57 @@ +# Node.js URL to WHATWG URL + +This recipe converts Node.js `url` module usage to the WHATWG URL API. It modifies code that uses the legacy `url` module to use the modern `URL` class instead. + +See [DEP0116](https://nodejs.org/api/deprecations.html#DEP0116). + +## Example + + +**`url.parse` to `new URL()`** +```js +// Before +const url = require('node:url'); +const myUrl = new url.URL('https://example.com'); +const urlAuth = legacyURL.auth; +// After +const myUrl = new URL('https://example.com'); +const urlAuth = `${myUrl.username}:${myUrl.password}`; +``` + +**`url.format` to `myUrl.toString()`** +```js +// Before +const url = require('node:url'); +url.format({ + protocol: 'https', + hostname: 'example.com', + pathname: '/some/path', + query: { + page: 1, + format: 'json', + }, +}); +// After +const myUrl = new URL('https://example.com/some/path?page=1&format=json'); +myUrl.toString(); +``` + +## Caveats + +The [`url.resolve`](https://nodejs.org/api/url.html#urlresolvefrom-to) method is not directly translatable to the WHATWG URL API. You may need to implement custom logic to handle URL resolution in your application. + +```js +function resolve(from, to) { + const resolvedUrl = new URL(to, new URL(from, 'resolve://')); + if (resolvedUrl.protocol === 'resolve:') { + // `from` is a relative URL. + const { pathname, search, hash } = resolvedUrl; + return pathname + search + hash; + } + return resolvedUrl.toString(); +} + +resolve('/one/two/three', 'four'); // '/one/two/four' +resolve('http://example.com/', '/one'); // 'http://example.com/one' +resolve('http://example.com/one', '/two'); // 'http://example.com/two' +``` diff --git a/recipes/node-url-to-whatwg-url/codemod.yaml b/recipes/node-url-to-whatwg-url/codemod.yaml new file mode 100644 index 00000000..aa86d9f9 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/codemod.yaml @@ -0,0 +1,21 @@ +schema_version: "1.0" +name: "@nodejs/node-url-to-whatwg-url" +version: 1.0.0 +description: Handle DEPDEP0116 via transforming `url.parse` to `new URL()` +author: Augustin Mauroy +license: MIT +workflow: workflow.yaml +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + +registry: + access: public + visibility: public diff --git a/recipes/node-url-to-whatwg-url/package.json b/recipes/node-url-to-whatwg-url/package.json new file mode 100644 index 00000000..15ff9d4c --- /dev/null +++ b/recipes/node-url-to-whatwg-url/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nodejs/node-url-to-whatwg-url", + "version": "1.0.0", + "description": "Handle DEPDEP0116 via transforming `url.parse` to `new URL()`", + "type": "module", + "scripts": { + "test": "node --run test:import-process.js && node --run test:url-format.js && node --run test:url-parse.js", + "test:import-process": "npx codemod@next jssg test -l typescript ./src/import-process.ts ./tests/import-process", + "test:url-format": "npx codemod@next jssg test -l typescript ./src/url-format.ts ./tests/url-format", + "test:url-parse": "npx codemod@next jssg test -l typescript ./src/url-parse.ts ./tests/url-parse" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodejs/userland-migrations.git", + "directory": "recipes/node-url-to-whatwg-url", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Augustin Mauroy", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations/tree/main/node-url-to-whatwg-url#readme", + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.3" + } +} diff --git a/recipes/node-url-to-whatwg-url/src/import-process.ts b/recipes/node-url-to-whatwg-url/src/import-process.ts new file mode 100644 index 00000000..8cf9b252 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/src/import-process.ts @@ -0,0 +1,17 @@ +import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; +import type JS from "@codemod.com/jssg-types/langs/javascript"; + +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + let hasChanges = false; + + // after the migration, `url.format` is deprecated, so we replace it with `new URL().toString()` + // check remaining usages of `url` module and replace import statements accordingly + // and require calls + + if (!hasChanges) return null; + + return rootNode.commitEdits(edits); +}; + diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts new file mode 100644 index 00000000..afd63405 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -0,0 +1,15 @@ +import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; +import type JS from "@codemod.com/jssg-types/langs/javascript"; + +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + let hasChanges = false; + + // `url.format` is deprecated, so we replace it with `new URL().toString()` + + if (!hasChanges) return null; + + return rootNode.commitEdits(edits); +}; + diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts new file mode 100644 index 00000000..29b86da1 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -0,0 +1,20 @@ +import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; +import type JS from "@codemod.com/jssg-types/langs/javascript"; + +/** + * Transforms `url.parse` usage to `new URL()`. Handles direct global access + * + * See https://nodejs.org/api/deprecations.html#DEPDEP0116 for more details. + */ +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + let hasChanges = false; + + // `url.parse` is deprecated, so we replace it with `new URL()` + + if (!hasChanges) return null; + + return rootNode.commitEdits(edits); +}; + diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/.gitkeep b/recipes/node-url-to-whatwg-url/tests/import-process/expected/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/.gitkeep b/recipes/node-url-to-whatwg-url/tests/import-process/input/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-format/expected/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-format/input/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-parse/input/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/recipes/node-url-to-whatwg-url/workflow.yaml b/recipes/node-url-to-whatwg-url/workflow.yaml new file mode 100644 index 00000000..f1b03b02 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/workflow.yaml @@ -0,0 +1,55 @@ +version: "1" +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + runtime: + type: direct + steps: + - name: Handle DEPDEP0116 via transforming `url.parse` to `new URL()` + js-ast-grep: + js_file: src/url-parse.ts + base_path: . + include: + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript + - name: Handle DEPDEP0116 via transforming `url.format` to `new URL().toString()` + js-ast-grep: + js_file: src/url-format.ts + base_path: . + include: + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript + - name: Clean up import statements + require calls + js-ast-grep: + js_file: src/import-process.ts + base_path: . + include: + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" From a4ff1db98d5f8cd751a43474cdfd756c028c2ccb Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sun, 3 Aug 2025 15:26:00 +0200 Subject: [PATCH 17/52] url-parse(`node-url-to-whatwg-url`): setup `url-parse` --- biome.jsonc | 2 +- .../tests/url-parse/expected/.gitkeep | 0 .../tests/url-parse/expected/file-1.js | 12 ++++++++++++ .../tests/url-parse/expected/file-2.js | 12 ++++++++++++ .../tests/url-parse/expected/file-3.js | 12 ++++++++++++ .../tests/url-parse/expected/file-4.js | 12 ++++++++++++ .../tests/url-parse/expected/file-5.mjs | 12 ++++++++++++ .../tests/url-parse/expected/file-6.mjs | 12 ++++++++++++ .../tests/url-parse/expected/file-7.mjs | 12 ++++++++++++ .../tests/url-parse/expected/file-8.mjs | 12 ++++++++++++ .../tests/url-parse/input/.gitkeep | 0 .../tests/url-parse/input/file-1.js | 12 ++++++++++++ .../tests/url-parse/input/file-2.js | 12 ++++++++++++ .../tests/url-parse/input/file-3.js | 12 ++++++++++++ .../tests/url-parse/input/file-4.js | 12 ++++++++++++ .../tests/url-parse/input/file-5.mjs | 12 ++++++++++++ .../tests/url-parse/input/file-6.mjs | 12 ++++++++++++ .../tests/url-parse/input/file-7.mjs | 12 ++++++++++++ .../tests/url-parse/input/file-8.mjs | 12 ++++++++++++ 19 files changed, 193 insertions(+), 1 deletion(-) delete mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-1.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-2.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-3.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-4.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-5.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-6.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-7.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-8.mjs delete mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/file-1.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/file-2.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/file-3.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/file-4.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/file-5.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/file-6.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/file-7.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/file-8.mjs diff --git a/biome.jsonc b/biome.jsonc index 30993ebf..91e93aad 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,7 +1,7 @@ { "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "files": { - "includes": ["**", "!**/*.snap.cjs", "!**/fixtures/**", "!**/expected/**", "!**/input/**"] + "includes": ["**", "!**/*.snap.cjs", "!**/fixtures/**", "!**/tests/**"] }, "assist": { "actions": { "source": { "organizeImports": "off" } } }, // Rules for the linter diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-1.js b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-1.js new file mode 100644 index 00000000..2e5a4e64 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-1.js @@ -0,0 +1,12 @@ +const url = require('node:url'); + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-2.js b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-2.js new file mode 100644 index 00000000..b621e7f2 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-2.js @@ -0,0 +1,12 @@ +const nodeUrl = require('node:url'); + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-3.js b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-3.js new file mode 100644 index 00000000..063540c3 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-3.js @@ -0,0 +1,12 @@ +const { parse } = require('node:url'); + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-4.js b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-4.js new file mode 100644 index 00000000..d50c076e --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-4.js @@ -0,0 +1,12 @@ +const { parse: urlParse } = require('node:url'); + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-5.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-5.mjs new file mode 100644 index 00000000..ce1957a2 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-5.mjs @@ -0,0 +1,12 @@ +import url from 'node:url'; + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-6.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-6.mjs new file mode 100644 index 00000000..83a2cdbd --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-6.mjs @@ -0,0 +1,12 @@ +import nodeUrl from 'node:url'; + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-7.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-7.mjs new file mode 100644 index 00000000..89ae4f85 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-7.mjs @@ -0,0 +1,12 @@ +import { parse } from 'node:url'; + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-8.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-8.mjs new file mode 100644 index 00000000..c8300629 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-8.mjs @@ -0,0 +1,12 @@ +import { parse as urlParse } from 'node:url'; + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-parse/input/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-1.js b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-1.js new file mode 100644 index 00000000..aecb96d3 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-1.js @@ -0,0 +1,12 @@ +const url = require('node:url'); + +const myURL = url.parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-2.js b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-2.js new file mode 100644 index 00000000..c18e819d --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-2.js @@ -0,0 +1,12 @@ +const nodeUrl = require('node:url'); + +const myURL = nodeUrl.parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-3.js b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-3.js new file mode 100644 index 00000000..6a2540f9 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-3.js @@ -0,0 +1,12 @@ +const { parse } = require('node:url'); + +const myURL = parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-4.js b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-4.js new file mode 100644 index 00000000..83024dcb --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-4.js @@ -0,0 +1,12 @@ +const { parse: urlParse } = require('node:url'); + +const myURL = urlParse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-5.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-5.mjs new file mode 100644 index 00000000..db58b025 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-5.mjs @@ -0,0 +1,12 @@ +import url from 'node:url'; + +const myURL = url.parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-6.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-6.mjs new file mode 100644 index 00000000..07d9f974 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-6.mjs @@ -0,0 +1,12 @@ +import nodeUrl from 'node:url'; + +const myURL = nodeUrl.parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-7.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-7.mjs new file mode 100644 index 00000000..687463f6 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-7.mjs @@ -0,0 +1,12 @@ +import { parse } from 'node:url'; + +const myURL = parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-8.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-8.mjs new file mode 100644 index 00000000..2461384f --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-8.mjs @@ -0,0 +1,12 @@ +import { parse as urlParse } from 'node:url'; + +const myURL = urlParse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; From 4aeffddc146dc8ab41be1e22fd090b73180f4183 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sun, 3 Aug 2025 16:42:21 +0200 Subject: [PATCH 18/52] WIP --- package-lock.json | 12 ++ .../src/import-process.ts | 7 +- .../node-url-to-whatwg-url/src/url-format.ts | 105 +++++++++++++++++- .../node-url-to-whatwg-url/src/url-parse.ts | 11 +- 4 files changed, 127 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 221e6678..a5ad1dde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1424,6 +1424,10 @@ "resolved": "recipes/import-assertions-to-attributes", "link": true }, + "node_modules/@nodejs/node-url-to-whatwg-url": { + "resolved": "recipes/node-url-to-whatwg-url", + "link": true + }, "node_modules/@nodejs/process-main-module": { "resolved": "recipes/process-main-module", "link": true @@ -4080,6 +4084,14 @@ "@codemod.com/jssg-types": "^1.0.3" } }, + "recipes/node-url-to-whatwg-url": { + "name": "@nodejs/node-url-to-whatwg-url", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.3" + } + }, "recipes/process-main-module": { "name": "@nodejs/process-main-module", "version": "1.0.1", diff --git a/recipes/node-url-to-whatwg-url/src/import-process.ts b/recipes/node-url-to-whatwg-url/src/import-process.ts index 8cf9b252..4d086c9e 100644 --- a/recipes/node-url-to-whatwg-url/src/import-process.ts +++ b/recipes/node-url-to-whatwg-url/src/import-process.ts @@ -6,9 +6,10 @@ export default function transform(root: SgRoot): string | null { const edits: Edit[] = []; let hasChanges = false; - // after the migration, `url.format` is deprecated, so we replace it with `new URL().toString()` - // check remaining usages of `url` module and replace import statements accordingly - // and require calls + // after the migration, replacement of `url.parse` and `url.format` + // we need to check remaining usage of `url` module + // if needed, we can remove the `url` import + // we are going to use bruno's utility to resolve bindings if (!hasChanges) return null; diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index afd63405..f427405e 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -1,15 +1,114 @@ -import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; +import type { SgRoot, Edit, SgNode } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; +/** + * Transforms url.format() calls to new URL().toString() + * @param callNode The AST node representing the url.format() call + * @returns The transformed code + */ +function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { + for (const call of callNode) { + const optionsMatch = call.getMatch("OPTIONS"); + if (!optionsMatch) continue; + + // Find the object node that contains the URL options + const objectNode = optionsMatch.find({ + rule: { + kind: "object" + } + }); + + if (!objectNode) continue; + + // Extract URL components using AST traversal + const urlComponents = { + protocol: '', + hostname: '', + pathname: '', + search: '' + }; + + // Find all property pairs in the object + const pairs = objectNode.findAll({ + rule: { + kind: "pair" + } + }); + + for (const pair of pairs) { + // Get the property key + const keyNode = pair.find({ + rule: { + kind: "property_identifier" + } + }); + + // Get the string value + const valueNode = pair.find({ + rule: { + kind: "string" + } + }); + + if (keyNode && valueNode) { + const key = keyNode.text(); + // Get the string fragment (the actual content without quotes) + const stringFragment = valueNode.find({ + rule: { + kind: "string_fragment" + } + }); + + const value = stringFragment ? stringFragment.text() : valueNode.text().slice(1, -1); + + // Map the properties to URL components + if (key === 'protocol') urlComponents.protocol = value; + else if (key === 'hostname') urlComponents.hostname = value; + else if (key === 'pathname') urlComponents.pathname = value; + else if (key === 'search') urlComponents.search = value; + else console.warn(`Unknown URL option: ${key}`); + } + } + + // Construct the URL string + const urlString = `${urlComponents.protocol}://${urlComponents.hostname}${urlComponents.pathname}${urlComponents.search}`; + + // Replace the entire call with new URL().toString() + const replacement = `new URL('${urlString}').toString()`; + edits.push(call.replace(replacement)); + } +} + +/** + * Transforms `url.format` usage to `new URL().toString()`. + * + * See https://nodejs.org/api/deprecations.html#DEP0116 + * + * Handle: + * 1. `url.format(options)` → `new URL().toString()` + * 2. `format(options)` → `new URL().toString()` + * if imported with aliases + * 2. `foo.format(options)` → `new URL().toString()` + * 3. `foo(options)` → `new URL().toString()` + */ export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; let hasChanges = false; - // `url.format` is deprecated, so we replace it with `new URL().toString()` + // 1. Find all `url.format(options)` calls + const urlFormatCalls = rootNode.findAll({ + // TODO(@AugustinMauroy): use burno's utility (not merged yet) + rule: { pattern: "url.format($OPTIONS)" } + }); + + // 2. Find all `format(options)` calls + if (urlFormatCalls.length > 0) { + urlFormatToUrlToString(urlFormatCalls, edits); + hasChanges = edits.length > 0; + } if (!hasChanges) return null; return rootNode.commitEdits(edits); }; - diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index 29b86da1..e065411c 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -2,9 +2,16 @@ import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; /** - * Transforms `url.parse` usage to `new URL()`. Handles direct global access + * Transforms `url.parse` usage to `new URL()`. * - * See https://nodejs.org/api/deprecations.html#DEPDEP0116 for more details. + * See https://nodejs.org/api/deprecations.html#DEP0116 for more details. + * + * Handle: + * 1. `url.parse(urlString)` → `new URL(urlString)` + * 2. `parse(urlString)` → `new URL(urlString)` + * if imported with aliases + * 2. `foo.parse(urlString)` → `new URL(urlString)` + * 3. `foo(urlString)` → `new URL(urlString)` */ export default function transform(root: SgRoot): string | null { const rootNode = root.root(); From 0b1f00a44fefe31e7d08d7bb2581ab8bfc6ad6ee Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 11:59:06 +0200 Subject: [PATCH 19/52] url-parse(`node-url-to-whatwg-url`): introduce --- recipes/node-url-to-whatwg-url/README.md | 5 +- recipes/node-url-to-whatwg-url/package.json | 8 +- .../src/import-process.ts | 114 ++++++++++- .../node-url-to-whatwg-url/src/url-format.ts | 180 ++++++++++++------ .../node-url-to-whatwg-url/src/url-parse.ts | 98 +++++++++- .../tests/import-process/expected/file-1.js | 1 + .../tests/import-process/expected/file-2.js | 3 + .../tests/import-process/expected/file-3.js | 1 + .../tests/import-process/expected/file-4.js | 3 + .../tests/import-process/expected/file-5.js | 1 + .../tests/import-process/expected/file-6.js | 1 + .../tests/import-process/input/file-1.js | 3 + .../tests/import-process/input/file-2.js | 3 + .../tests/import-process/input/file-3.js | 3 + .../tests/import-process/input/file-4.js | 3 + .../tests/import-process/input/file-5.js | 3 + .../tests/import-process/input/file-6.js | 3 + .../tests/url-format/expected/file-1.js | 3 + .../tests/url-format/expected/file-2.js | 3 + .../tests/url-format/expected/file-3.js | 3 + .../tests/url-format/input/file-1.js | 8 + .../tests/url-format/input/file-2.js | 9 + .../tests/url-format/input/file-3.js | 8 + 23 files changed, 398 insertions(+), 69 deletions(-) create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/expected/file-1.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/expected/file-2.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/expected/file-3.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/expected/file-4.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/expected/file-5.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/expected/file-6.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/input/file-1.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/input/file-2.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/input/file-3.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/input/file-4.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/input/file-5.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/input/file-6.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-1.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-2.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-3.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-1.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-2.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-3.js diff --git a/recipes/node-url-to-whatwg-url/README.md b/recipes/node-url-to-whatwg-url/README.md index d7688f56..3f531ad2 100644 --- a/recipes/node-url-to-whatwg-url/README.md +++ b/recipes/node-url-to-whatwg-url/README.md @@ -11,6 +11,7 @@ See [DEP0116](https://nodejs.org/api/deprecations.html#DEP0116). ```js // Before const url = require('node:url'); + const myUrl = new url.URL('https://example.com'); const urlAuth = legacyURL.auth; // After @@ -22,6 +23,7 @@ const urlAuth = `${myUrl.username}:${myUrl.password}`; ```js // Before const url = require('node:url'); + url.format({ protocol: 'https', hostname: 'example.com', @@ -32,8 +34,7 @@ url.format({ }, }); // After -const myUrl = new URL('https://example.com/some/path?page=1&format=json'); -myUrl.toString(); +const myUrl = new URL('https://example.com/some/path?page=1&format=json').toString(); ``` ## Caveats diff --git a/recipes/node-url-to-whatwg-url/package.json b/recipes/node-url-to-whatwg-url/package.json index 15ff9d4c..dfa865bb 100644 --- a/recipes/node-url-to-whatwg-url/package.json +++ b/recipes/node-url-to-whatwg-url/package.json @@ -4,10 +4,10 @@ "description": "Handle DEPDEP0116 via transforming `url.parse` to `new URL()`", "type": "module", "scripts": { - "test": "node --run test:import-process.js && node --run test:url-format.js && node --run test:url-parse.js", - "test:import-process": "npx codemod@next jssg test -l typescript ./src/import-process.ts ./tests/import-process", - "test:url-format": "npx codemod@next jssg test -l typescript ./src/url-format.ts ./tests/url-format", - "test:url-parse": "npx codemod@next jssg test -l typescript ./src/url-parse.ts ./tests/url-parse" + "test": "node --run test:import-process && node --run test:url-format && node --run test:url-parse", + "test:import-process": "npx codemod@next jssg test -l typescript ./src/import-process.ts ./tests/ --filter import-process", + "test:url-format": "npx codemod@next jssg test -l typescript ./src/url-format.ts ./tests/ --filter url-format", + "test:url-parse": "npx codemod@next jssg test -l typescript ./src/url-parse.ts ./tests/ --filter url-parse" }, "repository": { "type": "git", diff --git a/recipes/node-url-to-whatwg-url/src/import-process.ts b/recipes/node-url-to-whatwg-url/src/import-process.ts index 4d086c9e..f657b35e 100644 --- a/recipes/node-url-to-whatwg-url/src/import-process.ts +++ b/recipes/node-url-to-whatwg-url/src/import-process.ts @@ -1,5 +1,9 @@ -import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; +import type { SgRoot, Edit, Range } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; +import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; +import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; +import { removeLines } from "@nodejs/codemod-utils/ast-grep/remove-lines"; +// Clean up unused imports/requires from 'node:url' after transforms using shared utils export default function transform(root: SgRoot): string | null { const rootNode = root.root(); @@ -11,8 +15,114 @@ export default function transform(root: SgRoot): string | null { // if needed, we can remove the `url` import // we are going to use bruno's utility to resolve bindings + const isBindingUsed = (name: string): boolean => { + const refs = rootNode.findAll({ rule: { pattern: name } }); + // Heuristic: declaration counts as one; any other usage yields > 1 + return refs.length > 1; + }; + + const linesToRemove: Range[] = []; + + // 1) ES Module imports: import ... from 'node:url' + // @ts-ignore - ast-grep types vs jssg types + const esmImports = getNodeImportStatements(root, "url"); + + for (const imp of esmImports) { + // Try namespace/default binding + const clause = imp.find({ rule: { kind: "import_clause" } }); + let removed = false; + if (clause) { + // Namespace import like: import * as url from 'node:url' + const nsId = clause.find({ rule: { kind: "namespace_import" } })?.find({ rule: { kind: "identifier" } }); + if (nsId && !isBindingUsed(nsId.text())) { + edits.push(imp.replace("")); + removed = true; + } + if (removed) continue; + + // Named imports bucket + const specs = clause.findAll({ rule: { kind: "import_specifier" } }); + + // Default import like: import url from 'node:url' (only when not a named or namespace import) + if (specs.length === 0 && !nsId) { + const defaultId = clause.find({ rule: { kind: "identifier" } }); + if (defaultId && !isBindingUsed(defaultId.text())) { + edits.push(imp.replace("")); + removed = true; + } + if (removed) continue; + } + + // Named imports: import { a, b as c } from 'node:url' + if (specs.length > 0) { + const keepTexts: string[] = []; + for (const spec of specs) { + const text = spec.text().trim(); + // If alias form exists, use alias as the binding name + const bindingName = text.includes(" as ") ? text.split(/\s+as\s+/)[1] : text; + if (bindingName && isBindingUsed(bindingName)) keepTexts.push(text); + } + if (keepTexts.length === 0) { + linesToRemove.push(imp.range()); + } else if (keepTexts.length !== specs.length) { + const namedImportsNode = clause.find({ rule: { kind: "named_imports" } }); + if (namedImportsNode) edits.push(namedImportsNode.replace(`{ ${keepTexts.join(", ")} }`)); + } + } + } + } + + // 2) CommonJS requires: const ... = require('node:url') + // @ts-ignore - ast-grep types vs jssg types + const requireDecls = getNodeRequireCalls(root, "url"); + + for (const decl of requireDecls) { + // Namespace require: const url = require('node:url') + const id = decl.find({ rule: { kind: "identifier" } }); + const hasObjectPattern = decl.find({ rule: { kind: "object_pattern" } }); + + if (id && !hasObjectPattern) { + if (!isBindingUsed(id.text())) linesToRemove.push(decl.parent().range()); + continue; + } + + // Destructured require: const { parse, format: fmt } = require('node:url') + if (hasObjectPattern) { + const names: string[] = []; + // Shorthand bindings + const shorts = decl.findAll({ rule: { kind: "shorthand_property_identifier_pattern" } }); + for (const s of shorts) names.push(s.text()); + // Aliased bindings (pair_pattern) => use the identifier name (value) + const pairs = decl.findAll({ rule: { kind: "pair_pattern" } }); + for (const pair of pairs) { + const aliasId = pair.find({ rule: { kind: "identifier" } }); + if (aliasId) names.push(aliasId.text()); + } + + const usedTexts: string[] = []; + for (const s of shorts) if (isBindingUsed(s.text())) usedTexts.push(s.text()); + for (const pair of pairs) { + const aliasId = pair.find({ rule: { kind: "identifier" } }); + if (aliasId && isBindingUsed(aliasId.text())) usedTexts.push(pair.text()); // keep full spec text + } + + if (usedTexts.length === 0) { + linesToRemove.push(decl.parent().range()); + } else if (usedTexts.length !== names.length) { + const objPat = decl.find({ rule: { kind: "object_pattern" } }); + if (objPat) edits.push(objPat.replace(`{ ${usedTexts.join(", ")} }`)); + } + } + } + + hasChanges = edits.length > 0 || linesToRemove.length > 0; + if (!hasChanges) return null; - return rootNode.commitEdits(edits); + // Apply edits, remove whole lines ranges, then normalize leading whitespace only + let source = rootNode.commitEdits(edits); + source = removeLines(source, linesToRemove); + source = source.replace(/^\n+/, ""); + return source; }; diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index f427405e..e21fdd18 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -12,68 +12,125 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { if (!optionsMatch) continue; // Find the object node that contains the URL options - const objectNode = optionsMatch.find({ - rule: { - kind: "object" - } - }); - + const objectNode = optionsMatch.find({ rule: { kind: "object" } }); if (!objectNode) continue; - // Extract URL components using AST traversal - const urlComponents = { - protocol: '', - hostname: '', - pathname: '', - search: '' - }; - - // Find all property pairs in the object - const pairs = objectNode.findAll({ - rule: { - kind: "pair" + const urlState: { + protocol?: string; + auth?: string; // user:pass + host?: string; // host:port + hostname?: string; + port?: string; + pathname?: string; + search?: string; // ?a=b + hash?: string; // #frag + queryParams?: Array<[string, string]>; + } = {}; + + const pairs = objectNode.findAll({ rule: { kind: "pair" } }); + + const getLiteralText = (node: SgNode | null | undefined): string | undefined => { + if (!node) return undefined; + const kind = node.kind(); + if (kind === "string") { + const frag = node.find({ rule: { kind: "string_fragment" } }); + return frag ? frag.text() : node.text().slice(1, -1); } - }); + if (kind === "number") return node.text(); + if (kind === "true" || kind === "false") return node.text(); + return undefined; + }; for (const pair of pairs) { - // Get the property key - const keyNode = pair.find({ - rule: { - kind: "property_identifier" + const keyNode = pair.find({ rule: { kind: "property_identifier" } }); + const key = keyNode?.text(); + if (!key) continue; + + if (key === "query") { + // Collect query object literals into key=value + const queryObj = pair.find({ rule: { kind: "object" } }); + if (queryObj) { + const qpairs = queryObj.findAll({ rule: { kind: "pair" } }); + const list: Array<[string, string]> = []; + for (const qp of qpairs) { + const qkeyNode = qp.find({ rule: { kind: "property_identifier" } }); + const qvalLiteral = getLiteralText(qp.find({ rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] } })); + if (qkeyNode && qvalLiteral !== undefined) list.push([qkeyNode.text(), qvalLiteral]); + } + urlState.queryParams = list; } - }); + continue; + } - // Get the string value - const valueNode = pair.find({ - rule: { - kind: "string" + // value might be string/number/bool + const valueLiteral = getLiteralText(pair.find({ rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] } })); + if (valueLiteral === undefined) continue; + + switch (key) { + case "protocol": { + // normalize without trailing ':' + urlState.protocol = valueLiteral.replace(/:$/, ""); + break; } - }); - - if (keyNode && valueNode) { - const key = keyNode.text(); - // Get the string fragment (the actual content without quotes) - const stringFragment = valueNode.find({ - rule: { - kind: "string_fragment" - } - }); + case "auth": { + urlState.auth = valueLiteral; // 'user:pass' + break; + } + case "host": { + urlState.host = valueLiteral; // 'example.com:8080' + break; + } + case "hostname": { + urlState.hostname = valueLiteral; + break; + } + case "port": { + urlState.port = valueLiteral; + break; + } + case "pathname": { + urlState.pathname = valueLiteral; + break; + } + case "search": { + urlState.search = valueLiteral; + break; + } + case "hash": { + urlState.hash = valueLiteral; + break; + } + default: + // ignore unknown options in this simple mapping + break; + } + } + + const proto = urlState.protocol ?? ""; + const auth = urlState.auth ? `${urlState.auth}@` : ""; + let host = urlState.host; + if (!host) { + if (urlState.hostname && urlState.port) host = `${urlState.hostname}:${urlState.port}`; + else if (urlState.hostname) host = urlState.hostname; + else host = ""; + } - const value = stringFragment ? stringFragment.text() : valueNode.text().slice(1, -1); + let pathname = urlState.pathname ?? ""; + if (pathname && !pathname.startsWith("/")) pathname = `/${pathname}`; - // Map the properties to URL components - if (key === 'protocol') urlComponents.protocol = value; - else if (key === 'hostname') urlComponents.hostname = value; - else if (key === 'pathname') urlComponents.pathname = value; - else if (key === 'search') urlComponents.search = value; - else console.warn(`Unknown URL option: ${key}`); - } + let search = urlState.search ?? ""; + if (!search && urlState.queryParams && urlState.queryParams.length > 0) { + const qs = urlState.queryParams.map(([k, v]) => `${k}=${v}`).join("&"); + search = `?${qs}`; } + if (search && !search.startsWith("?")) search = `?${search}`; + + let hash = urlState.hash ?? ""; + if (hash && !hash.startsWith("#")) hash = `#${hash}`; - // Construct the URL string - const urlString = `${urlComponents.protocol}://${urlComponents.hostname}${urlComponents.pathname}${urlComponents.search}`; + const base = proto ? `${proto}://` : ""; + const urlString = `${base}${auth}${host}${pathname}${search}${hash}`; - // Replace the entire call with new URL().toString() const replacement = `new URL('${urlString}').toString()`; edits.push(call.replace(replacement)); } @@ -96,18 +153,23 @@ export default function transform(root: SgRoot): string | null { const edits: Edit[] = []; let hasChanges = false; - // 1. Find all `url.format(options)` calls - const urlFormatCalls = rootNode.findAll({ - // TODO(@AugustinMauroy): use burno's utility (not merged yet) - rule: { pattern: "url.format($OPTIONS)" } - }); - - // 2. Find all `format(options)` calls - if (urlFormatCalls.length > 0) { - urlFormatToUrlToString(urlFormatCalls, edits); - hasChanges = edits.length > 0; + // Look for various ways format can be referenced + const patterns = [ + "url.format($OPTIONS)", + "nodeUrl.format($OPTIONS)", + "format($OPTIONS)", + "urlFormat($OPTIONS)" + ]; + + for (const pattern of patterns) { + const calls = rootNode.findAll({ rule: { pattern } }); + if (calls.length > 0) { + urlFormatToUrlToString(calls, edits); + } } + hasChanges = edits.length > 0; + if (!hasChanges) return null; return rootNode.commitEdits(edits); diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index e065411c..610d0bdf 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -1,4 +1,4 @@ -import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; +import type { SgRoot, Edit, SgNode } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; /** @@ -16,10 +16,104 @@ import type JS from "@codemod.com/jssg-types/langs/javascript"; export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; - let hasChanges = false; // `url.parse` is deprecated, so we replace it with `new URL()` + // Safety: only run on files that import/require node:url + const hasNodeUrlImport = + rootNode.find({ rule: { pattern: "require('node:url')" } }) || + rootNode.find({ rule: { pattern: 'require("node:url")' } }) || + rootNode.find({ rule: { pattern: "import $_ from 'node:url'" } }) || + rootNode.find({ rule: { pattern: 'import $_ from "node:url"' } }) || + rootNode.find({ rule: { pattern: "import { $_ } from 'node:url'" } }) || + rootNode.find({ rule: { pattern: 'import { $_ } from "node:url"' } }); + + if (!hasNodeUrlImport) { + return null; + } + + // 1) Replace parse calls with new URL() + const parseCallPatterns = [ + // member calls on typical identifiers + "url.parse($ARG)", + "nodeUrl.parse($ARG)", + // bare identifier (named import) + "parse($ARG)", + // common alias used in tests + "urlParse($ARG)" + ]; + + for (const pattern of parseCallPatterns) { + const calls = rootNode.findAll({ rule: { pattern } }); + for (const call of calls) { + const arg = call.getMatch("ARG") as SgNode | undefined; + if (!arg) continue; + const replacement = `new URL(${arg.text()})`; + edits.push(call.replace(replacement)); + } + } + + // 2) Transform legacy properties on URL object + // - auth => `${obj.username}:${obj.password}` + // - path => `${obj.pathname}${obj.search}` + // - hostname => obj.hostname.replace(/^[\[|\]]$/, '') (strip square brackets) + + // Property access: obj.auth -> `${obj.username}:${obj.password}` + const authAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.auth" } }); + for (const node of authAccesses) { + const base = node.getMatch("OBJ") as SgNode | undefined; + if (!base) continue; + const replacement = '`${' + base.text() + '.username}:${' + base.text() + '.password}`'; + edits.push(node.replace(replacement)); + } + + // Destructuring: const { auth } = obj -> const auth = `${obj.username}:${obj.password}` + const authDestructures = rootNode.findAll({ rule: { pattern: "const { auth } = $OBJ" } }); + for (const node of authDestructures) { + const base = node.getMatch("OBJ") as SgNode | undefined; + if (!base) continue; + const replacement = 'const auth = ' + '`${' + base.text() + '.username}:${' + base.text() + '.password}`' + ';'; + edits.push(node.replace(replacement)); + } + + // Property access: obj.path -> `${obj.pathname}${obj.search}` + const pathAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.path" } }); + for (const node of pathAccesses) { + const base = node.getMatch("OBJ") as SgNode | undefined; + if (!base) continue; + const replacement = '`${' + base.text() + '.pathname}${' + base.text() + '.search}`'; + edits.push(node.replace(replacement)); + } + + // Destructuring: const { path } = obj -> const path = `${obj.pathname}${obj.search}` + const pathDestructures = rootNode.findAll({ rule: { pattern: "const { path } = $OBJ" } }); + for (const node of pathDestructures) { + const base = node.getMatch("OBJ") as SgNode | undefined; + if (!base) continue; + const replacement = 'const path = ' + '`${' + base.text() + '.pathname}${' + base.text() + '.search}`' + ';'; + edits.push(node.replace(replacement)); + } + + // Property access: obj.hostname -> obj.hostname.replace(/^\[|\]$/, '') + const hostnameAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.hostname" } }); + for (const node of hostnameAccesses) { + const base = node.getMatch("OBJ") as SgNode | undefined; + if (!base) continue; + const replacement = base.text() + ".hostname.replace(/^\\[|\\]$/, '')"; + edits.push(node.replace(replacement)); + } + + // Destructuring: const { hostname } = obj -> const hostname = obj.hostname.replace(/^\[|\]$/, '') + const hostnameDestructures = rootNode.findAll({ rule: { pattern: "const { hostname } = $OBJ" } }); + for (const node of hostnameDestructures) { + const base = node.getMatch("OBJ") as SgNode | undefined; + if (!base) continue; + const replacement = 'const hostname = ' + base.text() + ".hostname.replace(/^\\[|\\]$/, '')" + ';'; + edits.push(node.replace(replacement)); + } + + const hasChanges = edits.length > 0; + if (!hasChanges) return null; return rootNode.commitEdits(edits); diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-1.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-1.js new file mode 100644 index 00000000..943c458c --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-1.js @@ -0,0 +1 @@ +const x = 1; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-2.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-2.js new file mode 100644 index 00000000..1b094505 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-2.js @@ -0,0 +1,3 @@ +import { format as fmt } from 'node:url'; + +console.log(fmt); diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-3.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-3.js new file mode 100644 index 00000000..1905b485 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-3.js @@ -0,0 +1 @@ +function foo() { return 1; } diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-4.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-4.js new file mode 100644 index 00000000..8563cef9 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-4.js @@ -0,0 +1,3 @@ +const { format: fmt } = require('node:url'); + +fmt('x'); diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-5.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-5.js new file mode 100644 index 00000000..2cc629c7 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-5.js @@ -0,0 +1 @@ +export const v = 42; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-6.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-6.js new file mode 100644 index 00000000..4321ad8b --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-6.js @@ -0,0 +1 @@ +doStuff(); diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-1.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-1.js new file mode 100644 index 00000000..97a850a2 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-1.js @@ -0,0 +1,3 @@ +import url from 'node:url'; + +const x = 1; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-2.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-2.js new file mode 100644 index 00000000..b3025e60 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-2.js @@ -0,0 +1,3 @@ +import { parse, format as fmt } from 'node:url'; + +console.log(fmt); diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-3.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-3.js new file mode 100644 index 00000000..4dc78c3c --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-3.js @@ -0,0 +1,3 @@ +const url = require('node:url'); + +function foo() { return 1; } diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-4.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-4.js new file mode 100644 index 00000000..25e5c4ac --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-4.js @@ -0,0 +1,3 @@ +const { parse, format: fmt } = require('node:url'); + +fmt('x'); diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-5.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-5.js new file mode 100644 index 00000000..71d51251 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-5.js @@ -0,0 +1,3 @@ +import * as url from 'node:url'; + +export const v = 42; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-6.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-6.js new file mode 100644 index 00000000..e6769006 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-6.js @@ -0,0 +1,3 @@ +const { parse } = require('node:url'); + +doStuff(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-1.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-1.js new file mode 100644 index 00000000..dc398267 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-1.js @@ -0,0 +1,3 @@ +const url = require('node:url'); + +const str = new URL('https://example.com/some/path?page=1').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-2.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-2.js new file mode 100644 index 00000000..5be5dd20 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-2.js @@ -0,0 +1,3 @@ +const { format } = require('node:url'); + +const a = new URL('https://example.com:8080/p?page=1&format=json#frag').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-3.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-3.js new file mode 100644 index 00000000..87dd3d98 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-3.js @@ -0,0 +1,3 @@ +const nodeUrl = require('node:url'); + +const b = new URL('http://example.com:3000/x').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-1.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-1.js new file mode 100644 index 00000000..e95d6e40 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-1.js @@ -0,0 +1,8 @@ +const url = require('node:url'); + +const str = url.format({ + protocol: 'https', + hostname: 'example.com', + pathname: '/some/path', + search: '?page=1' +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-2.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-2.js new file mode 100644 index 00000000..4f33ebd9 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-2.js @@ -0,0 +1,9 @@ +const { format } = require('node:url'); + +const a = format({ + protocol: 'https:', + host: 'example.com:8080', + pathname: 'p', + query: { page: '1', format: 'json' }, + hash: 'frag', +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-3.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-3.js new file mode 100644 index 00000000..ef9bffca --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-3.js @@ -0,0 +1,8 @@ +const nodeUrl = require('node:url'); + +const b = nodeUrl.format({ + protocol: 'http', + hostname: 'example.com', + port: '3000', + pathname: '/x', +}); From 729f5f8a49e98cc9869b0ac55bee8196f688d1a9 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 13:50:21 +0200 Subject: [PATCH 20/52] clean --- .../src/import-process.ts | 5 +- .../node-url-to-whatwg-url/src/url-format.ts | 37 +++--- .../node-url-to-whatwg-url/src/url-parse.ts | 123 +++++++++--------- .../tests/import-process/expected/.gitkeep | 0 .../tests/import-process/input/.gitkeep | 0 .../tests/url-format/expected/.gitkeep | 0 .../tests/url-format/input/.gitkeep | 0 7 files changed, 84 insertions(+), 81 deletions(-) delete mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/expected/.gitkeep delete mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/input/.gitkeep delete mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/.gitkeep delete mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/.gitkeep diff --git a/recipes/node-url-to-whatwg-url/src/import-process.ts b/recipes/node-url-to-whatwg-url/src/import-process.ts index f657b35e..dd7646b4 100644 --- a/recipes/node-url-to-whatwg-url/src/import-process.ts +++ b/recipes/node-url-to-whatwg-url/src/import-process.ts @@ -8,7 +8,6 @@ import { removeLines } from "@nodejs/codemod-utils/ast-grep/remove-lines"; export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; - let hasChanges = false; // after the migration, replacement of `url.parse` and `url.format` // we need to check remaining usage of `url` module @@ -115,9 +114,7 @@ export default function transform(root: SgRoot): string | null { } } - hasChanges = edits.length > 0 || linesToRemove.length > 0; - - if (!hasChanges) return null; + if (edits.length < 0 || linesToRemove.length < 0) return null; // Apply edits, remove whole lines ranges, then normalize leading whitespace only let source = rootNode.commitEdits(edits); diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index e21fdd18..0468b42e 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -1,6 +1,23 @@ import type { SgRoot, Edit, SgNode } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; +/** + * Get the literal text value of a node, if it exists. + * @param node The node to extract the literal text from + * @returns The literal text value, or undefined if not found + */ +const getLiteralText = (node: SgNode | null | undefined): string | undefined => { + const kind = node.kind(); + + if (kind === "string") { + const frag = node.find({ rule: { kind: "string_fragment" } }); + return frag ? frag.text() : node.text().slice(1, -1); + } + if (kind === "number") return node.text(); + if (kind === "true" || kind === "false") return node.text(); + return undefined; +}; + /** * Transforms url.format() calls to new URL().toString() * @param callNode The AST node representing the url.format() call @@ -29,18 +46,6 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { const pairs = objectNode.findAll({ rule: { kind: "pair" } }); - const getLiteralText = (node: SgNode | null | undefined): string | undefined => { - if (!node) return undefined; - const kind = node.kind(); - if (kind === "string") { - const frag = node.find({ rule: { kind: "string_fragment" } }); - return frag ? frag.text() : node.text().slice(1, -1); - } - if (kind === "number") return node.text(); - if (kind === "true" || kind === "false") return node.text(); - return undefined; - }; - for (const pair of pairs) { const keyNode = pair.find({ rule: { kind: "property_identifier" } }); const key = keyNode?.text(); @@ -151,7 +156,6 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; - let hasChanges = false; // Look for various ways format can be referenced const patterns = [ @@ -163,14 +167,13 @@ export default function transform(root: SgRoot): string | null { for (const pattern of patterns) { const calls = rootNode.findAll({ rule: { pattern } }); - if (calls.length > 0) { + + if (calls.length) { urlFormatToUrlToString(calls, edits); } } - hasChanges = edits.length > 0; - - if (!hasChanges) return null; + if (!edits.length) return null; return rootNode.commitEdits(edits); }; diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index 610d0bdf..ed70535d 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -1,4 +1,4 @@ -import type { SgRoot, Edit, SgNode } from "@codemod.com/jssg-types/main"; +import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; /** @@ -17,8 +17,6 @@ export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; - // `url.parse` is deprecated, so we replace it with `new URL()` - // Safety: only run on files that import/require node:url const hasNodeUrlImport = rootNode.find({ rule: { pattern: "require('node:url')" } }) || @@ -45,8 +43,9 @@ export default function transform(root: SgRoot): string | null { for (const pattern of parseCallPatterns) { const calls = rootNode.findAll({ rule: { pattern } }); + for (const call of calls) { - const arg = call.getMatch("ARG") as SgNode | undefined; + const arg = call.getMatch("ARG"); if (!arg) continue; const replacement = `new URL(${arg.text()})`; edits.push(call.replace(replacement)); @@ -59,62 +58,66 @@ export default function transform(root: SgRoot): string | null { // - hostname => obj.hostname.replace(/^[\[|\]]$/, '') (strip square brackets) // Property access: obj.auth -> `${obj.username}:${obj.password}` - const authAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.auth" } }); - for (const node of authAccesses) { - const base = node.getMatch("OBJ") as SgNode | undefined; - if (!base) continue; - const replacement = '`${' + base.text() + '.username}:${' + base.text() + '.password}`'; - edits.push(node.replace(replacement)); - } - - // Destructuring: const { auth } = obj -> const auth = `${obj.username}:${obj.password}` - const authDestructures = rootNode.findAll({ rule: { pattern: "const { auth } = $OBJ" } }); - for (const node of authDestructures) { - const base = node.getMatch("OBJ") as SgNode | undefined; - if (!base) continue; - const replacement = 'const auth = ' + '`${' + base.text() + '.username}:${' + base.text() + '.password}`' + ';'; - edits.push(node.replace(replacement)); - } - - // Property access: obj.path -> `${obj.pathname}${obj.search}` - const pathAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.path" } }); - for (const node of pathAccesses) { - const base = node.getMatch("OBJ") as SgNode | undefined; - if (!base) continue; - const replacement = '`${' + base.text() + '.pathname}${' + base.text() + '.search}`'; - edits.push(node.replace(replacement)); - } - - // Destructuring: const { path } = obj -> const path = `${obj.pathname}${obj.search}` - const pathDestructures = rootNode.findAll({ rule: { pattern: "const { path } = $OBJ" } }); - for (const node of pathDestructures) { - const base = node.getMatch("OBJ") as SgNode | undefined; - if (!base) continue; - const replacement = 'const path = ' + '`${' + base.text() + '.pathname}${' + base.text() + '.search}`' + ';'; - edits.push(node.replace(replacement)); - } - - // Property access: obj.hostname -> obj.hostname.replace(/^\[|\]$/, '') - const hostnameAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.hostname" } }); - for (const node of hostnameAccesses) { - const base = node.getMatch("OBJ") as SgNode | undefined; - if (!base) continue; - const replacement = base.text() + ".hostname.replace(/^\\[|\\]$/, '')"; - edits.push(node.replace(replacement)); - } - - // Destructuring: const { hostname } = obj -> const hostname = obj.hostname.replace(/^\[|\]$/, '') - const hostnameDestructures = rootNode.findAll({ rule: { pattern: "const { hostname } = $OBJ" } }); - for (const node of hostnameDestructures) { - const base = node.getMatch("OBJ") as SgNode | undefined; - if (!base) continue; - const replacement = 'const hostname = ' + base.text() + ".hostname.replace(/^\\[|\\]$/, '')" + ';'; - edits.push(node.replace(replacement)); - } - - const hasChanges = edits.length > 0; - - if (!hasChanges) return null; + const authAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.auth" } }); + for (const node of authAccesses) { + const base = node.getMatch("OBJ"); + if (!base) continue; + + const replacement = `\`\${${base.text()}.username}:\${${base.text()}.password}\``; + edits.push(node.replace(replacement)); + } + + // Destructuring: const { auth } = obj -> const auth = `${obj.username}:${obj.password}` + const authDestructures = rootNode.findAll({ rule: { pattern: "const { auth } = $OBJ" } }); + for (const node of authDestructures) { + const base = node.getMatch("OBJ"); + if (!base) continue; + + const replacement = `const auth = \`\${${base.text()}.username}:\${${base.text()}.password}\`;`; + edits.push(node.replace(replacement)); + } + + // Property access: obj.path -> `${obj.pathname}${obj.search}` + const pathAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.path" } }); + for (const node of pathAccesses) { + const base = node.getMatch("OBJ"); + if (!base) continue; + + const replacement = `\`\${${base.text()}.pathname}\${${base.text()}.search}\``; + edits.push(node.replace(replacement)); + } + + // Destructuring: const { path } = obj -> const path = `${obj.pathname}${obj.search}` + const pathDestructures = rootNode.findAll({ rule: { pattern: "const { path } = $OBJ" } }); + for (const node of pathDestructures) { + const base = node.getMatch("OBJ"); + if (!base) continue; + + const replacement = `const path = \`\${${base.text()}.pathname}\${${base.text()}.search}\`;`; + edits.push(node.replace(replacement)); + } + + // Property access: obj.hostname -> obj.hostname.replace(/^\[|\]$/, '') + const hostnameAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.hostname" } }); + for (const node of hostnameAccesses) { + const base = node.getMatch("OBJ"); + if (!base) continue; + + const replacement = `${base.text()}.hostname.replace(/^\\[|\\]$/, '')`; + edits.push(node.replace(replacement)); + } + + // Destructuring: const { hostname } = obj -> const hostname = obj.hostname.replace(/^\[|\]$/, '') + const hostnameDestructures = rootNode.findAll({ rule: { pattern: "const { hostname } = $OBJ" } }); + for (const node of hostnameDestructures) { + const base = node.getMatch("OBJ"); + if (!base) continue; + + const replacement = `const hostname = ${base.text()}.hostname.replace(/^\\[|\\]$/, '');`; + edits.push(node.replace(replacement)); + } + + if (!edits.length) return null; return rootNode.commitEdits(edits); }; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/.gitkeep b/recipes/node-url-to-whatwg-url/tests/import-process/expected/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/.gitkeep b/recipes/node-url-to-whatwg-url/tests/import-process/input/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-format/expected/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-format/input/.gitkeep deleted file mode 100644 index e69de29b..00000000 From 0914a53d70b2eda0e8321f3e60abe546c5978750 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 13:54:54 +0200 Subject: [PATCH 21/52] WIP --- .../node-url-to-whatwg-url/src/url-format.ts | 10 ++ .../node-url-to-whatwg-url/src/url-parse.ts | 91 +++++++++---------- 2 files changed, 54 insertions(+), 47 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index 0468b42e..e9b0a7b6 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -1,5 +1,7 @@ import type { SgRoot, Edit, SgNode } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; +import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; +import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; /** * Get the literal text value of a node, if it exists. @@ -157,7 +159,15 @@ export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; + // Safety: only run on files that import/require node:url + const hasNodeUrlImport = + getNodeImportStatements(root, "url").length > 0 || + getNodeRequireCalls(root, "url").length > 0; + + if (!hasNodeUrlImport) return null; + // Look for various ways format can be referenced + // todo(@AugustinMauroy): use resolveBindingPath const patterns = [ "url.format($OPTIONS)", "nodeUrl.format($OPTIONS)", diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index ed70535d..b879b08e 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -1,3 +1,5 @@ +import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; +import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; @@ -14,50 +16,46 @@ import type JS from "@codemod.com/jssg-types/langs/javascript"; * 3. `foo(urlString)` → `new URL(urlString)` */ export default function transform(root: SgRoot): string | null { - const rootNode = root.root(); - const edits: Edit[] = []; - - // Safety: only run on files that import/require node:url - const hasNodeUrlImport = - rootNode.find({ rule: { pattern: "require('node:url')" } }) || - rootNode.find({ rule: { pattern: 'require("node:url")' } }) || - rootNode.find({ rule: { pattern: "import $_ from 'node:url'" } }) || - rootNode.find({ rule: { pattern: 'import $_ from "node:url"' } }) || - rootNode.find({ rule: { pattern: "import { $_ } from 'node:url'" } }) || - rootNode.find({ rule: { pattern: 'import { $_ } from "node:url"' } }); - - if (!hasNodeUrlImport) { - return null; - } - - // 1) Replace parse calls with new URL() - const parseCallPatterns = [ - // member calls on typical identifiers - "url.parse($ARG)", - "nodeUrl.parse($ARG)", - // bare identifier (named import) - "parse($ARG)", - // common alias used in tests - "urlParse($ARG)" - ]; - - for (const pattern of parseCallPatterns) { - const calls = rootNode.findAll({ rule: { pattern } }); - - for (const call of calls) { - const arg = call.getMatch("ARG"); - if (!arg) continue; - const replacement = `new URL(${arg.text()})`; - edits.push(call.replace(replacement)); - } - } - - // 2) Transform legacy properties on URL object - // - auth => `${obj.username}:${obj.password}` - // - path => `${obj.pathname}${obj.search}` - // - hostname => obj.hostname.replace(/^[\[|\]]$/, '') (strip square brackets) - - // Property access: obj.auth -> `${obj.username}:${obj.password}` + const rootNode = root.root(); + const edits: Edit[] = []; + + // Safety: only run on files that import/require node:url + const hasNodeUrlImport = + getNodeImportStatements(root, "url").length > 0 || + getNodeRequireCalls(root, "url").length > 0; + + if (!hasNodeUrlImport) return null; + + // 1) Replace parse calls with new URL() + // todo(@AugustinMauroy): use resolveBindingPath + const parseCallPatterns = [ + // member calls on typical identifiers + "url.parse($ARG)", + "nodeUrl.parse($ARG)", + // bare identifier (named import) + "parse($ARG)", + // common alias used in tests + "urlParse($ARG)" + ]; + + for (const pattern of parseCallPatterns) { + const calls = rootNode.findAll({ rule: { pattern } }); + + for (const call of calls) { + const arg = call.getMatch("ARG"); + if (!arg) continue; + + const replacement = `new URL(${arg.text()})`; + edits.push(call.replace(replacement)); + } + } + + // 2) Transform legacy properties on URL object + // - auth => `${obj.username}:${obj.password}` + // - path => `${obj.pathname}${obj.search}` + // - hostname => obj.hostname.replace(/^[\[|\]]$/, '') (strip square brackets) + + // Property access: obj.auth -> `${obj.username}:${obj.password}` const authAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.auth" } }); for (const node of authAccesses) { const base = node.getMatch("OBJ"); @@ -117,8 +115,7 @@ export default function transform(root: SgRoot): string | null { edits.push(node.replace(replacement)); } - if (!edits.length) return null; + if (!edits.length) return null; - return rootNode.commitEdits(edits); + return rootNode.commitEdits(edits); }; - From d8debdfcfac92df30e0c0ceb47de6f63ad791e44 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 13:57:21 +0200 Subject: [PATCH 22/52] fix: ts --- recipes/node-url-to-whatwg-url/src/url-format.ts | 2 ++ recipes/node-url-to-whatwg-url/src/url-parse.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index e9b0a7b6..55f7592d 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -161,7 +161,9 @@ export default function transform(root: SgRoot): string | null { // Safety: only run on files that import/require node:url const hasNodeUrlImport = + // @ts-ignore getNodeImportStatements(root, "url").length > 0 || + // @ts-ignore getNodeRequireCalls(root, "url").length > 0; if (!hasNodeUrlImport) return null; diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index b879b08e..c22d415b 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -21,8 +21,10 @@ export default function transform(root: SgRoot): string | null { // Safety: only run on files that import/require node:url const hasNodeUrlImport = + // @ts-ignore getNodeImportStatements(root, "url").length > 0 || - getNodeRequireCalls(root, "url").length > 0; + // @ts-ignore + getNodeRequireCalls(root, "url").length > 0; if (!hasNodeUrlImport) return null; From b431aa4fc61cf3cca42277ac9592f22a7e238cb9 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 14:19:13 +0200 Subject: [PATCH 23/52] fix: windows --- .../src/import-process.ts | 223 +++++++++--------- 1 file changed, 106 insertions(+), 117 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/src/import-process.ts b/recipes/node-url-to-whatwg-url/src/import-process.ts index dd7646b4..14cf6be8 100644 --- a/recipes/node-url-to-whatwg-url/src/import-process.ts +++ b/recipes/node-url-to-whatwg-url/src/import-process.ts @@ -3,123 +3,112 @@ import type JS from "@codemod.com/jssg-types/langs/javascript"; import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; import { removeLines } from "@nodejs/codemod-utils/ast-grep/remove-lines"; -// Clean up unused imports/requires from 'node:url' after transforms using shared utils +/** + * Clean up unused imports/requires from 'node:url' after transforms using shared utils + */ export default function transform(root: SgRoot): string | null { - const rootNode = root.root(); - const edits: Edit[] = []; - - // after the migration, replacement of `url.parse` and `url.format` - // we need to check remaining usage of `url` module - // if needed, we can remove the `url` import - // we are going to use bruno's utility to resolve bindings - - const isBindingUsed = (name: string): boolean => { - const refs = rootNode.findAll({ rule: { pattern: name } }); - // Heuristic: declaration counts as one; any other usage yields > 1 - return refs.length > 1; - }; - - const linesToRemove: Range[] = []; - - // 1) ES Module imports: import ... from 'node:url' - // @ts-ignore - ast-grep types vs jssg types - const esmImports = getNodeImportStatements(root, "url"); - - for (const imp of esmImports) { - // Try namespace/default binding - const clause = imp.find({ rule: { kind: "import_clause" } }); - let removed = false; - if (clause) { - // Namespace import like: import * as url from 'node:url' - const nsId = clause.find({ rule: { kind: "namespace_import" } })?.find({ rule: { kind: "identifier" } }); - if (nsId && !isBindingUsed(nsId.text())) { - edits.push(imp.replace("")); - removed = true; - } - if (removed) continue; - - // Named imports bucket - const specs = clause.findAll({ rule: { kind: "import_specifier" } }); - - // Default import like: import url from 'node:url' (only when not a named or namespace import) - if (specs.length === 0 && !nsId) { - const defaultId = clause.find({ rule: { kind: "identifier" } }); - if (defaultId && !isBindingUsed(defaultId.text())) { - edits.push(imp.replace("")); - removed = true; - } - if (removed) continue; - } - - // Named imports: import { a, b as c } from 'node:url' - if (specs.length > 0) { - const keepTexts: string[] = []; - for (const spec of specs) { - const text = spec.text().trim(); - // If alias form exists, use alias as the binding name - const bindingName = text.includes(" as ") ? text.split(/\s+as\s+/)[1] : text; - if (bindingName && isBindingUsed(bindingName)) keepTexts.push(text); - } - if (keepTexts.length === 0) { - linesToRemove.push(imp.range()); - } else if (keepTexts.length !== specs.length) { - const namedImportsNode = clause.find({ rule: { kind: "named_imports" } }); - if (namedImportsNode) edits.push(namedImportsNode.replace(`{ ${keepTexts.join(", ")} }`)); - } - } - } - } - - // 2) CommonJS requires: const ... = require('node:url') - // @ts-ignore - ast-grep types vs jssg types - const requireDecls = getNodeRequireCalls(root, "url"); - - for (const decl of requireDecls) { - // Namespace require: const url = require('node:url') - const id = decl.find({ rule: { kind: "identifier" } }); - const hasObjectPattern = decl.find({ rule: { kind: "object_pattern" } }); - - if (id && !hasObjectPattern) { - if (!isBindingUsed(id.text())) linesToRemove.push(decl.parent().range()); - continue; - } - - // Destructured require: const { parse, format: fmt } = require('node:url') - if (hasObjectPattern) { - const names: string[] = []; - // Shorthand bindings - const shorts = decl.findAll({ rule: { kind: "shorthand_property_identifier_pattern" } }); - for (const s of shorts) names.push(s.text()); - // Aliased bindings (pair_pattern) => use the identifier name (value) - const pairs = decl.findAll({ rule: { kind: "pair_pattern" } }); - for (const pair of pairs) { - const aliasId = pair.find({ rule: { kind: "identifier" } }); - if (aliasId) names.push(aliasId.text()); - } - - const usedTexts: string[] = []; - for (const s of shorts) if (isBindingUsed(s.text())) usedTexts.push(s.text()); - for (const pair of pairs) { - const aliasId = pair.find({ rule: { kind: "identifier" } }); - if (aliasId && isBindingUsed(aliasId.text())) usedTexts.push(pair.text()); // keep full spec text - } - - if (usedTexts.length === 0) { - linesToRemove.push(decl.parent().range()); - } else if (usedTexts.length !== names.length) { - const objPat = decl.find({ rule: { kind: "object_pattern" } }); - if (objPat) edits.push(objPat.replace(`{ ${usedTexts.join(", ")} }`)); - } - } - } - - if (edits.length < 0 || linesToRemove.length < 0) return null; - - // Apply edits, remove whole lines ranges, then normalize leading whitespace only - let source = rootNode.commitEdits(edits); - source = removeLines(source, linesToRemove); - source = source.replace(/^\n+/, ""); - return source; + const rootNode = root.root(); + const edits: Edit[] = []; + + const isBindingUsed = (name: string): boolean => { + const refs = rootNode.findAll({ rule: { pattern: name } }); + // Heuristic: declaration counts as one; any other usage yields > 1 + return refs.length > 1; + }; + + const linesToRemove: Range[] = []; + + // 1) ES Module imports: import ... from 'node:url' + // @ts-ignore - ast-grep types vs jssg types + const esmImports = getNodeImportStatements(root, "url"); + + for (const imp of esmImports) { + const clause = imp.find({ rule: { kind: "import_clause" } }); + let removed = false; + if (clause) { + const nsId = clause.find({ rule: { kind: "namespace_import" } })?.find({ rule: { kind: "identifier" } }); + if (nsId && !isBindingUsed(nsId.text())) { + linesToRemove.push(imp.range()); + removed = true; + } + if (removed) continue; + + const specs = clause.findAll({ rule: { kind: "import_specifier" } }); + + if (specs.length === 0 && !nsId) { + const defaultId = clause.find({ rule: { kind: "identifier" } }); + if (defaultId && !isBindingUsed(defaultId.text())) { + linesToRemove.push(imp.range()); + removed = true; + } + if (removed) continue; + } + + if (specs.length > 0) { + const keepTexts: string[] = []; + for (const spec of specs) { + const text = spec.text().trim(); + const bindingName = text.includes(" as ") ? text.split(/\s+as\s+/)[1] : text; + if (bindingName && isBindingUsed(bindingName)) keepTexts.push(text); + } + if (keepTexts.length === 0) { + linesToRemove.push(imp.range()); + } else if (keepTexts.length !== specs.length) { + const namedImportsNode = clause.find({ rule: { kind: "named_imports" } }); + if (namedImportsNode) edits.push(namedImportsNode.replace(`{ ${keepTexts.join(", ")} }`)); + } + } + } + } + + // 2) CommonJS requires: const ... = require('node:url') + // @ts-ignore - ast-grep types vs jssg types + const requireDecls = getNodeRequireCalls(root, "url"); + + for (const decl of requireDecls) { + const id = decl.find({ rule: { kind: "identifier" } }); + const hasObjectPattern = decl.find({ rule: { kind: "object_pattern" } }); + + if (id && !hasObjectPattern) { + if (!isBindingUsed(id.text())) linesToRemove.push(decl.parent().range()); + continue; + } + + if (hasObjectPattern) { + const names: string[] = []; + const shorts = decl.findAll({ rule: { kind: "shorthand_property_identifier_pattern" } }); + for (const s of shorts) names.push(s.text()); + const pairs = decl.findAll({ rule: { kind: "pair_pattern" } }); + for (const pair of pairs) { + const aliasId = pair.find({ rule: { kind: "identifier" } }); + if (aliasId) names.push(aliasId.text()); + } + + const usedTexts: string[] = []; + for (const s of shorts) if (isBindingUsed(s.text())) usedTexts.push(s.text()); + for (const pair of pairs) { + const aliasId = pair.find({ rule: { kind: "identifier" } }); + if (aliasId && isBindingUsed(aliasId.text())) usedTexts.push(pair.text()); + } + + if (usedTexts.length === 0) { + linesToRemove.push(decl.parent().range()); + } else if (usedTexts.length !== names.length) { + const objPat = decl.find({ rule: { kind: "object_pattern" } }); + if (objPat) edits.push(objPat.replace(`{ ${usedTexts.join(", ")} }`)); + } + } + } + + if (edits.length === 0 && linesToRemove.length === 0) return null; + + let source = rootNode.commitEdits(edits); + + source = removeLines(source, linesToRemove.map(range => ({ + start: { line: range.start.line, column: 0, index: 0 }, + end: { line: range.end.line + 1, column: 0, index: 0 } + }))); + + return source; }; - From 25f7df2e80ad893d9f82b17d2201dbb131995654 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 14:36:27 +0200 Subject: [PATCH 24/52] use resolve utility --- .../node-url-to-whatwg-url/src/url-format.ts | 25 +++++++++------- .../node-url-to-whatwg-url/src/url-parse.ts | 29 ++++++++++--------- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index 55f7592d..fc900b83 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -2,6 +2,7 @@ import type { SgRoot, Edit, SgNode } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; +import { resolveBindingPath } from "@nodejs/codemod-utils/ast-grep/resolve-binding-path"; /** * Get the literal text value of a node, if it exists. @@ -169,20 +170,24 @@ export default function transform(root: SgRoot): string | null { if (!hasNodeUrlImport) return null; // Look for various ways format can be referenced - // todo(@AugustinMauroy): use resolveBindingPath - const patterns = [ - "url.format($OPTIONS)", - "nodeUrl.format($OPTIONS)", - "format($OPTIONS)", - "urlFormat($OPTIONS)" - ]; + // Build patterns using resolveBindingPath for both import and require forms + // @ts-ignore - type difference between jssg and ast-grep wrappers + const importNodes = getNodeImportStatements(root, "url"); + // @ts-ignore - type difference between jssg and ast-grep wrappers + const requireNodes = getNodeRequireCalls(root, "url"); + const patterns = new Set(); + + for (const node of [...importNodes, ...requireNodes]) { + // @ts-ignore - helper accepts ast-grep SgNode; runtime compatible + const binding = resolveBindingPath(node, "$.format"); + if (!binding) continue; + patterns.add(`${binding}($OPTIONS)`); + } for (const pattern of patterns) { const calls = rootNode.findAll({ rule: { pattern } }); - if (calls.length) { - urlFormatToUrlToString(calls, edits); - } + if (calls.length) urlFormatToUrlToString(calls, edits); } if (!edits.length) return null; diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index c22d415b..9a5316cf 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -1,7 +1,8 @@ -import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; -import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; +import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; +import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; +import { resolveBindingPath } from "@nodejs/codemod-utils/ast-grep/resolve-binding-path"; /** * Transforms `url.parse` usage to `new URL()`. @@ -28,17 +29,19 @@ export default function transform(root: SgRoot): string | null { if (!hasNodeUrlImport) return null; - // 1) Replace parse calls with new URL() - // todo(@AugustinMauroy): use resolveBindingPath - const parseCallPatterns = [ - // member calls on typical identifiers - "url.parse($ARG)", - "nodeUrl.parse($ARG)", - // bare identifier (named import) - "parse($ARG)", - // common alias used in tests - "urlParse($ARG)" - ]; + // 1) Replace parse calls with new URL() using binding-aware patterns + // @ts-ignore - type difference between jssg and ast-grep wrappers + const importNodes = getNodeImportStatements(root, "url"); + // @ts-ignore - type difference between jssg and ast-grep wrappers + const requireNodes = getNodeRequireCalls(root, "url"); + const parseCallPatterns = new Set(); + + for (const node of [...importNodes, ...requireNodes]) { + // @ts-ignore resolve across wrappers + const binding = resolveBindingPath(node, "$.parse"); + if (!binding) continue; + parseCallPatterns.add(`${binding}($ARG)`); + } for (const pattern of parseCallPatterns) { const calls = rootNode.findAll({ rule: { pattern } }); From 36f3b7929adabbce958645e827a6a96458f5d46a Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 20:49:51 +0200 Subject: [PATCH 25/52] add more supported case for `url-format` --- .../node-url-to-whatwg-url/src/url-format.ts | 213 ++++++++++++++---- .../tests/url-format/expected/file-1.js | 6 + .../tests/url-format/input/file-1.js | 19 +- 3 files changed, 189 insertions(+), 49 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index fc900b83..93de6642 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -10,6 +10,7 @@ import { resolveBindingPath } from "@nodejs/codemod-utils/ast-grep/resolve-bindi * @returns The literal text value, or undefined if not found */ const getLiteralText = (node: SgNode | null | undefined): string | undefined => { + if (!node) return undefined; const kind = node.kind(); if (kind === "string") { @@ -23,8 +24,8 @@ const getLiteralText = (node: SgNode | null | undefined): string | undefined /** * Transforms url.format() calls to new URL().toString() - * @param callNode The AST node representing the url.format() call - * @returns The transformed code + * @param callNode The AST nodes representing the url.format() calls + * @param edits The edits collector */ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { for (const call of callNode) { @@ -35,18 +36,42 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { const objectNode = optionsMatch.find({ rule: { kind: "object" } }); if (!objectNode) continue; + type V = { literal: true; text: string } | { literal: false; code: string }; const urlState: { - protocol?: string; - auth?: string; // user:pass - host?: string; // host:port - hostname?: string; - port?: string; - pathname?: string; - search?: string; // ?a=b - hash?: string; // #frag + protocol?: V; + auth?: V; // user:pass + host?: V; // host:port + hostname?: V; + port?: V; + pathname?: V; + search?: V; // ?a=b + hash?: V; // #frag queryParams?: Array<[string, string]>; } = {}; + const getValue = (pair: SgNode): V | undefined => { + // string/number/bool + const litNode = pair.find({ + rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] }, + }); + const lit = getLiteralText(litNode); + if (lit !== undefined) return { literal: true, text: lit }; + + // identifier value + const idNode = pair.find({ rule: { kind: "identifier" } }); + if (idNode) return { literal: false, code: idNode.text() }; + + // shorthand property + const shorthand = pair.find({ rule: { kind: "shorthand_property_identifier" } }); + if (shorthand) return { literal: false, code: shorthand.text() }; + + // template string value + const template = pair.find({ rule: { kind: "template_string" } }); + if (template) return { literal: false, code: template.text() }; + + return undefined; + }; + const pairs = objectNode.findAll({ rule: { kind: "pair" } }); for (const pair of pairs) { @@ -62,7 +87,9 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { const list: Array<[string, string]> = []; for (const qp of qpairs) { const qkeyNode = qp.find({ rule: { kind: "property_identifier" } }); - const qvalLiteral = getLiteralText(qp.find({ rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] } })); + const qvalLiteral = getLiteralText( + qp.find({ rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] } }), + ); if (qkeyNode && qvalLiteral !== undefined) list.push([qkeyNode.text(), qvalLiteral]); } urlState.queryParams = list; @@ -70,42 +97,42 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { continue; } - // value might be string/number/bool - const valueLiteral = getLiteralText(pair.find({ rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] } })); - if (valueLiteral === undefined) continue; + // value might be literal or identifier/shorthand/template + const val = getValue(pair); + if (!val) continue; switch (key) { case "protocol": { - // normalize without trailing ':' - urlState.protocol = valueLiteral.replace(/:$/, ""); + if (val.literal) urlState.protocol = { literal: true, text: val.text.replace(/:$/, "") }; + else urlState.protocol = val; break; } case "auth": { - urlState.auth = valueLiteral; // 'user:pass' + urlState.auth = val; break; } case "host": { - urlState.host = valueLiteral; // 'example.com:8080' + urlState.host = val; break; } case "hostname": { - urlState.hostname = valueLiteral; + urlState.hostname = val; break; } case "port": { - urlState.port = valueLiteral; + urlState.port = val; break; } case "pathname": { - urlState.pathname = valueLiteral; + urlState.pathname = val; break; } case "search": { - urlState.search = valueLiteral; + urlState.search = val; break; } case "hash": { - urlState.hash = valueLiteral; + urlState.hash = val; break; } default: @@ -114,32 +141,125 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { } } - const proto = urlState.protocol ?? ""; - const auth = urlState.auth ? `${urlState.auth}@` : ""; - let host = urlState.host; - if (!host) { - if (urlState.hostname && urlState.port) host = `${urlState.hostname}:${urlState.port}`; - else if (urlState.hostname) host = urlState.hostname; - else host = ""; + // Also handle shorthand properties like `{ search }` + const shorthands = objectNode.findAll({ rule: { kind: "shorthand_property_identifier" } }); + for (const sh of shorthands) { + const name = sh.text(); + const v: V = { literal: false, code: name }; + switch (name) { + case "protocol": + urlState.protocol = v; + break; + case "auth": + urlState.auth = v; + break; + case "host": + urlState.host = v; + break; + case "hostname": + urlState.hostname = v; + break; + case "port": + urlState.port = v; + break; + case "pathname": + urlState.pathname = v; + break; + case "search": + urlState.search = v; + break; + case "hash": + urlState.hash = v; + break; + default: + break; + } } - let pathname = urlState.pathname ?? ""; - if (pathname && !pathname.startsWith("/")) pathname = `/${pathname}`; + // Build output segments + type Seg = { type: "lit"; text: string } | { type: "expr"; code: string }; + const segs: Seg[] = []; + const pushVal = (v?: V) => { + if (!v) return; + if (v.literal) { + if (v.text) segs.push({ type: "lit", text: v.text }); + } else { + // v is the non-literal branch here + segs.push({ type: "expr", code: (v as Extract).code }); + } + }; - let search = urlState.search ?? ""; - if (!search && urlState.queryParams && urlState.queryParams.length > 0) { + // protocol:// + if (urlState.protocol) { + pushVal(urlState.protocol); + segs.push({ type: "lit", text: "://" }); + } + + // auth@ + if (urlState.auth) { + pushVal(urlState.auth); + segs.push({ type: "lit", text: "@" }); + } + + // host or hostname[:port] + if (urlState.host) { + pushVal(urlState.host); + } else { + if (urlState.hostname) pushVal(urlState.hostname); + if (urlState.port) { + if (urlState.hostname) segs.push({ type: "lit", text: ":" }); + pushVal(urlState.port); + } + } + + // pathname + if (urlState.pathname) { + const p = urlState.pathname; + if (p.literal) { + const text = p.text && !p.text.startsWith("/") ? `/${p.text}` : p.text; + if (text) segs.push({ type: "lit", text }); + } else { + pushVal(p); + } + } + + // search or build from query + if (urlState.search) { + const s = urlState.search; + if (s.literal) { + const text = s.text ? (s.text.startsWith("?") ? s.text : `?${s.text}`) : ""; + if (text) segs.push({ type: "lit", text }); + } else { + pushVal(s); + } + } else if (urlState.queryParams && urlState.queryParams.length > 0) { const qs = urlState.queryParams.map(([k, v]) => `${k}=${v}`).join("&"); - search = `?${qs}`; + if (qs) segs.push({ type: "lit", text: `?${qs}` }); + } + + // hash + if (urlState.hash) { + const h = urlState.hash; + if (h.literal) { + const text = h.text ? (h.text.startsWith("#") ? h.text : `#${h.text}`) : ""; + if (text) segs.push({ type: "lit", text }); + } else { + pushVal(h); + } } - if (search && !search.startsWith("?")) search = `?${search}`; - let hash = urlState.hash ?? ""; - if (hash && !hash.startsWith("#")) hash = `#${hash}`; + if (!segs.length) continue; - const base = proto ? `${proto}://` : ""; - const urlString = `${base}${auth}${host}${pathname}${search}${hash}`; + const hasExpr = segs.some((s) => s.type === "expr"); + let finalExpr: string; + if (hasExpr) { + const esc = (s: string) => s.replace(/`/g, "\\`").replace(/\\\$/g, "\\\\$").replace(/\$\{/g, "\\${"); + finalExpr = `\`${segs.map((s) => (s.type === "lit" ? esc(s.text) : `\${${s.code}}`)).join("")}\``; + } else { + finalExpr = `'${segs.map((s) => (s.type === "lit" ? s.text : "")).join("")}'`; + } - const replacement = `new URL('${urlString}').toString()`; + const replacement = `new URL(${finalExpr}).toString()`; edits.push(call.replace(replacement)); } } @@ -169,8 +289,7 @@ export default function transform(root: SgRoot): string | null { if (!hasNodeUrlImport) return null; - // Look for various ways format can be referenced - // Build patterns using resolveBindingPath for both import and require forms + // Look for various ways format can be referenced; build binding-aware patterns // @ts-ignore - type difference between jssg and ast-grep wrappers const importNodes = getNodeImportStatements(root, "url"); // @ts-ignore - type difference between jssg and ast-grep wrappers @@ -180,10 +299,14 @@ export default function transform(root: SgRoot): string | null { for (const node of [...importNodes, ...requireNodes]) { // @ts-ignore - helper accepts ast-grep SgNode; runtime compatible const binding = resolveBindingPath(node, "$.format"); - if (!binding) continue; - patterns.add(`${binding}($OPTIONS)`); + if (binding) patterns.add(`${binding}($OPTIONS)`); } + // Fallbacks for common names and tests + ["url.format($OPTIONS)", "nodeUrl.format($OPTIONS)", "format($OPTIONS)", "urlFormat($OPTIONS)"].forEach((p) => + patterns.add(p), + ); + for (const pattern of patterns) { const calls = rootNode.findAll({ rule: { pattern } }); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-1.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-1.js index dc398267..b6f28419 100644 --- a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-1.js +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-1.js @@ -1,3 +1,9 @@ const url = require('node:url'); const str = new URL('https://example.com/some/path?page=1').toString(); + +const foo = 'https'; + +const search = '?page=1'; + +const str2 = new URL(`${foo}://example.com/some/path${search}`).toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-1.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-1.js index e95d6e40..c33c1224 100644 --- a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-1.js +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-1.js @@ -1,8 +1,19 @@ const url = require('node:url'); const str = url.format({ - protocol: 'https', - hostname: 'example.com', - pathname: '/some/path', - search: '?page=1' + protocol: 'https', + hostname: 'example.com', + pathname: '/some/path', + search: '?page=1' +}); + +const foo = 'https'; + +const search = '?page=1'; + +const str2 = url.format({ + protocol: foo, + hostname: 'example.com', + pathname: '/some/path', + search }); From 821e3ee5538063d8a5e0a18b543ce124607d89e9 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 20:57:39 +0200 Subject: [PATCH 26/52] test: add more case --- .../tests/url-format/expected/file-4.mjs | 3 +++ .../tests/url-format/expected/file-5.mjs | 3 +++ .../tests/url-format/expected/file-6.js | 3 +++ .../tests/url-format/expected/file-7.js | 6 ++++++ .../tests/url-format/expected/file-8.js | 3 +++ .../tests/url-format/input/file-4.mjs | 7 +++++++ .../tests/url-format/input/file-5.mjs | 8 ++++++++ .../tests/url-format/input/file-6.js | 8 ++++++++ .../tests/url-format/input/file-7.js | 11 +++++++++++ .../tests/url-format/input/file-8.js | 8 ++++++++ 10 files changed, 60 insertions(+) create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-4.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-5.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-6.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-7.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-8.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-4.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-5.mjs create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-6.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-7.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-8.js diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-4.mjs b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-4.mjs new file mode 100644 index 00000000..56dc93a6 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-4.mjs @@ -0,0 +1,3 @@ +import url from 'node:url'; + +const a = new URL('https://example.com/esm-default').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-5.mjs b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-5.mjs new file mode 100644 index 00000000..8fafd63c --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-5.mjs @@ -0,0 +1,3 @@ +import { format as fmt } from 'node:url'; + +const b = new URL('http://example.org:8080/path?q=1').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-6.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-6.js new file mode 100644 index 00000000..54c9109f --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-6.js @@ -0,0 +1,3 @@ +const { format } = require('node:url'); + +const c = new URL('https://user:pass@example.com/has-auth').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-7.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-7.js new file mode 100644 index 00000000..65210f4e --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-7.js @@ -0,0 +1,6 @@ +const { format } = require('node:url'); + +const search = 'page=2'; +const proto = 'https:'; + +const d = new URL(`${proto}://example.com:443/norm${search}`).toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-8.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-8.js new file mode 100644 index 00000000..8e28a1fc --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-8.js @@ -0,0 +1,3 @@ +const { format } = require('node:url'); + +const e = new URL('https://example.com:3000/no-leading-slash').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-4.mjs b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-4.mjs new file mode 100644 index 00000000..d6a5b131 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-4.mjs @@ -0,0 +1,7 @@ +import url from 'node:url'; + +const a = url.format({ + protocol: 'https', + hostname: 'example.com', + pathname: '/esm-default', +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-5.mjs b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-5.mjs new file mode 100644 index 00000000..c6905fbf --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-5.mjs @@ -0,0 +1,8 @@ +import { format as fmt } from 'node:url'; + +const b = fmt({ + protocol: 'http', + host: 'example.org:8080', + pathname: 'path', + search: 'q=1', +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-6.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-6.js new file mode 100644 index 00000000..1477a48a --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-6.js @@ -0,0 +1,8 @@ +const { format } = require('node:url'); + +const c = format({ + protocol: 'https', + auth: 'user:pass', + hostname: 'example.com', + pathname: '/has-auth', +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-7.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-7.js new file mode 100644 index 00000000..862906a9 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-7.js @@ -0,0 +1,11 @@ +const { format } = require('node:url'); + +const search = 'page=2'; +const proto = 'https:'; + +const d = format({ + protocol: proto, + host: 'example.com:443', + pathname: '/norm', + search, +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-8.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-8.js new file mode 100644 index 00000000..7c6ff694 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-8.js @@ -0,0 +1,8 @@ +const { format } = require('node:url'); + +const e = format({ + protocol: 'https', + hostname: 'example.com', + port: '3000', + pathname: 'no-leading-slash', +}); From 0713b1e3e0e547f25dc59ba45c61616c77704833 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 21:38:09 +0200 Subject: [PATCH 27/52] add support of semicolon --- recipes/node-url-to-whatwg-url/src/url-format.ts | 6 ++++-- recipes/node-url-to-whatwg-url/src/url-parse.ts | 14 ++++++++------ .../tests/url-format/expected/file-10.js | 3 +++ .../tests/url-format/expected/file-9.js | 3 +++ .../tests/url-format/input/file-10.js | 9 +++++++++ .../tests/url-format/input/file-9.js | 8 ++++++++ .../tests/url-parse/expected/file-9.js | 12 ++++++++++++ .../tests/url-parse/input/file-9.js | 12 ++++++++++++ 8 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-10.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/file-9.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-10.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/file-9.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-9.js create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/file-9.js diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index 93de6642..027e4ea2 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -259,8 +259,10 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { finalExpr = `'${segs.map((s) => (s.type === "lit" ? s.text : "")).join("")}'`; } - const replacement = `new URL(${finalExpr}).toString()`; - edits.push(call.replace(replacement)); + // Include semicolon if original statement had one + const hadSemi = /;\s*$/.test(call.text()); + const replacement = `new URL(${finalExpr}).toString()${hadSemi ? ';' : ''}`; + edits.push(call.replace(replacement)); } } diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index 9a5316cf..f62ebf56 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -75,8 +75,9 @@ export default function transform(root: SgRoot): string | null { for (const node of authDestructures) { const base = node.getMatch("OBJ"); if (!base) continue; - - const replacement = `const auth = \`\${${base.text()}.username}:\${${base.text()}.password}\`;`; + const hadSemi = /;\s*$/.test(node.text()); + const name = base.text(); + const replacement = `const auth = \`${'${'}${name}.username${'}'}:${'${'}${name}.password${'}'}\`${hadSemi ? ';' : ''}`; edits.push(node.replace(replacement)); } @@ -95,8 +96,9 @@ export default function transform(root: SgRoot): string | null { for (const node of pathDestructures) { const base = node.getMatch("OBJ"); if (!base) continue; - - const replacement = `const path = \`\${${base.text()}.pathname}\${${base.text()}.search}\`;`; + const hadSemi = /;\s*$/.test(node.text()); + const name = base.text(); + const replacement = `const path = \`${'${'}${name}.pathname${'}'}${'${'}${name}.search${'}'}\`${hadSemi ? ';' : ''}`; edits.push(node.replace(replacement)); } @@ -115,8 +117,8 @@ export default function transform(root: SgRoot): string | null { for (const node of hostnameDestructures) { const base = node.getMatch("OBJ"); if (!base) continue; - - const replacement = `const hostname = ${base.text()}.hostname.replace(/^\\[|\\]$/, '');`; + const hadSemi = /;\s*$/.test(node.text()); + const replacement = `const hostname = ${base.text()}.hostname.replace(/^\\[|\\]$/, '')${hadSemi ? ';' : ''}`; edits.push(node.replace(replacement)); } diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-10.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-10.js new file mode 100644 index 00000000..5f942c25 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-10.js @@ -0,0 +1,3 @@ +const { format } = require('node:url') + +const a = new URL('https://example.com:8080/p?page=1&format=json#frag').toString() diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-9.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-9.js new file mode 100644 index 00000000..18a33bea --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-9.js @@ -0,0 +1,3 @@ +const url = require('node:url') + +const str = new URL('https://example.com/some/path?page=1').toString() diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-10.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-10.js new file mode 100644 index 00000000..f69be481 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-10.js @@ -0,0 +1,9 @@ +const { format } = require('node:url') + +const a = format({ + protocol: 'https:', + host: 'example.com:8080', + pathname: 'p', + query: { page: '1', format: 'json' }, + hash: 'frag', +}) diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-9.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-9.js new file mode 100644 index 00000000..83897892 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-9.js @@ -0,0 +1,8 @@ +const url = require('node:url') + +const str = url.format({ + protocol: 'https', + hostname: 'example.com', + pathname: '/some/path', + search: '?page=1' +}) diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-9.js b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-9.js new file mode 100644 index 00000000..c861e91b --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-9.js @@ -0,0 +1,12 @@ +const url = require('node:url') + +const myURL = new URL('https://example.com/path?query=string#hash') + +const auth = `${myURL.username}:${myURL.password}` +const urlAuth = `${myURL.username}:${myURL.password}` + +const path = `${myURL.pathname}${myURL.search}` +const urlPath = `${myURL.pathname}${myURL.search}` + +const hostname = myURL.hostname.replace(/^\[|\]$/, '') +const urlHostname = myURL.hostname.replace(/^\[|\]$/, '') diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-9.js b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-9.js new file mode 100644 index 00000000..66f4ac90 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-9.js @@ -0,0 +1,12 @@ +const url = require('node:url') + +const myURL = url.parse('https://example.com/path?query=string#hash') + +const { auth } = myURL +const urlAuth = myURL.auth + +const { path } = myURL +const urlPath = myURL.path + +const { hostname } = myURL +const urlHostname = myURL.hostname From 4b06973bd0ed98171bb7039b39a31b88cb9bbcd4 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 21:45:14 +0200 Subject: [PATCH 28/52] Update package.json --- recipes/node-url-to-whatwg-url/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/recipes/node-url-to-whatwg-url/package.json b/recipes/node-url-to-whatwg-url/package.json index dfa865bb..670200e0 100644 --- a/recipes/node-url-to-whatwg-url/package.json +++ b/recipes/node-url-to-whatwg-url/package.json @@ -4,7 +4,7 @@ "description": "Handle DEPDEP0116 via transforming `url.parse` to `new URL()`", "type": "module", "scripts": { - "test": "node --run test:import-process && node --run test:url-format && node --run test:url-parse", + "test": "node --run test:import-process; node --run test:url-format; node --run test:url-parse", "test:import-process": "npx codemod@next jssg test -l typescript ./src/import-process.ts ./tests/ --filter import-process", "test:url-format": "npx codemod@next jssg test -l typescript ./src/url-format.ts ./tests/ --filter url-format", "test:url-parse": "npx codemod@next jssg test -l typescript ./src/url-parse.ts ./tests/ --filter url-parse" From 9eb2c0db3ab6f3e7b606fe9f6ac6c294b6e8b8c7 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 21:48:30 +0200 Subject: [PATCH 29/52] Revert "Update package.json" This reverts commit c5ce549c01af0aa713cefc6107db617a376e2f02. --- recipes/node-url-to-whatwg-url/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/recipes/node-url-to-whatwg-url/package.json b/recipes/node-url-to-whatwg-url/package.json index 670200e0..dfa865bb 100644 --- a/recipes/node-url-to-whatwg-url/package.json +++ b/recipes/node-url-to-whatwg-url/package.json @@ -4,7 +4,7 @@ "description": "Handle DEPDEP0116 via transforming `url.parse` to `new URL()`", "type": "module", "scripts": { - "test": "node --run test:import-process; node --run test:url-format; node --run test:url-parse", + "test": "node --run test:import-process && node --run test:url-format && node --run test:url-parse", "test:import-process": "npx codemod@next jssg test -l typescript ./src/import-process.ts ./tests/ --filter import-process", "test:url-format": "npx codemod@next jssg test -l typescript ./src/url-format.ts ./tests/ --filter url-format", "test:url-parse": "npx codemod@next jssg test -l typescript ./src/url-parse.ts ./tests/ --filter url-parse" From b8934287dcbdfcc682b038bce04577c4bbd310bf Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 13 Aug 2025 00:23:06 +0200 Subject: [PATCH 30/52] update --- recipes/node-url-to-whatwg-url/README.md | 19 +++++++++++++------ recipes/node-url-to-whatwg-url/codemod.yaml | 2 +- recipes/node-url-to-whatwg-url/package.json | 8 ++++---- recipes/node-url-to-whatwg-url/workflow.yaml | 15 +++++++++------ 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/README.md b/recipes/node-url-to-whatwg-url/README.md index 3f531ad2..a3b98a54 100644 --- a/recipes/node-url-to-whatwg-url/README.md +++ b/recipes/node-url-to-whatwg-url/README.md @@ -6,22 +6,26 @@ See [DEP0116](https://nodejs.org/api/deprecations.html#DEP0116). ## Example +### `url.parse` to `new URL()` -**`url.parse` to `new URL()`** +**Before:** ```js -// Before const url = require('node:url'); const myUrl = new url.URL('https://example.com'); const urlAuth = legacyURL.auth; -// After +``` + +**After:** +```js const myUrl = new URL('https://example.com'); const urlAuth = `${myUrl.username}:${myUrl.password}`; ``` -**`url.format` to `myUrl.toString()`** +### `url.format` to `myUrl.toString() + +**Before:** ```js -// Before const url = require('node:url'); url.format({ @@ -33,7 +37,10 @@ url.format({ format: 'json', }, }); -// After +``` + +**After:** +```js const myUrl = new URL('https://example.com/some/path?page=1&format=json').toString(); ``` diff --git a/recipes/node-url-to-whatwg-url/codemod.yaml b/recipes/node-url-to-whatwg-url/codemod.yaml index aa86d9f9..15a429b8 100644 --- a/recipes/node-url-to-whatwg-url/codemod.yaml +++ b/recipes/node-url-to-whatwg-url/codemod.yaml @@ -1,7 +1,7 @@ schema_version: "1.0" name: "@nodejs/node-url-to-whatwg-url" version: 1.0.0 -description: Handle DEPDEP0116 via transforming `url.parse` to `new URL()` +description: Handle DEP0116 via transforming `url.parse` to `new URL()` author: Augustin Mauroy license: MIT workflow: workflow.yaml diff --git a/recipes/node-url-to-whatwg-url/package.json b/recipes/node-url-to-whatwg-url/package.json index dfa865bb..9c149108 100644 --- a/recipes/node-url-to-whatwg-url/package.json +++ b/recipes/node-url-to-whatwg-url/package.json @@ -1,13 +1,13 @@ { "name": "@nodejs/node-url-to-whatwg-url", "version": "1.0.0", - "description": "Handle DEPDEP0116 via transforming `url.parse` to `new URL()`", + "description": "Handle DEP0116 via transforming `url.parse` to `new URL()`", "type": "module", "scripts": { "test": "node --run test:import-process && node --run test:url-format && node --run test:url-parse", - "test:import-process": "npx codemod@next jssg test -l typescript ./src/import-process.ts ./tests/ --filter import-process", - "test:url-format": "npx codemod@next jssg test -l typescript ./src/url-format.ts ./tests/ --filter url-format", - "test:url-parse": "npx codemod@next jssg test -l typescript ./src/url-parse.ts ./tests/ --filter url-parse" + "test:import-process": "npx codemod jssg test -l typescript ./src/import-process.ts ./tests/ --filter import-process", + "test:url-format": "npx codemod jssg test -l typescript ./src/url-format.ts ./tests/ --filter url-format", + "test:url-parse": "npx codemod jssg test -l typescript ./src/url-parse.ts ./tests/ --filter url-parse" }, "repository": { "type": "git", diff --git a/recipes/node-url-to-whatwg-url/workflow.yaml b/recipes/node-url-to-whatwg-url/workflow.yaml index f1b03b02..2bc23591 100644 --- a/recipes/node-url-to-whatwg-url/workflow.yaml +++ b/recipes/node-url-to-whatwg-url/workflow.yaml @@ -1,4 +1,7 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod-com/codemod/refs/heads/main/schemas/workflow.json + version: "1" + nodes: - id: apply-transforms name: Apply AST Transformations @@ -11,11 +14,11 @@ nodes: js_file: src/url-parse.ts base_path: . include: + - "**/*.cjs" + - "**/*.cts" - "**/*.js" - "**/*.jsx" - "**/*.mjs" - - "**/*.cjs" - - "**/*.cts" - "**/*.mts" - "**/*.ts" - "**/*.tsx" @@ -27,11 +30,11 @@ nodes: js_file: src/url-format.ts base_path: . include: + - "**/*.cjs" + - "**/*.cts" - "**/*.js" - "**/*.jsx" - "**/*.mjs" - - "**/*.cjs" - - "**/*.cts" - "**/*.mts" - "**/*.ts" - "**/*.tsx" @@ -43,11 +46,11 @@ nodes: js_file: src/import-process.ts base_path: . include: + - "**/*.cjs" + - "**/*.cts" - "**/*.js" - "**/*.jsx" - "**/*.mjs" - - "**/*.cjs" - - "**/*.cts" - "**/*.mts" - "**/*.ts" - "**/*.tsx" From 36944422f9727b56752415ed7434d4f80c0c76a2 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 13 Aug 2025 00:31:18 +0200 Subject: [PATCH 31/52] update logic --- package-lock.json | 11 ++----- .../node-url-to-whatwg-url/src/url-format.ts | 31 +++++++------------ 2 files changed, 14 insertions(+), 28 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6ba8dfa5..0e5809ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4092,14 +4092,6 @@ "@codemod.com/jssg-types": "^1.0.3" } }, - "recipes/node-url-to-whatwg-url": { - "name": "@nodejs/node-url-to-whatwg-url", - "version": "1.0.0", - "license": "MIT", - "devDependencies": { - "@codemod.com/jssg-types": "^1.0.3" - } - }, "recipes/process-main-module": { "name": "@nodejs/process-main-module", "version": "1.0.1", @@ -4130,7 +4122,7 @@ "@nodejs/codemod-utils": "*" }, "devDependencies": { - "@types/node": "^24.2.1" + "@codemod.com/jssg-types": "^1.0.3" } }, "utils": { @@ -4139,6 +4131,7 @@ "license": "MIT", "devDependencies": { "@ast-grep/napi": "^0.39.3", + "@codemod.com/jssg-types": "^1.0.3", "dedent": "^1.6.0" } } diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index 027e4ea2..c3628780 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -283,33 +283,26 @@ export default function transform(root: SgRoot): string | null { const edits: Edit[] = []; // Safety: only run on files that import/require node:url - const hasNodeUrlImport = - // @ts-ignore - getNodeImportStatements(root, "url").length > 0 || - // @ts-ignore - getNodeRequireCalls(root, "url").length > 0; - - if (!hasNodeUrlImport) return null; - - // Look for various ways format can be referenced; build binding-aware patterns - // @ts-ignore - type difference between jssg and ast-grep wrappers - const importNodes = getNodeImportStatements(root, "url"); - // @ts-ignore - type difference between jssg and ast-grep wrappers - const requireNodes = getNodeRequireCalls(root, "url"); - const patterns = new Set(); - - for (const node of [...importNodes, ...requireNodes]) { + const importNodes = getNodeImportStatements(root, "url"); + const requireNodes = getNodeRequireCalls(root, "url"); + const requiresImports = [...importNodes, ...requireNodes] + + if (!requiresImports.length) return null; + + const parseCallPatterns = new Set(); + + for (const node of requiresImports) { // @ts-ignore - helper accepts ast-grep SgNode; runtime compatible const binding = resolveBindingPath(node, "$.format"); - if (binding) patterns.add(`${binding}($OPTIONS)`); + if (binding) parseCallPatterns.add(`${binding}($OPTIONS)`); } // Fallbacks for common names and tests ["url.format($OPTIONS)", "nodeUrl.format($OPTIONS)", "format($OPTIONS)", "urlFormat($OPTIONS)"].forEach((p) => - patterns.add(p), + parseCallPatterns.add(p), ); - for (const pattern of patterns) { + for (const pattern of parseCallPatterns) { const calls = rootNode.findAll({ rule: { pattern } }); if (calls.length) urlFormatToUrlToString(calls, edits); From e571ce785e17a2fddfd566fe897fdbc44790fb96 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 13 Aug 2025 00:36:05 +0200 Subject: [PATCH 32/52] Update url-format.ts --- recipes/node-url-to-whatwg-url/src/url-format.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index c3628780..23be645f 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -283,8 +283,8 @@ export default function transform(root: SgRoot): string | null { const edits: Edit[] = []; // Safety: only run on files that import/require node:url - const importNodes = getNodeImportStatements(root, "url"); - const requireNodes = getNodeRequireCalls(root, "url"); + const importNodes = getNodeImportStatements(root as undefined, "url"); + const requireNodes = getNodeRequireCalls(root as undefined, "url"); const requiresImports = [...importNodes, ...requireNodes] if (!requiresImports.length) return null; From d240d5a0f17dc5940e67e4d212294e5237293fbd Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 13 Aug 2025 00:39:10 +0200 Subject: [PATCH 33/52] Update url-parse.ts --- .../node-url-to-whatwg-url/src/url-parse.ts | 98 +++++++++---------- 1 file changed, 48 insertions(+), 50 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index f62ebf56..5b4d9e71 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -71,56 +71,54 @@ export default function transform(root: SgRoot): string | null { } // Destructuring: const { auth } = obj -> const auth = `${obj.username}:${obj.password}` - const authDestructures = rootNode.findAll({ rule: { pattern: "const { auth } = $OBJ" } }); - for (const node of authDestructures) { - const base = node.getMatch("OBJ"); - if (!base) continue; - const hadSemi = /;\s*$/.test(node.text()); - const name = base.text(); - const replacement = `const auth = \`${'${'}${name}.username${'}'}:${'${'}${name}.password${'}'}\`${hadSemi ? ';' : ''}`; - edits.push(node.replace(replacement)); - } - - // Property access: obj.path -> `${obj.pathname}${obj.search}` - const pathAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.path" } }); - for (const node of pathAccesses) { - const base = node.getMatch("OBJ"); - if (!base) continue; - - const replacement = `\`\${${base.text()}.pathname}\${${base.text()}.search}\``; - edits.push(node.replace(replacement)); - } - - // Destructuring: const { path } = obj -> const path = `${obj.pathname}${obj.search}` - const pathDestructures = rootNode.findAll({ rule: { pattern: "const { path } = $OBJ" } }); - for (const node of pathDestructures) { - const base = node.getMatch("OBJ"); - if (!base) continue; - const hadSemi = /;\s*$/.test(node.text()); - const name = base.text(); - const replacement = `const path = \`${'${'}${name}.pathname${'}'}${'${'}${name}.search${'}'}\`${hadSemi ? ';' : ''}`; - edits.push(node.replace(replacement)); - } - - // Property access: obj.hostname -> obj.hostname.replace(/^\[|\]$/, '') - const hostnameAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.hostname" } }); - for (const node of hostnameAccesses) { - const base = node.getMatch("OBJ"); - if (!base) continue; - - const replacement = `${base.text()}.hostname.replace(/^\\[|\\]$/, '')`; - edits.push(node.replace(replacement)); - } - - // Destructuring: const { hostname } = obj -> const hostname = obj.hostname.replace(/^\[|\]$/, '') - const hostnameDestructures = rootNode.findAll({ rule: { pattern: "const { hostname } = $OBJ" } }); - for (const node of hostnameDestructures) { - const base = node.getMatch("OBJ"); - if (!base) continue; - const hadSemi = /;\s*$/.test(node.text()); - const replacement = `const hostname = ${base.text()}.hostname.replace(/^\\[|\\]$/, '')${hadSemi ? ';' : ''}`; - edits.push(node.replace(replacement)); - } + const fieldsToReplace = [ + { + key: "auth", + replaceFn: (base: string, hadSemi: boolean) => + `const auth = \`\${${base}.username}:\${${base}.password}\`${hadSemi ? ";" : ""}`, + }, + { + key: "path", + replaceFn: (base: string, hadSemi: boolean) => + `const path = \`\${${base}.pathname}\${${base}.search}\`${hadSemi ? ";" : ""}`, + }, + { + key: "hostname", + replaceFn: (base: string, hadSemi: boolean) => + `const hostname = ${base}.hostname.replace(/^\\[|\\]$/, '')${hadSemi ? ";" : ""}`, + }, + ]; + + for (const { key, replaceFn } of fieldsToReplace) { + // Handle property access + const propertyAccesses = rootNode.findAll({ rule: { pattern: `$OBJ.${key}` } }); + for (const node of propertyAccesses) { + const base = node.getMatch("OBJ"); + if (!base) continue; + + let replacement = ""; + if (key === "auth") { + replacement = `\`\${${base.text()}.username}:\${${base.text()}.password}\``; + } else if (key === "path") { + replacement = `\`\${${base.text()}.pathname}\${${base.text()}.search}\``; + } else if (key === "hostname") { + replacement = `${base.text()}.hostname.replace(/^\\[|\\]$/, '')`; + } + + edits.push(node.replace(replacement)); + } + + // Handle destructuring + const destructures = rootNode.findAll({ rule: { pattern: `const { ${key} } = $OBJ` } }); + for (const node of destructures) { + const base = node.getMatch("OBJ"); + if (!base) continue; + + const hadSemi = /;\s*$/.test(node.text()); + const replacement = replaceFn(base.text(), hadSemi); + edits.push(node.replace(replacement)); + } + } if (!edits.length) return null; From c1280638d44aa3741290cdc9ceeb0d719ddea104 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 23:57:49 +0200 Subject: [PATCH 34/52] chore: remove `@next` and clean (#176) From 9467f6ce0f817930927b674e0dc7dac22f3f9eab Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sun, 3 Aug 2025 14:50:42 +0200 Subject: [PATCH 35/52] fea(`node-url-to-whatwg-url`): scaffold codemod --- .../tests/import-process/expected/.gitkeep | 0 .../tests/import-process/input/.gitkeep | 0 .../tests/url-format/expected/.gitkeep | 0 .../tests/url-format/input/.gitkeep | 0 .../tests/url-parse/expected/.gitkeep | 0 .../tests/url-parse/input/.gitkeep | 0 recipes/node-url-to-whatwg-url/workflow.yaml | 12 ++++++------ 7 files changed, 6 insertions(+), 6 deletions(-) create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/expected/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/input/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/.gitkeep create mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/.gitkeep diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/.gitkeep b/recipes/node-url-to-whatwg-url/tests/import-process/expected/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/.gitkeep b/recipes/node-url-to-whatwg-url/tests/import-process/input/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-format/expected/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-format/input/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-parse/input/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/recipes/node-url-to-whatwg-url/workflow.yaml b/recipes/node-url-to-whatwg-url/workflow.yaml index 2bc23591..9eec3c60 100644 --- a/recipes/node-url-to-whatwg-url/workflow.yaml +++ b/recipes/node-url-to-whatwg-url/workflow.yaml @@ -14,11 +14,11 @@ nodes: js_file: src/url-parse.ts base_path: . include: - - "**/*.cjs" - - "**/*.cts" - "**/*.js" - "**/*.jsx" - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" - "**/*.mts" - "**/*.ts" - "**/*.tsx" @@ -30,11 +30,11 @@ nodes: js_file: src/url-format.ts base_path: . include: - - "**/*.cjs" - - "**/*.cts" - "**/*.js" - "**/*.jsx" - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" - "**/*.mts" - "**/*.ts" - "**/*.tsx" @@ -46,11 +46,11 @@ nodes: js_file: src/import-process.ts base_path: . include: - - "**/*.cjs" - - "**/*.cts" - "**/*.js" - "**/*.jsx" - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" - "**/*.mts" - "**/*.ts" - "**/*.tsx" From 4430f7f6907e13bb704b94d2f9ca892783b0e1e2 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sun, 3 Aug 2025 15:26:00 +0200 Subject: [PATCH 36/52] url-parse(`node-url-to-whatwg-url`): setup `url-parse` --- recipes/node-url-to-whatwg-url/tests/url-parse/expected/.gitkeep | 0 recipes/node-url-to-whatwg-url/tests/url-parse/input/.gitkeep | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/expected/.gitkeep delete mode 100644 recipes/node-url-to-whatwg-url/tests/url-parse/input/.gitkeep diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-parse/input/.gitkeep deleted file mode 100644 index e69de29b..00000000 From 01247a7e48c615efb586a152b1243486c98f3fa8 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sun, 3 Aug 2025 16:42:21 +0200 Subject: [PATCH 37/52] WIP --- package-lock.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/package-lock.json b/package-lock.json index a5ad1dde..6ba8dfa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4092,6 +4092,14 @@ "@codemod.com/jssg-types": "^1.0.3" } }, + "recipes/node-url-to-whatwg-url": { + "name": "@nodejs/node-url-to-whatwg-url", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.3" + } + }, "recipes/process-main-module": { "name": "@nodejs/process-main-module", "version": "1.0.1", From e1b3415cba4d65f7da9d479aea6d8b7a8e4f91ce Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 11:59:06 +0200 Subject: [PATCH 38/52] url-parse(`node-url-to-whatwg-url`): introduce --- recipes/node-url-to-whatwg-url/src/url-parse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index f62ebf56..6c4b1045 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -1,4 +1,4 @@ -import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; +import type { SgRoot, Edit, SgNode } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; From 68fe6031e0749456a643cfe6b00e0537fb78221f Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 13:50:21 +0200 Subject: [PATCH 39/52] clean --- .../node-url-to-whatwg-url/src/url-format.ts | 17 +++++++++++++++++ recipes/node-url-to-whatwg-url/src/url-parse.ts | 2 +- .../tests/import-process/expected/.gitkeep | 0 .../tests/import-process/input/.gitkeep | 0 .../tests/url-format/expected/.gitkeep | 0 .../tests/url-format/input/.gitkeep | 0 6 files changed, 18 insertions(+), 1 deletion(-) delete mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/expected/.gitkeep delete mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/input/.gitkeep delete mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/expected/.gitkeep delete mode 100644 recipes/node-url-to-whatwg-url/tests/url-format/input/.gitkeep diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index 027e4ea2..e6665180 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -22,6 +22,23 @@ const getLiteralText = (node: SgNode | null | undefined): string | undefined return undefined; }; +/** + * Get the literal text value of a node, if it exists. + * @param node The node to extract the literal text from + * @returns The literal text value, or undefined if not found + */ +const getLiteralText = (node: SgNode | null | undefined): string | undefined => { + const kind = node.kind(); + + if (kind === "string") { + const frag = node.find({ rule: { kind: "string_fragment" } }); + return frag ? frag.text() : node.text().slice(1, -1); + } + if (kind === "number") return node.text(); + if (kind === "true" || kind === "false") return node.text(); + return undefined; +}; + /** * Transforms url.format() calls to new URL().toString() * @param callNode The AST nodes representing the url.format() calls diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index 6c4b1045..f62ebf56 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -1,4 +1,4 @@ -import type { SgRoot, Edit, SgNode } from "@codemod.com/jssg-types/main"; +import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/.gitkeep b/recipes/node-url-to-whatwg-url/tests/import-process/expected/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/.gitkeep b/recipes/node-url-to-whatwg-url/tests/import-process/input/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-format/expected/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/.gitkeep b/recipes/node-url-to-whatwg-url/tests/url-format/input/.gitkeep deleted file mode 100644 index e69de29b..00000000 From 1c6a8841c872714bdf2f62a92695fe874248257d Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 13:54:54 +0200 Subject: [PATCH 40/52] WIP --- recipes/node-url-to-whatwg-url/src/url-parse.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index f62ebf56..e17523d4 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -1,3 +1,5 @@ +import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; +import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; From ac7b540d627586991332b1fab6f1256bb84af7f9 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 14:36:27 +0200 Subject: [PATCH 41/52] use resolve utility --- recipes/node-url-to-whatwg-url/src/url-parse.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index e17523d4..f62ebf56 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -1,5 +1,3 @@ -import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; -import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; From 4bccf85b4455ec917f7ce0f99d6fc23c42055229 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 13 Aug 2025 00:23:06 +0200 Subject: [PATCH 42/52] update --- recipes/node-url-to-whatwg-url/workflow.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/workflow.yaml b/recipes/node-url-to-whatwg-url/workflow.yaml index 9eec3c60..2bc23591 100644 --- a/recipes/node-url-to-whatwg-url/workflow.yaml +++ b/recipes/node-url-to-whatwg-url/workflow.yaml @@ -14,11 +14,11 @@ nodes: js_file: src/url-parse.ts base_path: . include: + - "**/*.cjs" + - "**/*.cts" - "**/*.js" - "**/*.jsx" - "**/*.mjs" - - "**/*.cjs" - - "**/*.cts" - "**/*.mts" - "**/*.ts" - "**/*.tsx" @@ -30,11 +30,11 @@ nodes: js_file: src/url-format.ts base_path: . include: + - "**/*.cjs" + - "**/*.cts" - "**/*.js" - "**/*.jsx" - "**/*.mjs" - - "**/*.cjs" - - "**/*.cts" - "**/*.mts" - "**/*.ts" - "**/*.tsx" @@ -46,11 +46,11 @@ nodes: js_file: src/import-process.ts base_path: . include: + - "**/*.cjs" + - "**/*.cts" - "**/*.js" - "**/*.jsx" - "**/*.mjs" - - "**/*.cjs" - - "**/*.cts" - "**/*.mts" - "**/*.ts" - "**/*.tsx" From 0e4799efe683ec197e033c6c2ba24e8399818704 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 13 Aug 2025 00:31:18 +0200 Subject: [PATCH 43/52] update logic --- package-lock.json | 11 ++----- .../node-url-to-whatwg-url/src/url-format.ts | 31 +++++++------------ 2 files changed, 14 insertions(+), 28 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6ba8dfa5..0e5809ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4092,14 +4092,6 @@ "@codemod.com/jssg-types": "^1.0.3" } }, - "recipes/node-url-to-whatwg-url": { - "name": "@nodejs/node-url-to-whatwg-url", - "version": "1.0.0", - "license": "MIT", - "devDependencies": { - "@codemod.com/jssg-types": "^1.0.3" - } - }, "recipes/process-main-module": { "name": "@nodejs/process-main-module", "version": "1.0.1", @@ -4130,7 +4122,7 @@ "@nodejs/codemod-utils": "*" }, "devDependencies": { - "@types/node": "^24.2.1" + "@codemod.com/jssg-types": "^1.0.3" } }, "utils": { @@ -4139,6 +4131,7 @@ "license": "MIT", "devDependencies": { "@ast-grep/napi": "^0.39.3", + "@codemod.com/jssg-types": "^1.0.3", "dedent": "^1.6.0" } } diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index e6665180..8b538ea4 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -300,33 +300,26 @@ export default function transform(root: SgRoot): string | null { const edits: Edit[] = []; // Safety: only run on files that import/require node:url - const hasNodeUrlImport = - // @ts-ignore - getNodeImportStatements(root, "url").length > 0 || - // @ts-ignore - getNodeRequireCalls(root, "url").length > 0; - - if (!hasNodeUrlImport) return null; - - // Look for various ways format can be referenced; build binding-aware patterns - // @ts-ignore - type difference between jssg and ast-grep wrappers - const importNodes = getNodeImportStatements(root, "url"); - // @ts-ignore - type difference between jssg and ast-grep wrappers - const requireNodes = getNodeRequireCalls(root, "url"); - const patterns = new Set(); - - for (const node of [...importNodes, ...requireNodes]) { + const importNodes = getNodeImportStatements(root, "url"); + const requireNodes = getNodeRequireCalls(root, "url"); + const requiresImports = [...importNodes, ...requireNodes] + + if (!requiresImports.length) return null; + + const parseCallPatterns = new Set(); + + for (const node of requiresImports) { // @ts-ignore - helper accepts ast-grep SgNode; runtime compatible const binding = resolveBindingPath(node, "$.format"); - if (binding) patterns.add(`${binding}($OPTIONS)`); + if (binding) parseCallPatterns.add(`${binding}($OPTIONS)`); } // Fallbacks for common names and tests ["url.format($OPTIONS)", "nodeUrl.format($OPTIONS)", "format($OPTIONS)", "urlFormat($OPTIONS)"].forEach((p) => - patterns.add(p), + parseCallPatterns.add(p), ); - for (const pattern of patterns) { + for (const pattern of parseCallPatterns) { const calls = rootNode.findAll({ rule: { pattern } }); if (calls.length) urlFormatToUrlToString(calls, edits); From 587e97da18abb0f30a170a4c530ec5df474b5ced Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 13 Aug 2025 00:36:05 +0200 Subject: [PATCH 44/52] Update url-format.ts --- recipes/node-url-to-whatwg-url/src/url-format.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index 8b538ea4..00633e23 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -300,8 +300,8 @@ export default function transform(root: SgRoot): string | null { const edits: Edit[] = []; // Safety: only run on files that import/require node:url - const importNodes = getNodeImportStatements(root, "url"); - const requireNodes = getNodeRequireCalls(root, "url"); + const importNodes = getNodeImportStatements(root as undefined, "url"); + const requireNodes = getNodeRequireCalls(root as undefined, "url"); const requiresImports = [...importNodes, ...requireNodes] if (!requiresImports.length) return null; From 02fb501f829c65bce7e6e1a0bd19ebb3fc96e801 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 13 Aug 2025 00:39:10 +0200 Subject: [PATCH 45/52] Update url-parse.ts --- .../node-url-to-whatwg-url/src/url-parse.ts | 98 +++++++++---------- 1 file changed, 48 insertions(+), 50 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index f62ebf56..5b4d9e71 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -71,56 +71,54 @@ export default function transform(root: SgRoot): string | null { } // Destructuring: const { auth } = obj -> const auth = `${obj.username}:${obj.password}` - const authDestructures = rootNode.findAll({ rule: { pattern: "const { auth } = $OBJ" } }); - for (const node of authDestructures) { - const base = node.getMatch("OBJ"); - if (!base) continue; - const hadSemi = /;\s*$/.test(node.text()); - const name = base.text(); - const replacement = `const auth = \`${'${'}${name}.username${'}'}:${'${'}${name}.password${'}'}\`${hadSemi ? ';' : ''}`; - edits.push(node.replace(replacement)); - } - - // Property access: obj.path -> `${obj.pathname}${obj.search}` - const pathAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.path" } }); - for (const node of pathAccesses) { - const base = node.getMatch("OBJ"); - if (!base) continue; - - const replacement = `\`\${${base.text()}.pathname}\${${base.text()}.search}\``; - edits.push(node.replace(replacement)); - } - - // Destructuring: const { path } = obj -> const path = `${obj.pathname}${obj.search}` - const pathDestructures = rootNode.findAll({ rule: { pattern: "const { path } = $OBJ" } }); - for (const node of pathDestructures) { - const base = node.getMatch("OBJ"); - if (!base) continue; - const hadSemi = /;\s*$/.test(node.text()); - const name = base.text(); - const replacement = `const path = \`${'${'}${name}.pathname${'}'}${'${'}${name}.search${'}'}\`${hadSemi ? ';' : ''}`; - edits.push(node.replace(replacement)); - } - - // Property access: obj.hostname -> obj.hostname.replace(/^\[|\]$/, '') - const hostnameAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.hostname" } }); - for (const node of hostnameAccesses) { - const base = node.getMatch("OBJ"); - if (!base) continue; - - const replacement = `${base.text()}.hostname.replace(/^\\[|\\]$/, '')`; - edits.push(node.replace(replacement)); - } - - // Destructuring: const { hostname } = obj -> const hostname = obj.hostname.replace(/^\[|\]$/, '') - const hostnameDestructures = rootNode.findAll({ rule: { pattern: "const { hostname } = $OBJ" } }); - for (const node of hostnameDestructures) { - const base = node.getMatch("OBJ"); - if (!base) continue; - const hadSemi = /;\s*$/.test(node.text()); - const replacement = `const hostname = ${base.text()}.hostname.replace(/^\\[|\\]$/, '')${hadSemi ? ';' : ''}`; - edits.push(node.replace(replacement)); - } + const fieldsToReplace = [ + { + key: "auth", + replaceFn: (base: string, hadSemi: boolean) => + `const auth = \`\${${base}.username}:\${${base}.password}\`${hadSemi ? ";" : ""}`, + }, + { + key: "path", + replaceFn: (base: string, hadSemi: boolean) => + `const path = \`\${${base}.pathname}\${${base}.search}\`${hadSemi ? ";" : ""}`, + }, + { + key: "hostname", + replaceFn: (base: string, hadSemi: boolean) => + `const hostname = ${base}.hostname.replace(/^\\[|\\]$/, '')${hadSemi ? ";" : ""}`, + }, + ]; + + for (const { key, replaceFn } of fieldsToReplace) { + // Handle property access + const propertyAccesses = rootNode.findAll({ rule: { pattern: `$OBJ.${key}` } }); + for (const node of propertyAccesses) { + const base = node.getMatch("OBJ"); + if (!base) continue; + + let replacement = ""; + if (key === "auth") { + replacement = `\`\${${base.text()}.username}:\${${base.text()}.password}\``; + } else if (key === "path") { + replacement = `\`\${${base.text()}.pathname}\${${base.text()}.search}\``; + } else if (key === "hostname") { + replacement = `${base.text()}.hostname.replace(/^\\[|\\]$/, '')`; + } + + edits.push(node.replace(replacement)); + } + + // Handle destructuring + const destructures = rootNode.findAll({ rule: { pattern: `const { ${key} } = $OBJ` } }); + for (const node of destructures) { + const base = node.getMatch("OBJ"); + if (!base) continue; + + const hadSemi = /;\s*$/.test(node.text()); + const replacement = replaceFn(base.text(), hadSemi); + edits.push(node.replace(replacement)); + } + } if (!edits.length) return null; From 245ca03b256cf9e8f287d67138405c0784204ff7 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 12 Aug 2025 23:57:49 +0200 Subject: [PATCH 46/52] chore: remove `@next` and clean (#176) From 81f421fcb15de01fb94471bfe20c933cc04d62bd Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 13 Aug 2025 11:51:38 +0200 Subject: [PATCH 47/52] Update package-lock.json --- package-lock.json | 66 +++++++++++++++++++++++------------------------ 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0e5809ba..aca02b7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1347,9 +1347,9 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", - "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1366,15 +1366,15 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.29", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", - "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1656,13 +1656,13 @@ } }, "node_modules/@types/node-fetch": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", - "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", "license": "MIT", "dependencies": { "@types/node": "*", - "form-data": "^4.0.0" + "form-data": "^4.0.4" } }, "node_modules/abort-controller": { @@ -1825,9 +1825,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "version": "4.25.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.2.tgz", + "integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==", "funding": [ { "type": "opencollective", @@ -1844,8 +1844,8 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", + "caniuse-lite": "^1.0.30001733", + "electron-to-chromium": "^1.5.199", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, @@ -1900,9 +1900,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001727", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", - "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "version": "1.0.30001734", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001734.tgz", + "integrity": "sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==", "funding": [ { "type": "opencollective", @@ -2218,9 +2218,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.191", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.191.tgz", - "integrity": "sha512-xcwe9ELcuxYLUFqZZxL19Z6HVKcvNkIwhbHUz7L3us6u12yR+7uY89dSl570f/IqNthx8dAw3tojG7i4Ni4tDA==", + "version": "1.5.200", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz", + "integrity": "sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -2397,9 +2397,9 @@ } }, "node_modules/flow-parser": { - "version": "0.277.1", - "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.277.1.tgz", - "integrity": "sha512-86F5PGl+OrFvCzyK04id9Yf9rxFB8485GPs5sexB4cVLOXmpHbSi1/dYiaemI53I85CpImBu/qHVmZnGQflgmw==", + "version": "0.278.0", + "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.278.0.tgz", + "integrity": "sha512-9oUcYDHf9n+E/t0FXndgBqGbaUsGEcmWqIr1ldqCzTzctsJV5E/bHusOj4ThB72Ss2mqWpLFNz0+o2c1O8J6+A==", "license": "MIT", "engines": { "node": ">=0.4.0" @@ -3231,9 +3231,9 @@ } }, "node_modules/openai/node_modules/@types/node": { - "version": "18.19.120", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.120.tgz", - "integrity": "sha512-WtCGHFXnVI8WHLxDAt5TbnCM4eSE+nI0QN2NJtwzcgMhht2eNz6V9evJrk+lwC8bCY8OWV5Ym8Jz7ZEyGnKnMA==", + "version": "18.19.122", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.122.tgz", + "integrity": "sha512-yzegtT82dwTNEe/9y+CM8cgb42WrUfMMCg2QqSddzO1J6uPmBD7qKCZ7dOHZP2Yrpm/kb0eqdNMn2MUyEiqBmA==", "license": "MIT", "dependencies": { "undici-types": "~5.26.4" @@ -4026,9 +4026,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "license": "ISC", "bin": { "yaml": "bin.mjs" From 841d57634e7e8a4e75d9fb34b5f78274a913cc61 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 13 Aug 2025 11:55:26 +0200 Subject: [PATCH 48/52] Update url-format.ts --- .../node-url-to-whatwg-url/src/url-format.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index 00633e23..23be645f 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -22,23 +22,6 @@ const getLiteralText = (node: SgNode | null | undefined): string | undefined return undefined; }; -/** - * Get the literal text value of a node, if it exists. - * @param node The node to extract the literal text from - * @returns The literal text value, or undefined if not found - */ -const getLiteralText = (node: SgNode | null | undefined): string | undefined => { - const kind = node.kind(); - - if (kind === "string") { - const frag = node.find({ rule: { kind: "string_fragment" } }); - return frag ? frag.text() : node.text().slice(1, -1); - } - if (kind === "number") return node.text(); - if (kind === "true" || kind === "false") return node.text(); - return undefined; -}; - /** * Transforms url.format() calls to new URL().toString() * @param callNode The AST nodes representing the url.format() calls From c8ff01ad393465c15de1c9cfe7816fb892951bf5 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sun, 17 Aug 2025 15:09:52 +0200 Subject: [PATCH 49/52] improve --- recipes/node-url-to-whatwg-url/README.md | 27 +++- .../node-url-to-whatwg-url/src/url-parse.ts | 143 +++++++++++++----- .../tests/url-parse/expected/file-1.js | 6 + .../tests/url-parse/input/file-1.js | 6 + 4 files changed, 140 insertions(+), 42 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/README.md b/recipes/node-url-to-whatwg-url/README.md index a3b98a54..71b79318 100644 --- a/recipes/node-url-to-whatwg-url/README.md +++ b/recipes/node-url-to-whatwg-url/README.md @@ -12,14 +12,30 @@ See [DEP0116](https://nodejs.org/api/deprecations.html#DEP0116). ```js const url = require('node:url'); -const myUrl = new url.URL('https://example.com'); -const urlAuth = legacyURL.auth; +const myURL = url.parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; ``` **After:** ```js -const myUrl = new URL('https://example.com'); -const urlAuth = `${myUrl.username}:${myUrl.password}`; +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); ``` ### `url.format` to `myUrl.toString() @@ -63,3 +79,6 @@ resolve('/one/two/three', 'four'); // '/one/two/four' resolve('http://example.com/', '/one'); // 'http://example.com/one' resolve('http://example.com/one', '/two'); // 'http://example.com/two' ``` + +If you are using `url.parse().auth`, `url.parse().username`, or `url.parse().password`. Will transform to `new URL().auth` which is not valid WHATWG url property. So you have to manually construct the `auth`, `path`, and `hostname` properties as shown in the examples above. + diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index 5b4d9e71..fe2f7028 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -29,12 +29,12 @@ export default function transform(root: SgRoot): string | null { if (!hasNodeUrlImport) return null; - // 1) Replace parse calls with new URL() using binding-aware patterns + // 1) Replace parse calls with new URL() using binding-aware patterns // @ts-ignore - type difference between jssg and ast-grep wrappers const importNodes = getNodeImportStatements(root, "url"); // @ts-ignore - type difference between jssg and ast-grep wrappers const requireNodes = getNodeRequireCalls(root, "url"); - const parseCallPatterns = new Set(); + const parseCallPatterns = new Set(); for (const node of [...importNodes, ...requireNodes]) { // @ts-ignore resolve across wrappers @@ -43,35 +43,55 @@ export default function transform(root: SgRoot): string | null { parseCallPatterns.add(`${binding}($ARG)`); } - for (const pattern of parseCallPatterns) { - const calls = rootNode.findAll({ rule: { pattern } }); + // 1.a) Identify variables assigned from parse(...) so we only rewrite legacy + // properties (auth, path, hostname) on those specific objects + const parseResultVars = new Set(); + for (const pattern of parseCallPatterns) { + // const/let/var declarations + const declKinds = ["const", "let", "var"] as const; + for (const kind of declKinds) { + const decls = rootNode.findAll({ rule: { pattern: `${kind} $OBJ = ${pattern}` } }); + for (const d of decls) { + const obj = d.getMatch("OBJ"); + if (!obj) continue; + const name = obj.text(); + // Only simple identifiers + if (/^[$A-Z_a-z][$\w]*$/.test(name)) parseResultVars.add(name); + } + } + // simple assignments + const assigns = rootNode.findAll({ rule: { pattern: `$OBJ = ${pattern}` } }); + for (const a of assigns) { + const obj = a.getMatch("OBJ"); + if (!obj) continue; + const name = obj.text(); + if (/^[$A-Z_a-z][$\w]*$/.test(name)) parseResultVars.add(name); + } + } - for (const call of calls) { - const arg = call.getMatch("ARG"); - if (!arg) continue; + // 1.b) Replace parse calls with new URL() + for (const pattern of parseCallPatterns) { + const calls = rootNode.findAll({ rule: { pattern } }); - const replacement = `new URL(${arg.text()})`; - edits.push(call.replace(replacement)); - } - } + for (const call of calls) { + const arg = call.getMatch("ARG"); + if (!arg) continue; + + const replacement = `new URL(${arg.text()})`; + edits.push(call.replace(replacement)); + } + } // 2) Transform legacy properties on URL object // - auth => `${obj.username}:${obj.password}` // - path => `${obj.pathname}${obj.search}` // - hostname => obj.hostname.replace(/^[\[|\]]$/, '') (strip square brackets) - // Property access: obj.auth -> `${obj.username}:${obj.password}` - const authAccesses = rootNode.findAll({ rule: { pattern: "$OBJ.auth" } }); - for (const node of authAccesses) { - const base = node.getMatch("OBJ"); - if (!base) continue; - - const replacement = `\`\${${base.text()}.username}:\${${base.text()}.password}\``; - edits.push(node.replace(replacement)); - } - + // We will only transform legacy properties when the base object is either: + // - a variable assigned from url.parse/parse(...) + // - a direct url.parse/parse(...) call expression // Destructuring: const { auth } = obj -> const auth = `${obj.username}:${obj.password}` - const fieldsToReplace = [ + const fieldsToReplace = [ { key: "auth", replaceFn: (base: string, hadSemi: boolean) => @@ -90,32 +110,79 @@ export default function transform(root: SgRoot): string | null { ]; for (const { key, replaceFn } of fieldsToReplace) { - // Handle property access - const propertyAccesses = rootNode.findAll({ rule: { pattern: `$OBJ.${key}` } }); - for (const node of propertyAccesses) { - const base = node.getMatch("OBJ"); - if (!base) continue; + // 2.a) Handle property access for identifiers that originate from parse(...) + for (const varName of parseResultVars) { + const propertyAccesses = rootNode.findAll({ rule: { pattern: `${varName}.${key}` } }); + for (const node of propertyAccesses) { + let replacement = ""; + if (key === "auth") { + replacement = `\`\${${varName}.username}:\${${varName}.password}\``; + } else if (key === "path") { + replacement = `\`\${${varName}.pathname}\${${varName}.search}\``; + } else if (key === "hostname") { + replacement = `${varName}.hostname.replace(/^\\[|\\]$/, '')`; + } + edits.push(node.replace(replacement)); + } + + // destructuring for identifiers + const destructures = rootNode.findAll({ rule: { pattern: `const { ${key} } = ${varName}` } }); + for (const node of destructures) { + const hadSemi = /;\s*$/.test(node.text()); + const replacement = replaceFn(varName, hadSemi); + edits.push(node.replace(replacement)); + } + } + + // 2.b) Handle direct call expressions like parse(...).auth and + // destructuring from parse(...) + for (const pattern of parseCallPatterns) { + const directAccesses = rootNode.findAll({ rule: { pattern: `${pattern}.${key}` } }); + for (const node of directAccesses) { + // Reconstruct base as the matched expression before .key + const baseExpr = node.text().replace(new RegExp(`\\.${key}$`), ""); + let replacement = ""; + if (key === "auth") { + replacement = `\`\${${baseExpr}.username}:\${${baseExpr}.password}\``; + } else if (key === "path") { + replacement = `\`\${${baseExpr}.pathname}\${${baseExpr}.search}\``; + } else if (key === "hostname") { + replacement = `${baseExpr}.hostname.replace(/^\\[|\\]$/, '')`; + } + edits.push(node.replace(replacement)); + } + const directDestructures = rootNode.findAll({ rule: { pattern: `const { ${key} } = ${pattern}` } }); + for (const node of directDestructures) { + const hadSemi = /;\s*$/.test(node.text()); + // Extract base expression text (the pattern text matches the whole RHS) + const rhsText = node.text().replace(/^[^{]+{\s*[^}]+\s*}\s*=\s*/, ""); + const replacement = replaceFn(rhsText, hadSemi); + edits.push(node.replace(replacement)); + } + } + + // 2.c) Handle property access and destructuring after parse calls were + // replaced with new URL($ARG) + const newURLAccesses = rootNode.findAll({ rule: { pattern: `new URL($ARG).${key}` } }); + for (const node of newURLAccesses) { + const baseExpr = node.text().replace(new RegExp(`\\.${key}$`), ""); let replacement = ""; if (key === "auth") { - replacement = `\`\${${base.text()}.username}:\${${base.text()}.password}\``; + replacement = `\`\${${baseExpr}.username}:\${${baseExpr}.password}\``; } else if (key === "path") { - replacement = `\`\${${base.text()}.pathname}\${${base.text()}.search}\``; + replacement = `\`\${${baseExpr}.pathname}\${${baseExpr}.search}\``; } else if (key === "hostname") { - replacement = `${base.text()}.hostname.replace(/^\\[|\\]$/, '')`; + replacement = `${baseExpr}.hostname.replace(/^\\[|\\]$/, '')`; } - edits.push(node.replace(replacement)); } - // Handle destructuring - const destructures = rootNode.findAll({ rule: { pattern: `const { ${key} } = $OBJ` } }); - for (const node of destructures) { - const base = node.getMatch("OBJ"); - if (!base) continue; - + const newURLDestructures = rootNode.findAll({ rule: { pattern: `const { ${key} } = new URL($ARG)` } }); + for (const node of newURLDestructures) { const hadSemi = /;\s*$/.test(node.text()); - const replacement = replaceFn(base.text(), hadSemi); + const rhsText = node.text().replace(/^[^{]+{\s*[^}]+\s*}\s*=\s*/, ""); + const replacement = replaceFn(rhsText, hadSemi); edits.push(node.replace(replacement)); } } diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-1.js b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-1.js index 2e5a4e64..c02bc68c 100644 --- a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-1.js +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-1.js @@ -10,3 +10,9 @@ const urlPath = `${myURL.pathname}${myURL.search}`; const hostname = myURL.hostname.replace(/^\[|\]$/, ''); const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); + +const someObject = { + auth: 'life is a waterfall' +} + +console.log(someObject.auth); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-1.js b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-1.js index aecb96d3..c5097d0f 100644 --- a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-1.js +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-1.js @@ -10,3 +10,9 @@ const urlPath = myURL.path; const { hostname } = myURL; const urlHostname = myURL.hostname; + +const someObject = { + auth: 'life is a waterfall' +} + +console.log(someObject.auth); From ca7dadf0e2e3560f0834159b9e7627c826974621 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 20 Aug 2025 11:38:42 +0200 Subject: [PATCH 50/52] update from feedback --- .../src/import-process.ts | 47 ++++-- .../node-url-to-whatwg-url/src/url-format.ts | 154 ++++++------------ .../node-url-to-whatwg-url/src/url-parse.ts | 131 +++++++++------ .../tests/import-process/expected/file-7.js | 3 + .../tests/import-process/input/file-7.js | 4 + 5 files changed, 173 insertions(+), 166 deletions(-) create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/expected/file-7.js create mode 100644 recipes/node-url-to-whatwg-url/tests/import-process/input/file-7.js diff --git a/recipes/node-url-to-whatwg-url/src/import-process.ts b/recipes/node-url-to-whatwg-url/src/import-process.ts index 14cf6be8..bd54fcb1 100644 --- a/recipes/node-url-to-whatwg-url/src/import-process.ts +++ b/recipes/node-url-to-whatwg-url/src/import-process.ts @@ -1,9 +1,15 @@ -import type { SgRoot, Edit, Range } from "@codemod.com/jssg-types/main"; +import type { SgRoot, SgNode, Edit, Range } from "@codemod.com/jssg-types/main"; import type JS from "@codemod.com/jssg-types/langs/javascript"; import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; import { removeLines } from "@nodejs/codemod-utils/ast-grep/remove-lines"; +const isBindingUsed = (rootNode: SgNode, name: string): boolean => { + const refs = rootNode.findAll({ rule: { pattern: name } }); + // Heuristic: declaration counts as one; any other usage yields > 1 + return refs.length > 1; + }; + /** * Clean up unused imports/requires from 'node:url' after transforms using shared utils */ @@ -11,12 +17,6 @@ export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; - const isBindingUsed = (name: string): boolean => { - const refs = rootNode.findAll({ rule: { pattern: name } }); - // Heuristic: declaration counts as one; any other usage yields > 1 - return refs.length > 1; - }; - const linesToRemove: Range[] = []; // 1) ES Module imports: import ... from 'node:url' @@ -28,7 +28,7 @@ export default function transform(root: SgRoot): string | null { let removed = false; if (clause) { const nsId = clause.find({ rule: { kind: "namespace_import" } })?.find({ rule: { kind: "identifier" } }); - if (nsId && !isBindingUsed(nsId.text())) { + if (nsId && !isBindingUsed(rootNode, nsId.text())) { linesToRemove.push(imp.range()); removed = true; } @@ -38,7 +38,7 @@ export default function transform(root: SgRoot): string | null { if (specs.length === 0 && !nsId) { const defaultId = clause.find({ rule: { kind: "identifier" } }); - if (defaultId && !isBindingUsed(defaultId.text())) { + if (defaultId && !isBindingUsed(rootNode, defaultId.text())) { linesToRemove.push(imp.range()); removed = true; } @@ -50,7 +50,7 @@ export default function transform(root: SgRoot): string | null { for (const spec of specs) { const text = spec.text().trim(); const bindingName = text.includes(" as ") ? text.split(/\s+as\s+/)[1] : text; - if (bindingName && isBindingUsed(bindingName)) keepTexts.push(text); + if (bindingName && isBindingUsed(rootNode, bindingName)) keepTexts.push(text); } if (keepTexts.length === 0) { linesToRemove.push(imp.range()); @@ -71,7 +71,7 @@ export default function transform(root: SgRoot): string | null { const hasObjectPattern = decl.find({ rule: { kind: "object_pattern" } }); if (id && !hasObjectPattern) { - if (!isBindingUsed(id.text())) linesToRemove.push(decl.parent().range()); + if (!isBindingUsed(rootNode, id.text())) linesToRemove.push(decl.parent().range()); continue; } @@ -86,10 +86,10 @@ export default function transform(root: SgRoot): string | null { } const usedTexts: string[] = []; - for (const s of shorts) if (isBindingUsed(s.text())) usedTexts.push(s.text()); + for (const s of shorts) if (isBindingUsed(rootNode, s.text())) usedTexts.push(s.text()); for (const pair of pairs) { const aliasId = pair.find({ rule: { kind: "identifier" } }); - if (aliasId && isBindingUsed(aliasId.text())) usedTexts.push(pair.text()); + if (aliasId && isBindingUsed(rootNode, aliasId.text())) usedTexts.push(pair.text()); } if (usedTexts.length === 0) { @@ -105,10 +105,23 @@ export default function transform(root: SgRoot): string | null { let source = rootNode.commitEdits(edits); - source = removeLines(source, linesToRemove.map(range => ({ - start: { line: range.start.line, column: 0, index: 0 }, - end: { line: range.end.line + 1, column: 0, index: 0 } - }))); + // Only remove the next line if it is blank; don't delete non-empty following lines. + const srcLines = source.split("\n"); + const adjustedRanges = linesToRemove.map((range) => { + const startLine = range.start.line; + let endLine = range.end.line; + const nextLine = endLine + 1; + if (nextLine < srcLines.length) { + const isNextLineBlank = /^\s*$/.test(srcLines[nextLine] ?? ""); + if (isNextLineBlank) endLine = nextLine; + } + return { + start: { line: startLine, column: 0, index: 0 }, + end: { line: endLine, column: 0, index: 0 }, + } as Range; + }); + + source = removeLines(source, adjustedRanges); return source; }; diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index 23be645f..f774f16c 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -1,15 +1,16 @@ import type { SgRoot, Edit, SgNode } from "@codemod.com/jssg-types/main"; -import type JS from "@codemod.com/jssg-types/langs/javascript"; import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; import { resolveBindingPath } from "@nodejs/codemod-utils/ast-grep/resolve-binding-path"; +type V = { literal: true; text: string } | { literal: false; code: string }; + /** * Get the literal text value of a node, if it exists. * @param node The node to extract the literal text from * @returns The literal text value, or undefined if not found */ -const getLiteralText = (node: SgNode | null | undefined): string | undefined => { +const getLiteralText = (node: SgNode | null | undefined): string | undefined => { if (!node) return undefined; const kind = node.kind(); @@ -22,12 +23,40 @@ const getLiteralText = (node: SgNode | null | undefined): string | undefined return undefined; }; +/** + * Get the value of a pair node. + * @param pair The pair node to extract the value from + * @returns The value of the pair node, or undefined if not found + */ +const getValue = (pair: SgNode): V | undefined => { + // string/number/bool + const litNode = pair.find({ + rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] }, + }); + const lit = getLiteralText(litNode); + if (lit !== undefined) return { literal: true, text: lit }; + + // identifier value + const idNode = pair.find({ rule: { kind: "identifier" } }); + if (idNode) return { literal: false, code: idNode.text() }; + + // shorthand property + const shorthand = pair.find({ rule: { kind: "shorthand_property_identifier" } }); + if (shorthand) return { literal: false, code: shorthand.text() }; + + // template string value + const template = pair.find({ rule: { kind: "template_string" } }); + if (template) return { literal: false, code: template.text() }; + + return undefined; + }; + /** * Transforms url.format() calls to new URL().toString() * @param callNode The AST nodes representing the url.format() calls * @param edits The edits collector */ -function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { +function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { for (const call of callNode) { const optionsMatch = call.getMatch("OPTIONS"); if (!optionsMatch) continue; @@ -36,7 +65,6 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { const objectNode = optionsMatch.find({ rule: { kind: "object" } }); if (!objectNode) continue; - type V = { literal: true; text: string } | { literal: false; code: string }; const urlState: { protocol?: V; auth?: V; // user:pass @@ -49,29 +77,6 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { queryParams?: Array<[string, string]>; } = {}; - const getValue = (pair: SgNode): V | undefined => { - // string/number/bool - const litNode = pair.find({ - rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] }, - }); - const lit = getLiteralText(litNode); - if (lit !== undefined) return { literal: true, text: lit }; - - // identifier value - const idNode = pair.find({ rule: { kind: "identifier" } }); - if (idNode) return { literal: false, code: idNode.text() }; - - // shorthand property - const shorthand = pair.find({ rule: { kind: "shorthand_property_identifier" } }); - if (shorthand) return { literal: false, code: shorthand.text() }; - - // template string value - const template = pair.find({ rule: { kind: "template_string" } }); - if (template) return { literal: false, code: template.text() }; - - return undefined; - }; - const pairs = objectNode.findAll({ rule: { kind: "pair" } }); for (const pair of pairs) { @@ -88,7 +93,14 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { for (const qp of qpairs) { const qkeyNode = qp.find({ rule: { kind: "property_identifier" } }); const qvalLiteral = getLiteralText( - qp.find({ rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] } }), + qp.find({ rule:{ + any: [ + { kind: "string" }, + { kind: "number" }, + { kind: "true" }, + { kind: "false" } + ] + } }), ); if (qkeyNode && qvalLiteral !== undefined) list.push([qkeyNode.text(), qvalLiteral]); } @@ -101,43 +113,14 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { const val = getValue(pair); if (!val) continue; - switch (key) { - case "protocol": { - if (val.literal) urlState.protocol = { literal: true, text: val.text.replace(/:$/, "") }; - else urlState.protocol = val; - break; - } - case "auth": { - urlState.auth = val; - break; - } - case "host": { - urlState.host = val; - break; + const handledProps = ["protocol", "auth", "host", "hostname", "port", "pathname", "search", "hash"]; + if (handledProps.includes(key)) { + if (key === "protocol" && val.literal) { + urlState.protocol = { literal: true, text: val.text.replace(/:$/, "") }; + } else { + // biome-ignore lint/suspicious/noExplicitAny: IDK how to solve that + (urlState as any)[key] = val; } - case "hostname": { - urlState.hostname = val; - break; - } - case "port": { - urlState.port = val; - break; - } - case "pathname": { - urlState.pathname = val; - break; - } - case "search": { - urlState.search = val; - break; - } - case "hash": { - urlState.hash = val; - break; - } - default: - // ignore unknown options in this simple mapping - break; } } @@ -146,33 +129,10 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { for (const sh of shorthands) { const name = sh.text(); const v: V = { literal: false, code: name }; - switch (name) { - case "protocol": - urlState.protocol = v; - break; - case "auth": - urlState.auth = v; - break; - case "host": - urlState.host = v; - break; - case "hostname": - urlState.hostname = v; - break; - case "port": - urlState.port = v; - break; - case "pathname": - urlState.pathname = v; - break; - case "search": - urlState.search = v; - break; - case "hash": - urlState.hash = v; - break; - default: - break; + const handledProps = ['protocol', 'auth', 'host', 'hostname', 'port', 'pathname', 'search', 'hash']; + if (handledProps.includes(name)) { + // biome-ignore lint/suspicious/noExplicitAny: IDK how to solve that + (urlState as any)[name] = v; } } @@ -278,13 +238,13 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { * 2. `foo.format(options)` → `new URL().toString()` * 3. `foo(options)` → `new URL().toString()` */ -export default function transform(root: SgRoot): string | null { +export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; // Safety: only run on files that import/require node:url - const importNodes = getNodeImportStatements(root as undefined, "url"); - const requireNodes = getNodeRequireCalls(root as undefined, "url"); + const importNodes = getNodeImportStatements(root, "url"); + const requireNodes = getNodeRequireCalls(root, "url"); const requiresImports = [...importNodes, ...requireNodes] if (!requiresImports.length) return null; @@ -292,16 +252,10 @@ export default function transform(root: SgRoot): string | null { const parseCallPatterns = new Set(); for (const node of requiresImports) { - // @ts-ignore - helper accepts ast-grep SgNode; runtime compatible const binding = resolveBindingPath(node, "$.format"); if (binding) parseCallPatterns.add(`${binding}($OPTIONS)`); } - // Fallbacks for common names and tests - ["url.format($OPTIONS)", "nodeUrl.format($OPTIONS)", "format($OPTIONS)", "urlFormat($OPTIONS)"].forEach((p) => - parseCallPatterns.add(p), - ); - for (const pattern of parseCallPatterns) { const calls = rootNode.findAll({ rule: { pattern } }); diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts index fe2f7028..3b5b0c14 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -1,5 +1,4 @@ import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; -import type JS from "@codemod.com/jssg-types/langs/javascript"; import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; import { resolveBindingPath } from "@nodejs/codemod-utils/ast-grep/resolve-binding-path"; @@ -16,28 +15,23 @@ import { resolveBindingPath } from "@nodejs/codemod-utils/ast-grep/resolve-bindi * 2. `foo.parse(urlString)` → `new URL(urlString)` * 3. `foo(urlString)` → `new URL(urlString)` */ -export default function transform(root: SgRoot): string | null { +export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; // Safety: only run on files that import/require node:url const hasNodeUrlImport = - // @ts-ignore getNodeImportStatements(root, "url").length > 0 || - // @ts-ignore getNodeRequireCalls(root, "url").length > 0; - if (!hasNodeUrlImport) return null; + if (!hasNodeUrlImport) return null; // 1) Replace parse calls with new URL() using binding-aware patterns - // @ts-ignore - type difference between jssg and ast-grep wrappers const importNodes = getNodeImportStatements(root, "url"); - // @ts-ignore - type difference between jssg and ast-grep wrappers const requireNodes = getNodeRequireCalls(root, "url"); const parseCallPatterns = new Set(); - for (const node of [...importNodes, ...requireNodes]) { - // @ts-ignore resolve across wrappers + for (const node of [...importNodes, ...requireNodes]) { const binding = resolveBindingPath(node, "$.parse"); if (!binding) continue; parseCallPatterns.add(`${binding}($ARG)`); @@ -47,22 +41,18 @@ export default function transform(root: SgRoot): string | null { // properties (auth, path, hostname) on those specific objects const parseResultVars = new Set(); for (const pattern of parseCallPatterns) { - // const/let/var declarations - const declKinds = ["const", "let", "var"] as const; - for (const kind of declKinds) { - const decls = rootNode.findAll({ rule: { pattern: `${kind} $OBJ = ${pattern}` } }); - for (const d of decls) { - const obj = d.getMatch("OBJ"); - if (!obj) continue; - const name = obj.text(); - // Only simple identifiers - if (/^[$A-Z_a-z][$\w]*$/.test(name)) parseResultVars.add(name); + const matches = rootNode.findAll({ + rule: { + any: [ + { pattern: `const $OBJ = ${pattern}` }, + { pattern: `let $OBJ = ${pattern}` }, + { pattern: `var $OBJ = ${pattern}` }, + { pattern: `$OBJ = ${pattern}` } + ] } - } - // simple assignments - const assigns = rootNode.findAll({ rule: { pattern: `$OBJ = ${pattern}` } }); - for (const a of assigns) { - const obj = a.getMatch("OBJ"); + }); + for (const m of matches) { + const obj = m.getMatch("OBJ"); if (!obj) continue; const name = obj.text(); if (/^[$A-Z_a-z][$\w]*$/.test(name)) parseResultVars.add(name); @@ -70,6 +60,7 @@ export default function transform(root: SgRoot): string | null { } // 1.b) Replace parse calls with new URL() + // Also, for declarations using `var`, upgrade to `let` while keeping `const` as-is. for (const pattern of parseCallPatterns) { const calls = rootNode.findAll({ rule: { pattern } }); @@ -80,32 +71,41 @@ export default function transform(root: SgRoot): string | null { const replacement = `new URL(${arg.text()})`; edits.push(call.replace(replacement)); } + + // Upgrade `var` → `let` when a declaration's initializer matches parse(...) + const varDecls = rootNode.findAll({ rule: { pattern: `var $OBJ = ${pattern}` } }); + for (const decl of varDecls) { + const text = decl.text(); + const updated = text.replace(/^var\b/, "let"); + if (updated !== text) edits.push(decl.replace(updated)); + } } // 2) Transform legacy properties on URL object // - auth => `${obj.username}:${obj.password}` // - path => `${obj.pathname}${obj.search}` // - hostname => obj.hostname.replace(/^[\[|\]]$/, '') (strip square brackets) - - // We will only transform legacy properties when the base object is either: - // - a variable assigned from url.parse/parse(...) - // - a direct url.parse/parse(...) call expression - // Destructuring: const { auth } = obj -> const auth = `${obj.username}:${obj.password}` - const fieldsToReplace = [ + const fieldsToReplace = [ { key: "auth", - replaceFn: (base: string, hadSemi: boolean) => - `const auth = \`\${${base}.username}:\${${base}.password}\`${hadSemi ? ";" : ""}`, + replaceFn: (base: string, hadSemi: boolean, declKind: "const" | "let" | "var") => { + const kind = declKind === "var" ? "let" : declKind; + return `${kind} auth = \`\${${base}.username}:\${${base}.password}\`${hadSemi ? ";" : ""}`; + }, }, { key: "path", - replaceFn: (base: string, hadSemi: boolean) => - `const path = \`\${${base}.pathname}\${${base}.search}\`${hadSemi ? ";" : ""}`, + replaceFn: (base: string, hadSemi: boolean, declKind: "const" | "let" | "var") => { + const kind = declKind === "var" ? "let" : declKind; + return `${kind} path = \`\${${base}.pathname}\${${base}.search}\`${hadSemi ? ";" : ""}`; + }, }, { key: "hostname", - replaceFn: (base: string, hadSemi: boolean) => - `const hostname = ${base}.hostname.replace(/^\\[|\\]$/, '')${hadSemi ? ";" : ""}`, + replaceFn: (base: string, hadSemi: boolean, declKind: "const" | "let" | "var") => { + const kind = declKind === "var" ? "let" : declKind; + return `${kind} hostname = ${base}.hostname.replace(/^\\[|\\]$/, '')${hadSemi ? ";" : ""}`; + }, }, ]; @@ -125,11 +125,21 @@ export default function transform(root: SgRoot): string | null { edits.push(node.replace(replacement)); } - // destructuring for identifiers - const destructures = rootNode.findAll({ rule: { pattern: `const { ${key} } = ${varName}` } }); + // destructuring for identifiers without looping kinds + const destructures = rootNode.findAll({ + rule: { + any: [ + { pattern: `const { ${key} } = ${varName}` }, + { pattern: `let { ${key} } = ${varName}` }, + { pattern: `var { ${key} } = ${varName}` } + ] + } + }); for (const node of destructures) { - const hadSemi = /;\s*$/.test(node.text()); - const replacement = replaceFn(varName, hadSemi); + const text = node.text(); + const hadSemi = /;\s*$/.test(text); + const declKind: "const" | "let" | "var" = text.trimStart().startsWith("var ") ? "var" : (text.trimStart().startsWith("const ") ? "const" : "let"); + const replacement = replaceFn(varName, hadSemi, declKind); edits.push(node.replace(replacement)); } } @@ -152,12 +162,24 @@ export default function transform(root: SgRoot): string | null { edits.push(node.replace(replacement)); } - const directDestructures = rootNode.findAll({ rule: { pattern: `const { ${key} } = ${pattern}` } }); + + + // direct destructuring from parse(...), cover all kinds in a single query + const directDestructures = rootNode.findAll({ + rule: { + any: [ + { pattern: `const { ${key} } = ${pattern}` }, + { pattern: `let { ${key} } = ${pattern}` }, + { pattern: `var { ${key} } = ${pattern}` } + ] + } + }); for (const node of directDestructures) { - const hadSemi = /;\s*$/.test(node.text()); - // Extract base expression text (the pattern text matches the whole RHS) - const rhsText = node.text().replace(/^[^{]+{\s*[^}]+\s*}\s*=\s*/, ""); - const replacement = replaceFn(rhsText, hadSemi); + const text = node.text(); + const hadSemi = /;\s*$/.test(text); + const rhsText = text.replace(/^[^{]+{\s*[^}]+\s*}\s*=\s*/, ""); + const declKind: "const" | "let" | "var" = text.trimStart().startsWith("var ") ? "var" : (text.trimStart().startsWith("const ") ? "const" : "let"); + const replacement = replaceFn(rhsText, hadSemi, declKind); edits.push(node.replace(replacement)); } } @@ -178,11 +200,22 @@ export default function transform(root: SgRoot): string | null { edits.push(node.replace(replacement)); } - const newURLDestructures = rootNode.findAll({ rule: { pattern: `const { ${key} } = new URL($ARG)` } }); + // destructuring from new URL, single query for all kinds + const newURLDestructures = rootNode.findAll({ + rule: { + any: [ + { pattern: `const { ${key} } = new URL($ARG)` }, + { pattern: `let { ${key} } = new URL($ARG)` }, + { pattern: `var { ${key} } = new URL($ARG)` } + ] + } + }); for (const node of newURLDestructures) { - const hadSemi = /;\s*$/.test(node.text()); - const rhsText = node.text().replace(/^[^{]+{\s*[^}]+\s*}\s*=\s*/, ""); - const replacement = replaceFn(rhsText, hadSemi); + const text = node.text(); + const hadSemi = /;\s*$/.test(text); + const rhsText = text.replace(/^[^{]+{\s*[^}]+\s*}\s*=\s*/, ""); + const declKind: "const" | "let" | "var" = text.trimStart().startsWith("var ") ? "var" : (text.trimStart().startsWith("const ") ? "const" : "let"); + const replacement = replaceFn(rhsText, hadSemi, declKind); edits.push(node.replace(replacement)); } } diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-7.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-7.js new file mode 100644 index 00000000..033b9802 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-7.js @@ -0,0 +1,3 @@ +import util from "node:util"; + +const x = 1; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-7.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-7.js new file mode 100644 index 00000000..04be6851 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-7.js @@ -0,0 +1,4 @@ +import url from "node:url"; +import util from "node:util"; + +const x = 1; From 6601addc1c3672f7d24d63f67d0598ee7845cb53 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:09:37 +0200 Subject: [PATCH 51/52] Update url-format.ts --- .../node-url-to-whatwg-url/src/url-format.ts | 144 ++++++++++-------- 1 file changed, 82 insertions(+), 62 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts index f774f16c..c1fdc6cb 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -5,6 +5,33 @@ import { resolveBindingPath } from "@nodejs/codemod-utils/ast-grep/resolve-bindi type V = { literal: true; text: string } | { literal: false; code: string }; +type UrlState = { + protocol?: V; + auth?: V; // user:pass + host?: V; // host:port + hostname?: V; + port?: V; + pathname?: V; + search?: V; // ?a=b + hash?: V; // #frag + queryParams?: Array<[string, string]>; +}; + +const handledProps: (keyof UrlState)[] = [ + "protocol", + "auth", + "host", + "hostname", + "port", + "pathname", + "search", + "hash", +]; + +const isHandledProp = (key: string): key is (typeof handledProps)[number] => { + return handledProps.includes(key as (typeof handledProps)[number]); +}; + /** * Get the literal text value of a node, if it exists. * @param node The node to extract the literal text from @@ -29,27 +56,27 @@ const getLiteralText = (node: SgNode | null | undefined): string | undefined => * @returns The value of the pair node, or undefined if not found */ const getValue = (pair: SgNode): V | undefined => { - // string/number/bool - const litNode = pair.find({ - rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] }, - }); - const lit = getLiteralText(litNode); - if (lit !== undefined) return { literal: true, text: lit }; - - // identifier value - const idNode = pair.find({ rule: { kind: "identifier" } }); - if (idNode) return { literal: false, code: idNode.text() }; - - // shorthand property - const shorthand = pair.find({ rule: { kind: "shorthand_property_identifier" } }); - if (shorthand) return { literal: false, code: shorthand.text() }; - - // template string value - const template = pair.find({ rule: { kind: "template_string" } }); - if (template) return { literal: false, code: template.text() }; - - return undefined; - }; + // string/number/bool + const litNode = pair.find({ + rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] }, + }); + const lit = getLiteralText(litNode); + if (lit !== undefined) return { literal: true, text: lit }; + + // identifier value + const idNode = pair.find({ rule: { kind: "identifier" } }); + if (idNode) return { literal: false, code: idNode.text() }; + + // shorthand property + const shorthand = pair.find({ rule: { kind: "shorthand_property_identifier" } }); + if (shorthand) return { literal: false, code: shorthand.text() }; + + // template string value + const template = pair.find({ rule: { kind: "template_string" } }); + if (template) return { literal: false, code: template.text() }; + + return undefined; +}; /** * Transforms url.format() calls to new URL().toString() @@ -65,17 +92,7 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { const objectNode = optionsMatch.find({ rule: { kind: "object" } }); if (!objectNode) continue; - const urlState: { - protocol?: V; - auth?: V; // user:pass - host?: V; // host:port - hostname?: V; - port?: V; - pathname?: V; - search?: V; // ?a=b - hash?: V; // #frag - queryParams?: Array<[string, string]>; - } = {}; + const urlState: UrlState = {}; const pairs = objectNode.findAll({ rule: { kind: "pair" } }); @@ -93,14 +110,16 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { for (const qp of qpairs) { const qkeyNode = qp.find({ rule: { kind: "property_identifier" } }); const qvalLiteral = getLiteralText( - qp.find({ rule:{ - any: [ - { kind: "string" }, - { kind: "number" }, - { kind: "true" }, - { kind: "false" } - ] - } }), + qp.find({ + rule: { + any: [ + { kind: "string" }, + { kind: "number" }, + { kind: "true" }, + { kind: "false" }, + ], + }, + }), ); if (qkeyNode && qvalLiteral !== undefined) list.push([qkeyNode.text(), qvalLiteral]); } @@ -113,13 +132,13 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { const val = getValue(pair); if (!val) continue; - const handledProps = ["protocol", "auth", "host", "hostname", "port", "pathname", "search", "hash"]; - if (handledProps.includes(key)) { + if (isHandledProp(key)) { if (key === "protocol" && val.literal) { urlState.protocol = { literal: true, text: val.text.replace(/:$/, "") }; } else { - // biome-ignore lint/suspicious/noExplicitAny: IDK how to solve that - (urlState as any)[key] = val; + if (key !== "queryParams") { + urlState[key] = val; + } } } } @@ -129,23 +148,23 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { for (const sh of shorthands) { const name = sh.text(); const v: V = { literal: false, code: name }; - const handledProps = ['protocol', 'auth', 'host', 'hostname', 'port', 'pathname', 'search', 'hash']; - if (handledProps.includes(name)) { - // biome-ignore lint/suspicious/noExplicitAny: IDK how to solve that - (urlState as any)[name] = v; + if (isHandledProp(name)) { + if (name !== "queryParams") { + urlState[name] = v; + } } } // Build output segments type Seg = { type: "lit"; text: string } | { type: "expr"; code: string }; const segs: Seg[] = []; - const pushVal = (v?: V) => { + const pushVal = (v?: V) => { if (!v) return; if (v.literal) { if (v.text) segs.push({ type: "lit", text: v.text }); } else { - // v is the non-literal branch here - segs.push({ type: "expr", code: (v as Extract).code }); + // v is the non-literal branch here + segs.push({ type: "expr", code: (v as Extract).code }); } }; @@ -213,16 +232,17 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { const hasExpr = segs.some((s) => s.type === "expr"); let finalExpr: string; if (hasExpr) { - const esc = (s: string) => s.replace(/`/g, "\\`").replace(/\\\$/g, "\\\\$").replace(/\$\{/g, "\\${"); + const esc = (s: string) => + s.replace(/`/g, "\\`").replace(/\\\$/g, "\\\\$").replace(/\$\{/g, "\\${"); finalExpr = `\`${segs.map((s) => (s.type === "lit" ? esc(s.text) : `\${${s.code}}`)).join("")}\``; } else { finalExpr = `'${segs.map((s) => (s.type === "lit" ? s.text : "")).join("")}'`; } - // Include semicolon if original statement had one - const hadSemi = /;\s*$/.test(call.text()); - const replacement = `new URL(${finalExpr}).toString()${hadSemi ? ';' : ''}`; - edits.push(call.replace(replacement)); + // Include semicolon if original statement had one + const hadSemi = /;\s*$/.test(call.text()); + const replacement = `new URL(${finalExpr}).toString()${hadSemi ? ";" : ""}`; + edits.push(call.replace(replacement)); } } @@ -243,15 +263,15 @@ export default function transform(root: SgRoot): string | null { const edits: Edit[] = []; // Safety: only run on files that import/require node:url - const importNodes = getNodeImportStatements(root, "url"); + const importNodes = getNodeImportStatements(root, "url"); const requireNodes = getNodeRequireCalls(root, "url"); - const requiresImports = [...importNodes, ...requireNodes] + const requiresImports = [...importNodes, ...requireNodes]; - if (!requiresImports.length) return null; + if (!requiresImports.length) return null; - const parseCallPatterns = new Set(); + const parseCallPatterns = new Set(); - for (const node of requiresImports) { + for (const node of requiresImports) { const binding = resolveBindingPath(node, "$.format"); if (binding) parseCallPatterns.add(`${binding}($OPTIONS)`); } @@ -265,4 +285,4 @@ export default function transform(root: SgRoot): string | null { if (!edits.length) return null; return rootNode.commitEdits(edits); -}; +} From 328fadd00077ac33ba9595f10a9f81f12fb4c9c7 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:15:22 +0200 Subject: [PATCH 52/52] simplify --- .../src/import-process.ts | 38 ++++++------------- .../tests/import-process/expected/file-1.js | 1 + .../tests/import-process/expected/file-3.js | 1 + .../tests/import-process/expected/file-5.js | 1 + .../tests/import-process/expected/file-6.js | 1 + 5 files changed, 15 insertions(+), 27 deletions(-) diff --git a/recipes/node-url-to-whatwg-url/src/import-process.ts b/recipes/node-url-to-whatwg-url/src/import-process.ts index bd54fcb1..8326acc0 100644 --- a/recipes/node-url-to-whatwg-url/src/import-process.ts +++ b/recipes/node-url-to-whatwg-url/src/import-process.ts @@ -1,10 +1,9 @@ import type { SgRoot, SgNode, Edit, Range } from "@codemod.com/jssg-types/main"; -import type JS from "@codemod.com/jssg-types/langs/javascript"; import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; import { removeLines } from "@nodejs/codemod-utils/ast-grep/remove-lines"; -const isBindingUsed = (rootNode: SgNode, name: string): boolean => { +const isBindingUsed = (rootNode: SgNode, name: string): boolean => { const refs = rootNode.findAll({ rule: { pattern: name } }); // Heuristic: declaration counts as one; any other usage yields > 1 return refs.length > 1; @@ -13,14 +12,13 @@ const isBindingUsed = (rootNode: SgNode, name: string): boolean => { /** * Clean up unused imports/requires from 'node:url' after transforms using shared utils */ -export default function transform(root: SgRoot): string | null { +export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; const linesToRemove: Range[] = []; // 1) ES Module imports: import ... from 'node:url' - // @ts-ignore - ast-grep types vs jssg types const esmImports = getNodeImportStatements(root, "url"); for (const imp of esmImports) { @@ -63,7 +61,6 @@ export default function transform(root: SgRoot): string | null { } // 2) CommonJS requires: const ... = require('node:url') - // @ts-ignore - ast-grep types vs jssg types const requireDecls = getNodeRequireCalls(root, "url"); for (const decl of requireDecls) { @@ -77,16 +74,21 @@ export default function transform(root: SgRoot): string | null { if (hasObjectPattern) { const names: string[] = []; - const shorts = decl.findAll({ rule: { kind: "shorthand_property_identifier_pattern" } }); + const shorts = decl.findAll({ + rule: { kind: "shorthand_property_identifier_pattern" } + }); for (const s of shorts) names.push(s.text()); const pairs = decl.findAll({ rule: { kind: "pair_pattern" } }); + for (const pair of pairs) { const aliasId = pair.find({ rule: { kind: "identifier" } }); if (aliasId) names.push(aliasId.text()); } const usedTexts: string[] = []; - for (const s of shorts) if (isBindingUsed(rootNode, s.text())) usedTexts.push(s.text()); + for (const s of shorts) { + if (isBindingUsed(rootNode, s.text())) usedTexts.push(s.text()); + } for (const pair of pairs) { const aliasId = pair.find({ rule: { kind: "identifier" } }); if (aliasId && isBindingUsed(rootNode, aliasId.text())) usedTexts.push(pair.text()); @@ -103,25 +105,7 @@ export default function transform(root: SgRoot): string | null { if (edits.length === 0 && linesToRemove.length === 0) return null; - let source = rootNode.commitEdits(edits); - - // Only remove the next line if it is blank; don't delete non-empty following lines. - const srcLines = source.split("\n"); - const adjustedRanges = linesToRemove.map((range) => { - const startLine = range.start.line; - let endLine = range.end.line; - const nextLine = endLine + 1; - if (nextLine < srcLines.length) { - const isNextLineBlank = /^\s*$/.test(srcLines[nextLine] ?? ""); - if (isNextLineBlank) endLine = nextLine; - } - return { - start: { line: startLine, column: 0, index: 0 }, - end: { line: endLine, column: 0, index: 0 }, - } as Range; - }); - - source = removeLines(source, adjustedRanges); + const source = rootNode.commitEdits(edits); - return source; + return removeLines(source, linesToRemove); }; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-1.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-1.js index 943c458c..95854893 100644 --- a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-1.js +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-1.js @@ -1 +1,2 @@ + const x = 1; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-3.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-3.js index 1905b485..a5c04cfc 100644 --- a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-3.js +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-3.js @@ -1 +1,2 @@ + function foo() { return 1; } diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-5.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-5.js index 2cc629c7..d2770664 100644 --- a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-5.js +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-5.js @@ -1 +1,2 @@ + export const v = 42; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-6.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-6.js index 4321ad8b..999741ed 100644 --- a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-6.js +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-6.js @@ -1 +1,2 @@ + doStuff();