diff --git a/recipes/axios-to-whatwg-fetch/src/remove-dependencies.ts b/recipes/axios-to-whatwg-fetch/src/remove-dependencies.ts index 8394f2c9..a9970b95 100644 --- a/recipes/axios-to-whatwg-fetch/src/remove-dependencies.ts +++ b/recipes/axios-to-whatwg-fetch/src/remove-dependencies.ts @@ -1,11 +1,11 @@ -import type { Transform } from '@codemod.com/jssg-types/main'; -import type Json from '@codemod.com/jssg-types/langs/json'; +import type { Codemod } from 'codemod:ast-grep'; +import type Json from 'codemod:ast-grep/langs/json'; import removeDependencies from '@nodejs/codemod-utils/remove-dependencies'; /** * Remove `axios` and `@types/axios` dependencies from package.json */ -const transform: Transform = async (root) => { +const transform: Codemod = async (root) => { return removeDependencies(['axios', '@types/axios'], { packageJsonPath: root.filename(), runInstall: false, diff --git a/recipes/axios-to-whatwg-fetch/src/workflow.ts b/recipes/axios-to-whatwg-fetch/src/workflow.ts index 09e8cb29..54078132 100644 --- a/recipes/axios-to-whatwg-fetch/src/workflow.ts +++ b/recipes/axios-to-whatwg-fetch/src/workflow.ts @@ -1,16 +1,18 @@ import { EOL } from 'node:os'; +import { useMetricAtom } from 'codemod:metrics'; +import type { + Edit, + Range, + Rule, + SgNode, + SgRoot, + Codemod +} from 'codemod:ast-grep'; +import type Js from 'codemod:ast-grep/langs/javascript'; import dedent from 'dedent'; import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; import { removeBinding } from '@nodejs/codemod-utils/ast-grep/remove-binding'; import { removeLines } from '@nodejs/codemod-utils/ast-grep/remove-lines'; -import type { - Edit, - Range, - Rule, - SgNode, - SgRoot, -} from '@codemod.com/jssg-types/main'; -import type Js from '@codemod.com/jssg-types/langs/javascript'; import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; type BindingToReplace = { @@ -42,53 +44,46 @@ type AxiosMethodUpdateConfig = { optionalOptionsArg?: boolean; }; -const formatLocation = ({ - root, - node, -}: { - root: SgRoot; - node: SgNode; -}) => { - const { line, column } = node.range().start; - return `${root.filename()}:${line + 1}:${column + 1}`; +const migrationMetric = useMetricAtom('axios-to-fetch-migrations'); +const skippedMetric = useMetricAtom('axios-to-fetch-skipped'); +const filesMetric = useMetricAtom('axios-to-fetch-files'); + +const formatLocation = (root: SgRoot, node: SgNode) => { + const { line, column } = node.range().start; + return `${root.filename()}:${line + 1}:${column + 1}`; }; const warnWithLocation = ( - context: WarningContext, - message: string, - node?: SgNode, + context: WarningContext, + message: string, + node?: SgNode, ) => { - const location = formatLocation({ - root: context.root, - node: node ?? context.match, - }); - console.warn(`[Codemod] ${message} (at ${location})`); + const location = formatLocation(context.root, node ?? context.match); + console.warn(`[Codemod] ${message} (at ${location})`); }; const UNSUPPORTED_CONFIG_OPTIONS = [ - 'beforeRedirect', - 'cancelToken', - 'decompress', - 'httpAgent', - 'httpsAgent', - 'maxBodyLength', - 'maxContentLength', - 'maxRedirects', - 'paramsSerializer', - 'signal', - 'socketPath', - 'timeout', - 'transformRequest', - 'transformResponse', - 'validateStatus', - 'withCredentials', + 'beforeRedirect', + 'cancelToken', + 'decompress', + 'httpAgent', + 'httpsAgent', + 'maxBodyLength', + 'maxContentLength', + 'maxRedirects', + 'paramsSerializer', + 'signal', + 'socketPath', + 'timeout', + 'transformRequest', + 'transformResponse', + 'validateStatus', + 'withCredentials', ] as const; -const getObjectPropertyValue = ( - objectNode: SgNode, - propertyName: string, -) => { +const getObjectPropertyValue = ( objectNode: SgNode, propertyName: string ) => { if (objectNode.kind() !== 'object') return undefined; + const pair = objectNode.find({ rule: { kind: 'pair', @@ -103,9 +98,8 @@ const getObjectPropertyValue = ( return pair?.field('value'); }; -const hasUnsupportedOptions = ( - configNode: SgNode | undefined, -): { unsupported: boolean; optionName?: string } => { +const hasUnsupportedOptions = + (configNode: SgNode | undefined ): { unsupported: boolean; optionName?: string } => { if (!configNode || configNode.kind() !== 'object') { return { unsupported: false }; } @@ -120,10 +114,8 @@ const hasUnsupportedOptions = ( return { unsupported: false }; }; -const getBodyExpression = ( - bodyNode: SgNode, - payloadKind: NonNullable, -) => { +const getBodyExpression = +(bodyNode: SgNode, payloadKind: NonNullable ) => { const source = bodyNode.text(); const trimmed = source.trim(); if (!trimmed || trimmed === 'undefined' || trimmed === 'null') return null; @@ -159,9 +151,9 @@ const getFormBodyExpression = ( return dedent` (() => { - const value = ${source}; - if (value instanceof FormData || value instanceof URLSearchParams) return value; - return new URLSearchParams(value); + const value = ${source}; + if (value instanceof FormData || value instanceof URLSearchParams) return value; + return new URLSearchParams(value); })() `; }; @@ -179,25 +171,28 @@ const createAxiosMethodUpdate = ({ replaceFn: (args: SgNode[], context: WarningContext) => { const url = args[0]; if (!url) { - warnWithLocation(context, `Missing URL in axios.${name}. Skipping.`); - return ''; + warnWithLocation(context, `Missing URL in axios.${name}. Skipping.`); + skippedMetric.increment({ method: name, reason: 'missing-url' }); + return ''; } const options = createOptions({ - oldOptions: args[oldOptionsIndex], - method, - bodyNode: bodyIndex === undefined ? undefined : (args[bodyIndex] ?? null), - payloadKind, + oldOptions: args[oldOptionsIndex], + method, + bodyNode: bodyIndex === undefined ? undefined : (args[bodyIndex] ?? null), + payloadKind, }); const fetchCall = optionalOptionsArg - ? `fetch(${url.text()}${options ? `, ${options}` : ''})` - : `fetch(${url.text()}, ${options})`; + ? `fetch(${url.text()}${options ? `, ${options}` : ''})` + : `fetch(${url.text()}, ${options})`; + + migrationMetric.increment({ method: name }); return dedent.withOptions({ alignValues: true })` - ${fetchCall} - .then(async (${responseAlias}) => Object.assign(${responseAlias}, { data: await ${responseAlias}.json() })) - .catch(() => null) + ${fetchCall} + .then(async (${responseAlias}) => Object.assign(${responseAlias}, { data: await ${responseAlias}.json() })) + .catch(() => null) `; }, }); @@ -257,51 +252,56 @@ const baseUpdates = [ { oldBind: '$.request', replaceFn: (args, context) => { - const config = args[0]; - if (!config) { - warnWithLocation( - context, - 'Missing config object in axios.request. Skipping.', - ); - return ''; - } + const config = args[0]; + if (!config) { + warnWithLocation( + context, + 'Missing config object in axios.request. Skipping.', + ); + skippedMetric.increment({ method: 'request', reason: 'missing-config' }); + return ''; + } - if (config.kind() !== 'object') { - warnWithLocation( - context, - 'Unsupported axios.request configuration shape. Skipping migration.', - config, - ); - return ''; - } + if (config.kind() !== 'object') { + warnWithLocation( + context, + 'Unsupported axios.request configuration shape. Skipping migration.', + config, + ); + skippedMetric.increment({ method: 'request', reason: 'unsupported-shape' }); + return ''; + } - const urlNode = getObjectPropertyValue(config, 'url'); - if (!urlNode) { - warnWithLocation( - context, - 'Missing URL in axios.request config. Skipping migration.', - config, - ); - return ''; - } - const url = urlNode.text(); + const urlNode = getObjectPropertyValue(config, 'url'); + if (!urlNode) { + warnWithLocation( + context, + 'Missing URL in axios.request config. Skipping migration.', + config, + ); + skippedMetric.increment({ method: 'request', reason: 'missing-url' }); + return ''; + } + const url = urlNode.text(); - const methodNode = getObjectPropertyValue(config, 'method'); + const methodNode = getObjectPropertyValue(config, 'method'); - const method = methodNode.child(1)?.text().toUpperCase(); + const method = methodNode.child(1)?.text().toUpperCase(); - const options = createOptions({ - oldOptions: config, - method: method ?? 'GET', // axios.request's default is GET - bodyNode: getObjectPropertyValue(config, 'data') ?? null, - payloadKind: 'json', - }); + const options = createOptions({ + oldOptions: config, + method: method ?? 'GET', // axios.request's default is GET + bodyNode: getObjectPropertyValue(config, 'data') ?? null, + payloadKind: 'json', + }); - return dedent.withOptions({ alignValues: true })` - fetch(${url}${options ? `, ${options}` : ''}) - .then(async (resp) => Object.assign(resp, { data: await resp.json() })) - .catch(() => null) - `; + migrationMetric.increment({ method: 'request', httpMethod: method ?? 'GET' }); + + return dedent.withOptions({ alignValues: true })` + fetch(${url}${options ? `, ${options}` : ''}) + .then(async (resp) => Object.assign(resp, { data: await resp.json() })) + .catch(() => null) + `; }, }, ] satisfies { @@ -311,20 +311,20 @@ const baseUpdates = [ }[]; const updates = baseUpdates.flatMap((update) => { - const bindings = [update.oldBind]; - if ( - // supportDefaultAccess is optional on some update items, so guard access - (!('supportDefaultAccess' in update) || - update.supportDefaultAccess !== false) && - !update.oldBind.includes('.default.') - ) { - bindings.push(update.oldBind.replace('$.', '$.default.')); - } - - return bindings.map((binding) => ({ - oldBind: binding, - replaceFn: update.replaceFn, - })); + const bindings = [update.oldBind]; + if ( + // supportDefaultAccess is optional on some update items, so guard access + (!('supportDefaultAccess' in update) || + update.supportDefaultAccess !== false) && + !update.oldBind.includes('.default.') + ) { + bindings.push(update.oldBind.replace('$.', '$.default.')); + } + + return bindings.map((binding) => ({ + oldBind: binding, + replaceFn: update.replaceFn, + })); }); /** @@ -348,13 +348,13 @@ const createOptions = ({ const headers = oldOptions?.find({ rule: { - kind: 'object', + kind: 'object', inside: { kind: 'pair', has: { - kind: 'property_identifier', - field: 'key', - regex: 'headers', + kind: 'property_identifier', + field: 'key', + regex: 'headers', }, }, }, @@ -374,12 +374,12 @@ const createOptions = ({ if (bodyNode) { const bodyExpression = getBodyExpression(bodyNode, payloadKind); if (bodyExpression) { - // Indent multi-line body expressions properly - const indentedBody = bodyExpression - .split(EOL) - .map((line, i) => (i === 0 ? line : `\t${line}`)) - .join(EOL); - optionParts.push(`\tbody: ${indentedBody}`); + // Indent multi-line body expressions properly + const indentedBody = bodyExpression + .split(EOL) + .map((line, i) => (i === 0 ? line : `\t${line}`)) + .join(EOL); + optionParts.push(`\tbody: ${indentedBody}`); } } @@ -406,7 +406,7 @@ const checkForUnsupportedOptions = ( ): boolean => { for (const bind of bindsToReplace) { const matches = rootNode.findAll({ - rule: bind.rule, + rule: bind.rule, }); for (const match of matches) { @@ -418,12 +418,17 @@ const checkForUnsupportedOptions = ( const config = args[0]; const unsupported = hasUnsupportedOptions(config); if (unsupported.unsupported) { - warnWithLocation( - { root, match }, - `Unsupported axios configuration option '${unsupported.optionName}' detected in axios.request. Skipping migration to preserve functionality.`, - config, - ); - return true; + warnWithLocation( + { root, match }, + `Unsupported axios configuration option '${unsupported.optionName}' detected in axios.request. Skipping migration to preserve functionality.`, + config, + ); + skippedMetric.increment({ + method: 'request', + reason: 'unsupported-option', + option: unsupported.optionName ?? 'unknown', + }); + return true; } } else { // For other methods (get, post, put, patch, delete, head, options) @@ -449,21 +454,21 @@ const checkForUnsupportedOptions = ( `Unsupported axios configuration option '${unsupported.optionName}' detected in axios.${methodName}. Skipping migration to preserve functionality.`, config, ); + skippedMetric.increment({ + method: methodName ?? 'unknown', + reason: 'unsupported-option', + option: unsupported.optionName ?? 'unknown', + }); return true; } } } } + return false; }; -/** - * Transforms the AST root by replacing axios bindings with Fetch API calls. - * - * @param {SgRoot} root - The root of the AST to transform. - * @returns {string | null} The transformed source code or null if no changes were made. - */ -export default function transform(root: SgRoot): string | null { +const codemod: Codemod = async (root: SgRoot,) => { const rootNode = root.root(); const edits: Edit[] = []; const linesToRemove: Range[] = []; @@ -473,6 +478,8 @@ export default function transform(root: SgRoot): string | null { if (!importRequireStatement.length) return null; + filesMetric.increment({ status: 'has-axios-import' }); + for (const node of importRequireStatement) { for (const update of updates) { const bind = resolveBindingPath(node, update.oldBind); @@ -480,9 +487,7 @@ export default function transform(root: SgRoot): string | null { if (!bind) continue; bindsToReplace.push({ - rule: { - pattern: `${bind}($$$ARG)`, - }, + rule: { pattern: `${bind}($$$ARG)` }, node, binding: bind, replaceFn: update.replaceFn, @@ -496,13 +501,13 @@ export default function transform(root: SgRoot): string | null { { root, match: rootNode }, 'One or more axios calls in this file use unsupported configuration options. Skipping migration to preserve functionality.', ); + filesMetric.increment({ status: 'skipped-unsupported-options' }); + return null; } for (const bind of bindsToReplace) { - const matches = rootNode.findAll({ - rule: bind.rule, - }); + const matches = rootNode.findAll({ rule: bind.rule }); for (const match of matches) { const argsAndCommaas = match.getMultipleMatches('ARG'); @@ -523,9 +528,16 @@ export default function transform(root: SgRoot): string | null { } } - if (!edits.length) return null; + if (!edits.length) { + filesMetric.increment({ status: 'no-changes' }); + return null; + } + + filesMetric.increment({ status: 'migrated' }); const sourceCode = rootNode.commitEdits(edits); return removeLines(sourceCode, linesToRemove); } + +export default codemod; diff --git a/recipes/axios-to-whatwg-fetch/tests/component/input.tsx b/recipes/axios-to-whatwg-fetch/tests/component/input.tsx index c7c38fa0..9ef9eb66 100644 --- a/recipes/axios-to-whatwg-fetch/tests/component/input.tsx +++ b/recipes/axios-to-whatwg-fetch/tests/component/input.tsx @@ -1,4 +1,3 @@ -import axios from 'axios'; import { useEffect, useState } from 'react'; type Todo = { id: number; title: string }; @@ -9,8 +8,9 @@ export function TodoList() { useEffect(() => { let active = true; - axios - .get('/api/todos') + fetch('/api/todos') + .then(async (res) => Object.assign(res, { data: await res.json() })) + .catch(() => null) .then((response) => { if (active) { setTodos(response.data.todos); diff --git a/recipes/axios-to-whatwg-fetch/tests/component/metrics.json b/recipes/axios-to-whatwg-fetch/tests/component/metrics.json new file mode 100644 index 00000000..d8f99ef7 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/component/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "get" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/delete-with-config/input.js b/recipes/axios-to-whatwg-fetch/tests/delete-with-config/input.js index 5219afa7..72eeb69d 100644 --- a/recipes/axios-to-whatwg-fetch/tests/delete-with-config/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/delete-with-config/input.js @@ -1,6 +1,8 @@ -import axios from 'axios'; -const deletedTodo = await axios.delete('https://dummyjson.com/todos/1', { - headers: { 'Content-Type': 'application/json' }, -}); +const deletedTodo = await fetch('https://dummyjson.com/todos/1', { + method: "DELETE", + headers: { 'Content-Type': 'application/json' } +}) + .then(async (resp) => Object.assign(resp, { data: await resp.json() })) + .catch(() => null); console.log('\nDELETE /todos1/1 ->', deletedTodo); diff --git a/recipes/axios-to-whatwg-fetch/tests/delete-with-config/metrics.json b/recipes/axios-to-whatwg-fetch/tests/delete-with-config/metrics.json new file mode 100644 index 00000000..e0c5b3b1 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/delete-with-config/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "delete" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/delete/input.js b/recipes/axios-to-whatwg-fetch/tests/delete/input.js index 95d91c94..2d946ad6 100644 --- a/recipes/axios-to-whatwg-fetch/tests/delete/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/delete/input.js @@ -1,5 +1,6 @@ -import axios from 'axios'; const base = 'https://dummyjson.com/todos/1'; -const deletedTodo = await axios.delete(base); +const deletedTodo = await fetch(base, { method: "DELETE" }) + .then(async (resp) => Object.assign(resp, { data: await resp.json() })) + .catch(() => null); console.log('\nDELETE /todos ->', deletedTodo); diff --git a/recipes/axios-to-whatwg-fetch/tests/delete/metrics.json b/recipes/axios-to-whatwg-fetch/tests/delete/metrics.json new file mode 100644 index 00000000..e0c5b3b1 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/delete/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "delete" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/dynamic-import/input.js b/recipes/axios-to-whatwg-fetch/tests/dynamic-import/input.js index 45350709..c2ed881d 100644 --- a/recipes/axios-to-whatwg-fetch/tests/dynamic-import/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/dynamic-import/input.js @@ -1,6 +1,7 @@ -const axiosModule = await import('axios'); export async function fetchTodos() { - const response = await axiosModule.default.get('https://dummyjson.com/todos'); + const response = await fetch('https://dummyjson.com/todos') + .then(async (res) => Object.assign(res, { data: await res.json() })) + .catch(() => null); return response.data; } diff --git a/recipes/axios-to-whatwg-fetch/tests/dynamic-import/metrics.json b/recipes/axios-to-whatwg-fetch/tests/dynamic-import/metrics.json new file mode 100644 index 00000000..d8f99ef7 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/dynamic-import/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "get" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/get-with-config/input.js b/recipes/axios-to-whatwg-fetch/tests/get-with-config/input.js index f6103278..d4718e39 100644 --- a/recipes/axios-to-whatwg-fetch/tests/get-with-config/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/get-with-config/input.js @@ -1,7 +1,6 @@ -import axios from "axios"; -const all = await axios.get("https://dummyjson.com/todos", { - headers: { "Content-Type": "application/json" }, -}); +const all = await fetch("https://dummyjson.com/todos", { headers: { "Content-Type": "application/json" } }) + .then(async (res) => Object.assign(res, { data: await res.json() })) + .catch(() => null); console.log("\nGET /todos ->", all.status); console.log(`Preview: ${all.data.todos.length} todos`); diff --git a/recipes/axios-to-whatwg-fetch/tests/get-with-config/metrics.json b/recipes/axios-to-whatwg-fetch/tests/get-with-config/metrics.json new file mode 100644 index 00000000..d8f99ef7 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/get-with-config/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "get" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/get/input.js b/recipes/axios-to-whatwg-fetch/tests/get/input.js index 6efc338f..045d0c94 100644 --- a/recipes/axios-to-whatwg-fetch/tests/get/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/get/input.js @@ -1,6 +1,7 @@ -import axios from "axios"; const base = "https://dummyjson.com/todos"; -const all = await axios.get(base); +const all = await fetch(base) + .then(async (res) => Object.assign(res, { data: await res.json() })) + .catch(() => null); console.log("\nGET /todos ->", all.status); console.log(`Preview: ${all.data.todos.length} todos`); diff --git a/recipes/axios-to-whatwg-fetch/tests/get/metrics.json b/recipes/axios-to-whatwg-fetch/tests/get/metrics.json new file mode 100644 index 00000000..d8f99ef7 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/get/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "get" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/head-with-config/input.js b/recipes/axios-to-whatwg-fetch/tests/head-with-config/input.js index fea04ab6..3d4c93a5 100644 --- a/recipes/axios-to-whatwg-fetch/tests/head-with-config/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/head-with-config/input.js @@ -1,5 +1,7 @@ -import axios from 'axios'; -const all = await axios.head('https://dummyjson.com/todos', { - headers: { 'Content-Type': 'application/json' }, -}); +const all = await fetch('https://dummyjson.com/todos', { + method: "HEAD", + headers: { 'Content-Type': 'application/json' } +}) + .then(async (resp) => Object.assign(resp, { data: await resp.json() })) + .catch(() => null); diff --git a/recipes/axios-to-whatwg-fetch/tests/head-with-config/metrics.json b/recipes/axios-to-whatwg-fetch/tests/head-with-config/metrics.json new file mode 100644 index 00000000..130ca216 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/head-with-config/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "head" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/head/input.js b/recipes/axios-to-whatwg-fetch/tests/head/input.js index af8e6368..49e7fc17 100644 --- a/recipes/axios-to-whatwg-fetch/tests/head/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/head/input.js @@ -1,3 +1,4 @@ -import axios from 'axios'; -const all = await axios.head('https://dummyjson.com/todos'); +const all = await fetch('https://dummyjson.com/todos', { method: "HEAD" }) + .then(async (resp) => Object.assign(resp, { data: await resp.json() })) + .catch(() => null); diff --git a/recipes/axios-to-whatwg-fetch/tests/head/metrics.json b/recipes/axios-to-whatwg-fetch/tests/head/metrics.json new file mode 100644 index 00000000..130ca216 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/head/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "head" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/import-alias/input.js b/recipes/axios-to-whatwg-fetch/tests/import-alias/input.js index 18ba71a5..3db6070d 100644 --- a/recipes/axios-to-whatwg-fetch/tests/import-alias/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/import-alias/input.js @@ -1,7 +1,8 @@ -import ax from 'axios'; async function loadTodo() { - const response = await ax.get('/todos/1'); + const response = await fetch('/todos/1') + .then(async (res) => Object.assign(res, { data: await res.json() })) + .catch(() => null); return response.data; } diff --git a/recipes/axios-to-whatwg-fetch/tests/import-alias/metrics.json b/recipes/axios-to-whatwg-fetch/tests/import-alias/metrics.json new file mode 100644 index 00000000..d8f99ef7 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/import-alias/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "get" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/mixed-migratable-unmigratable/metrics.json b/recipes/axios-to-whatwg-fetch/tests/mixed-migratable-unmigratable/metrics.json new file mode 100644 index 00000000..f8d1d834 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/mixed-migratable-unmigratable/metrics.json @@ -0,0 +1,26 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "skipped-unsupported-options" + }, + "count": 1 + } + ], + "axios-to-fetch-skipped": [ + { + "cardinality": { + "method": "post", + "option": "transformRequest", + "reason": "unsupported-option" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/mixed-with-axios-request/metrics.json b/recipes/axios-to-whatwg-fetch/tests/mixed-with-axios-request/metrics.json new file mode 100644 index 00000000..0881d0b3 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/mixed-with-axios-request/metrics.json @@ -0,0 +1,26 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "skipped-unsupported-options" + }, + "count": 1 + } + ], + "axios-to-fetch-skipped": [ + { + "cardinality": { + "method": "request", + "option": "validateStatus", + "reason": "unsupported-option" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/multiple-migratable-with-unsupported/metrics.json b/recipes/axios-to-whatwg-fetch/tests/multiple-migratable-with-unsupported/metrics.json new file mode 100644 index 00000000..708e6809 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/multiple-migratable-with-unsupported/metrics.json @@ -0,0 +1,26 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "skipped-unsupported-options" + }, + "count": 1 + } + ], + "axios-to-fetch-skipped": [ + { + "cardinality": { + "method": "post", + "option": "timeout", + "reason": "unsupported-option" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/options-with-config/input.js b/recipes/axios-to-whatwg-fetch/tests/options-with-config/input.js index 11039542..5d58d34a 100644 --- a/recipes/axios-to-whatwg-fetch/tests/options-with-config/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/options-with-config/input.js @@ -1,5 +1,7 @@ -import axios from 'axios'; -const all = await axios.options('https://dummyjson.com/todos', { - headers: { 'Content-Type': 'application/json' }, -}); +const all = await fetch('https://dummyjson.com/todos', { + method: "OPTIONS", + headers: { 'Content-Type': 'application/json' } +}) + .then(async (resp) => Object.assign(resp, { data: await resp.json() })) + .catch(() => null); diff --git a/recipes/axios-to-whatwg-fetch/tests/options-with-config/metrics.json b/recipes/axios-to-whatwg-fetch/tests/options-with-config/metrics.json new file mode 100644 index 00000000..5972b246 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/options-with-config/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "options" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/options/input.js b/recipes/axios-to-whatwg-fetch/tests/options/input.js index 9f46c4d4..f75d7e49 100644 --- a/recipes/axios-to-whatwg-fetch/tests/options/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/options/input.js @@ -1,3 +1,4 @@ -import axios from 'axios'; -const all = await axios.options('https://dummyjson.com/todos'); +const all = await fetch('https://dummyjson.com/todos', { method: "OPTIONS" }) + .then(async (resp) => Object.assign(resp, { data: await resp.json() })) + .catch(() => null); diff --git a/recipes/axios-to-whatwg-fetch/tests/options/metrics.json b/recipes/axios-to-whatwg-fetch/tests/options/metrics.json new file mode 100644 index 00000000..5972b246 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/options/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "options" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/patch-form-with-config/input.js b/recipes/axios-to-whatwg-fetch/tests/patch-form-with-config/input.js index f26b9c55..caf14ae5 100644 --- a/recipes/axios-to-whatwg-fetch/tests/patch-form-with-config/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/patch-form-with-config/input.js @@ -1,12 +1,11 @@ -import axios from 'axios'; -const patched = await axios.patchForm( - 'https://dummyjson.com/forms/2', - { done: true }, - { - headers: { +const patched = await fetch('https://dummyjson.com/forms/2', { + method: "PATCH", + headers: { Accept: 'application/json', }, - }, -); + body: new URLSearchParams({ done: true }) +}) + .then(async (resp) => Object.assign(resp, { data: await resp.json() })) + .catch(() => null); console.log(patched.status); diff --git a/recipes/axios-to-whatwg-fetch/tests/patch-form-with-config/metrics.json b/recipes/axios-to-whatwg-fetch/tests/patch-form-with-config/metrics.json new file mode 100644 index 00000000..2d383054 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/patch-form-with-config/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "patchForm" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/patch/input.js b/recipes/axios-to-whatwg-fetch/tests/patch/input.js index e4e92c2d..aef034aa 100644 --- a/recipes/axios-to-whatwg-fetch/tests/patch/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/patch/input.js @@ -1,10 +1,14 @@ -import axios from 'axios'; // Unsupported method: axios.patch const base = 'https://dummyjson.com/todos/1'; -const patchedTodo = await axios.patch(base, { - todo: 'Updated todo', - completed: true, -}); +const patchedTodo = await fetch(base, { + method: "PATCH", + body: JSON.stringify({ + todo: 'Updated todo', + completed: true, + }) +}) + .then(async (resp) => Object.assign(resp, { data: await resp.json() })) + .catch(() => null); console.log('\nPATCH /todos/1 ->', patchedTodo); diff --git a/recipes/axios-to-whatwg-fetch/tests/patch/metrics.json b/recipes/axios-to-whatwg-fetch/tests/patch/metrics.json new file mode 100644 index 00000000..0a7a00ef --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/patch/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "patch" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/post-form-formdata/input.js b/recipes/axios-to-whatwg-fetch/tests/post-form-formdata/input.js index c798815e..70874347 100644 --- a/recipes/axios-to-whatwg-fetch/tests/post-form-formdata/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/post-form-formdata/input.js @@ -1,6 +1,14 @@ -import axios from 'axios'; const formData = new FormData(); formData.append('name', 'Node.js'); -await axios.postForm('https://dummyjson.com/forms', formData); +await fetch('https://dummyjson.com/forms', { + method: "POST", + body: (() => { + const value = formData; + if (value instanceof FormData || value instanceof URLSearchParams) return value; + return new URLSearchParams(value); + })() +}) + .then(async (resp) => Object.assign(resp, { data: await resp.json() })) + .catch(() => null); diff --git a/recipes/axios-to-whatwg-fetch/tests/post-form-formdata/metrics.json b/recipes/axios-to-whatwg-fetch/tests/post-form-formdata/metrics.json new file mode 100644 index 00000000..e7de1521 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/post-form-formdata/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "postForm" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/post-form/input.js b/recipes/axios-to-whatwg-fetch/tests/post-form/input.js index 499347c7..42d2e8c6 100644 --- a/recipes/axios-to-whatwg-fetch/tests/post-form/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/post-form/input.js @@ -1,8 +1,12 @@ -import axios from 'axios'; const base = 'https://dummyjson.com/forms'; -const created = await axios.postForm(`${base}/submit`, { - title: 'Form Demo', - completed: false, -}); +const created = await fetch(`${base}/submit`, { + method: "POST", + body: new URLSearchParams({ + title: 'Form Demo', + completed: false, + }) +}) + .then(async (resp) => Object.assign(resp, { data: await resp.json() })) + .catch(() => null); console.log(created); diff --git a/recipes/axios-to-whatwg-fetch/tests/post-form/metrics.json b/recipes/axios-to-whatwg-fetch/tests/post-form/metrics.json new file mode 100644 index 00000000..e7de1521 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/post-form/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "postForm" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/post-with-config/input.js b/recipes/axios-to-whatwg-fetch/tests/post-with-config/input.js index f887b607..4a7d9b91 100644 --- a/recipes/axios-to-whatwg-fetch/tests/post-with-config/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/post-with-config/input.js @@ -1,15 +1,14 @@ -import axios from 'axios'; const base = 'https://dummyjson.com/todos/add'; -const createdTodo = await axios.post( - base, - { - todo: 'Use DummyJSON in the project', - completed: false, - userId: 5, - }, - { - headers: { 'Content-Type': 'application/json' }, - }, -); +const createdTodo = await fetch(base, { + method: "POST", + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + todo: 'Use DummyJSON in the project', + completed: false, + userId: 5, + }) +}) + .then(async (resp) => Object.assign(resp, { data: await resp.json() })) + .catch(() => null); console.log('\nPOST /todos/add ->', createdTodo); diff --git a/recipes/axios-to-whatwg-fetch/tests/post-with-config/metrics.json b/recipes/axios-to-whatwg-fetch/tests/post-with-config/metrics.json new file mode 100644 index 00000000..45fe151f --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/post-with-config/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "post" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/post/input.js b/recipes/axios-to-whatwg-fetch/tests/post/input.js index 2db408de..5c7e4b0c 100644 --- a/recipes/axios-to-whatwg-fetch/tests/post/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/post/input.js @@ -1,9 +1,13 @@ -import axios from 'axios'; const base = 'https://dummyjson.com/todos/add'; -const todoCreated = await axios.post(base, { - todo: 'Use DummyJSON in the project', - completed: false, - userId: 5, -}); +const todoCreated = await fetch(base, { + method: "POST", + body: JSON.stringify({ + todo: 'Use DummyJSON in the project', + completed: false, + userId: 5, + }) +}) + .then(async (resp) => Object.assign(resp, { data: await resp.json() })) + .catch(() => null); console.log('\nPOST /todos ->', todoCreated); diff --git a/recipes/axios-to-whatwg-fetch/tests/post/metrics.json b/recipes/axios-to-whatwg-fetch/tests/post/metrics.json new file mode 100644 index 00000000..45fe151f --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/post/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "post" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/put-form/input.js b/recipes/axios-to-whatwg-fetch/tests/put-form/input.js index 2acceecf..dd3ede52 100644 --- a/recipes/axios-to-whatwg-fetch/tests/put-form/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/put-form/input.js @@ -1,6 +1,14 @@ -import axios from 'axios'; const payload = { status: 'open' }; -const updated = await axios.putForm('https://dummyjson.com/forms/1', payload); +const updated = await fetch('https://dummyjson.com/forms/1', { + method: "PUT", + body: (() => { + const value = payload; + if (value instanceof FormData || value instanceof URLSearchParams) return value; + return new URLSearchParams(value); + })() +}) + .then(async (resp) => Object.assign(resp, { data: await resp.json() })) + .catch(() => null); console.log(updated.status); diff --git a/recipes/axios-to-whatwg-fetch/tests/put-form/metrics.json b/recipes/axios-to-whatwg-fetch/tests/put-form/metrics.json new file mode 100644 index 00000000..40dae2b8 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/put-form/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "putForm" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/put-with-config/input.js b/recipes/axios-to-whatwg-fetch/tests/put-with-config/input.js index efc0e103..4ed19d5f 100644 --- a/recipes/axios-to-whatwg-fetch/tests/put-with-config/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/put-with-config/input.js @@ -1,15 +1,14 @@ -import axios from 'axios'; const base = 'https://dummyjson.com/todos/1'; -const updatedTodo = await axios.put( - base, - { - todo: 'Use DummyJSON in the project', - completed: false, - userId: 5, - }, - { - headers: { 'Content-Type': 'application/json' }, - }, -); +const updatedTodo = await fetch(base, { + method: "PUT", + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + todo: 'Use DummyJSON in the project', + completed: false, + userId: 5, + }) +}) + .then(async (resp) => Object.assign(resp, { data: await resp.json() })) + .catch(() => null); console.log('\nPUT /todos/1 ->', updatedTodo); diff --git a/recipes/axios-to-whatwg-fetch/tests/put-with-config/metrics.json b/recipes/axios-to-whatwg-fetch/tests/put-with-config/metrics.json new file mode 100644 index 00000000..4fed96e8 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/put-with-config/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "put" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/put/input.js b/recipes/axios-to-whatwg-fetch/tests/put/input.js index d2c98b6d..127f1d81 100644 --- a/recipes/axios-to-whatwg-fetch/tests/put/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/put/input.js @@ -1,9 +1,13 @@ -import axios from 'axios'; const base = 'https://dummyjson.com/todos/1'; -const updatedTodo = await axios.put(base, { - todo: 'Use DummyJSON in the project', - completed: false, - userId: 5, -}); +const updatedTodo = await fetch(base, { + method: "PUT", + body: JSON.stringify({ + todo: 'Use DummyJSON in the project', + completed: false, + userId: 5, + }) +}) + .then(async (resp) => Object.assign(resp, { data: await resp.json() })) + .catch(() => null); console.log('\nPUT /todos/1 ->', updatedTodo); diff --git a/recipes/axios-to-whatwg-fetch/tests/put/metrics.json b/recipes/axios-to-whatwg-fetch/tests/put/metrics.json new file mode 100644 index 00000000..4fed96e8 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/put/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "put" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/request/expected.js b/recipes/axios-to-whatwg-fetch/tests/request/expected.js index 0ad2d0ae..178ac2de 100644 --- a/recipes/axios-to-whatwg-fetch/tests/request/expected.js +++ b/recipes/axios-to-whatwg-fetch/tests/request/expected.js @@ -1,13 +1,29 @@ const base = 'https://dummyjson.com/todos/1'; -const customRequest = await fetch(base, { +const customPatch = await fetch(base, { method: "PATCH", body: JSON.stringify({ todo: 'Updated todo', completed: true, }) }) - .then(async (resp) => Object.assign(resp, { data: await resp.json() })) - .catch(() => null); -console.log('\nREQUEST /todos/1 ->', customRequest); +.then(async (resp) => Object.assign(resp, { data: await resp.json() })) +.catch(() => null); +console.log('\nPATCH /todos/1 ->', customPatch); + +const customGet = await fetch(base, { method: "GET" }) +.then(async (resp) => Object.assign(resp, { data: await resp.json() })) +.catch(() => null); +console.log('\nGET /todos/1 ->', customGet); + +const customPost = await fetch('https://dummyjson.com/todos/add', { + method: "POST", + body: JSON.stringify({ + todo: 'New todo', + completed: false, + }) +}) +.then(async (resp) => Object.assign(resp, { data: await resp.json() })) +.catch(() => null); +console.log('\nPOST /todos/add ->', customPost); diff --git a/recipes/axios-to-whatwg-fetch/tests/request/input.js b/recipes/axios-to-whatwg-fetch/tests/request/input.js index edb41798..178ac2de 100644 --- a/recipes/axios-to-whatwg-fetch/tests/request/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/request/input.js @@ -1,13 +1,29 @@ -import axios from 'axios'; const base = 'https://dummyjson.com/todos/1'; -const customRequest = await axios.request({ - url: base, - method: 'PATCH', - data: { - todo: 'Updated todo', - completed: true, - }, -}); -console.log('\nREQUEST /todos/1 ->', customRequest); +const customPatch = await fetch(base, { + method: "PATCH", + body: JSON.stringify({ + todo: 'Updated todo', + completed: true, + }) +}) +.then(async (resp) => Object.assign(resp, { data: await resp.json() })) +.catch(() => null); +console.log('\nPATCH /todos/1 ->', customPatch); + +const customGet = await fetch(base, { method: "GET" }) +.then(async (resp) => Object.assign(resp, { data: await resp.json() })) +.catch(() => null); +console.log('\nGET /todos/1 ->', customGet); + +const customPost = await fetch('https://dummyjson.com/todos/add', { + method: "POST", + body: JSON.stringify({ + todo: 'New todo', + completed: false, + }) +}) +.then(async (resp) => Object.assign(resp, { data: await resp.json() })) +.catch(() => null); +console.log('\nPOST /todos/add ->', customPost); diff --git a/recipes/axios-to-whatwg-fetch/tests/request/metrics.json b/recipes/axios-to-whatwg-fetch/tests/request/metrics.json new file mode 100644 index 00000000..c33f1df5 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/request/metrics.json @@ -0,0 +1,39 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "httpMethod": "GET", + "method": "request" + }, + "count": 1 + }, + { + "cardinality": { + "httpMethod": "PATCH", + "method": "request" + }, + "count": 1 + }, + { + "cardinality": { + "httpMethod": "POST", + "method": "request" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/require/input.js b/recipes/axios-to-whatwg-fetch/tests/require/input.js index f18a42b5..e8b4f4a1 100644 --- a/recipes/axios-to-whatwg-fetch/tests/require/input.js +++ b/recipes/axios-to-whatwg-fetch/tests/require/input.js @@ -1,7 +1,8 @@ -const axios = require('axios'); function fetchAllTodos() { - return axios.get('https://dummyjson.com/todos'); + return fetch('https://dummyjson.com/todos') + .then(async (res) => Object.assign(res, { data: await res.json() })) + .catch(() => null); } module.exports = { fetchAllTodos }; diff --git a/recipes/axios-to-whatwg-fetch/tests/require/metrics.json b/recipes/axios-to-whatwg-fetch/tests/require/metrics.json new file mode 100644 index 00000000..d8f99ef7 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/require/metrics.json @@ -0,0 +1,24 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "axios-to-fetch-migrations": [ + { + "cardinality": { + "method": "get" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/unsupported-axios-request-config/metrics.json b/recipes/axios-to-whatwg-fetch/tests/unsupported-axios-request-config/metrics.json new file mode 100644 index 00000000..09a552cb --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/unsupported-axios-request-config/metrics.json @@ -0,0 +1,26 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "skipped-unsupported-options" + }, + "count": 1 + } + ], + "axios-to-fetch-skipped": [ + { + "cardinality": { + "method": "request", + "option": "transformRequest", + "reason": "unsupported-option" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/unsupported-params-serializer/metrics.json b/recipes/axios-to-whatwg-fetch/tests/unsupported-params-serializer/metrics.json new file mode 100644 index 00000000..dc272679 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/unsupported-params-serializer/metrics.json @@ -0,0 +1,26 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "skipped-unsupported-options" + }, + "count": 1 + } + ], + "axios-to-fetch-skipped": [ + { + "cardinality": { + "method": "get", + "option": "paramsSerializer", + "reason": "unsupported-option" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/unsupported-transform-request/metrics.json b/recipes/axios-to-whatwg-fetch/tests/unsupported-transform-request/metrics.json new file mode 100644 index 00000000..f8d1d834 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/unsupported-transform-request/metrics.json @@ -0,0 +1,26 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "skipped-unsupported-options" + }, + "count": 1 + } + ], + "axios-to-fetch-skipped": [ + { + "cardinality": { + "method": "post", + "option": "transformRequest", + "reason": "unsupported-option" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/axios-to-whatwg-fetch/tests/unsupported-transform-response/metrics.json b/recipes/axios-to-whatwg-fetch/tests/unsupported-transform-response/metrics.json new file mode 100644 index 00000000..143c4dd5 --- /dev/null +++ b/recipes/axios-to-whatwg-fetch/tests/unsupported-transform-response/metrics.json @@ -0,0 +1,26 @@ +{ + "axios-to-fetch-files": [ + { + "cardinality": { + "status": "has-axios-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "skipped-unsupported-options" + }, + "count": 1 + } + ], + "axios-to-fetch-skipped": [ + { + "cardinality": { + "method": "get", + "option": "transformResponse", + "reason": "unsupported-option" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/buffer-atob-btoa/src/workflow.ts b/recipes/buffer-atob-btoa/src/workflow.ts index 392ab335..7ea520c0 100644 --- a/recipes/buffer-atob-btoa/src/workflow.ts +++ b/recipes/buffer-atob-btoa/src/workflow.ts @@ -1,12 +1,16 @@ -import type { Edit, Range, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; -import type Js from '@codemod.com/jssg-types/langs/javascript'; +import { useMetricAtom } from 'codemod:metrics'; +import type { Edit, Range, SgNode, Codemod } from 'codemod:ast-grep'; +import type Js from 'codemod:ast-grep/langs/javascript'; import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; -import { getNodeRequireCalls } from '@nodejs/codemod-utils/ast-grep/require-call'; -import { getNodeImportStatements } from '@nodejs/codemod-utils/ast-grep/import-statement'; +import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; import { removeLines } from '@nodejs/codemod-utils/ast-grep/remove-lines'; import { removeBinding } from '@nodejs/codemod-utils/ast-grep/remove-binding'; -export default function transform(root: SgRoot): string | null { + +const migrationMetric = useMetricAtom('buffer-atob-btoa-migrations'); +const filesMetric = useMetricAtom('buffer-atob-btoa-files'); + +const transform: Codemod = async (root) => { const rootNode = root.root(); const bindingStatementFnTuples: [ string, @@ -29,14 +33,13 @@ export default function transform(root: SgRoot): string | null { }, ]; - const statements = [ - ...getNodeRequireCalls(root, 'buffer'), - ...getNodeImportStatements(root, 'buffer'), - ]; + const statements = getModuleDependencies(root, 'buffer'); // If no statements found, skip transformation if (!statements.length) return null; + filesMetric.increment({ status: 'has-buffer-import' }); + for (const statement of statements) { for (const update of updates) { const binding = resolveBindingPath(statement, update.oldBind); @@ -47,6 +50,7 @@ export default function transform(root: SgRoot): string | null { for (const [binding, statement, fn] of bindingStatementFnTuples) { const result = removeBinding(statement, binding); + const fnName = binding.split('.').pop() ?? binding; if (result?.edit) edits.push(result.edit); if (result?.lineToRemove) linesToRemove.push(result.lineToRemove); @@ -79,7 +83,11 @@ export default function transform(root: SgRoot): string | null { for (const call of calls) { const argMatch = call.getMatch('ARG'); - if (argMatch) edits.push(call.replace(fn(argMatch.text()))); + + if (argMatch) { + edits.push(call.replace(fn(argMatch.text()))); + migrationMetric.increment({ fn: fnName }); + } } if (calls.length === otherCalls.length) { @@ -87,7 +95,14 @@ export default function transform(root: SgRoot): string | null { } } - if (!edits.length) return null; + if (!edits.length) { + filesMetric.increment({ status: 'no-changes' }); + return null; + } + + filesMetric.increment({ status: 'migrated' }); return removeLines(rootNode.commitEdits(edits), linesToRemove); } + +export default transform; diff --git a/recipes/create-require-from-path/src/workflow.ts b/recipes/create-require-from-path/src/workflow.ts index 83f38d49..1c6a28a7 100644 --- a/recipes/create-require-from-path/src/workflow.ts +++ b/recipes/create-require-from-path/src/workflow.ts @@ -1,7 +1,11 @@ +import type { Codemod, Edit } from 'codemod:ast-grep'; +import type JS from 'codemod:ast-grep/langs/javascript'; +import { useMetricAtom } from 'codemod:metrics'; 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'; + +const migrationMetric = useMetricAtom('create-require-from-path-migrations'); +const filesMetric = useMetricAtom('create-require-from-path-files'); /** * Transform function that updates code to replace deprecated `createRequireFromPath` usage @@ -21,7 +25,7 @@ import type JS from '@codemod.com/jssg-types/langs/javascript'; * * 3. Preserves original variable names and declaration types. */ -export default function transform(root: SgRoot): string | null { +const transform: Codemod = async (root) => { const rootNode = root.root(); const edits: Edit[] = []; @@ -45,6 +49,7 @@ export default function transform(root: SgRoot): string | null { 'createRequire', ); edits.push(objectPattern.replace(newText)); + migrationMetric.increment({ kind: 'require-destructure' }); } } } @@ -68,6 +73,7 @@ export default function transform(root: SgRoot): string | null { 'createRequire', ); edits.push(namedImports.replace(newText)); + migrationMetric.increment({ kind: 'import-named' }); } } } @@ -131,6 +137,10 @@ export default function transform(root: SgRoot): string | null { }); edits.push(key.replace('createRequire')); + migrationMetric.increment({ + kind: 'renamed-alias', + source: rename.kind() === 'import_specifier' ? 'esm' : 'cjs', + }); } } @@ -147,10 +157,18 @@ export default function transform(root: SgRoot): string | null { const arg = argMatch.text(); const replacement = `createRequire(${arg})`; edits.push(call.replace(replacement)); + migrationMetric.increment({ kind: 'function-call' }); } } - if (!edits.length) return null; + if (!edits.length) { + filesMetric.increment({ status: 'no-changes' }); + return null; + } + + filesMetric.increment({ status: 'migrated' }); return rootNode.commitEdits(edits); } + +export default transform; diff --git a/recipes/create-require-from-path/tests/edge-case/metrics.json b/recipes/create-require-from-path/tests/edge-case/metrics.json new file mode 100644 index 00000000..4e814d79 --- /dev/null +++ b/recipes/create-require-from-path/tests/edge-case/metrics.json @@ -0,0 +1,24 @@ +{ + "create-require-from-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "create-require-from-path-migrations": [ + { + "cardinality": { + "kind": "function-call" + }, + "count": 3 + }, + { + "cardinality": { + "kind": "require-destructure" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/create-require-from-path/tests/file-1-module/metrics.json b/recipes/create-require-from-path/tests/file-1-module/metrics.json new file mode 100644 index 00000000..56b8060e --- /dev/null +++ b/recipes/create-require-from-path/tests/file-1-module/metrics.json @@ -0,0 +1,24 @@ +{ + "create-require-from-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "create-require-from-path-migrations": [ + { + "cardinality": { + "kind": "function-call" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "import-named" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/create-require-from-path/tests/file-1/metrics.json b/recipes/create-require-from-path/tests/file-1/metrics.json new file mode 100644 index 00000000..6b97314b --- /dev/null +++ b/recipes/create-require-from-path/tests/file-1/metrics.json @@ -0,0 +1,24 @@ +{ + "create-require-from-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "create-require-from-path-migrations": [ + { + "cardinality": { + "kind": "function-call" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "require-destructure" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/create-require-from-path/tests/file-2-module/metrics.json b/recipes/create-require-from-path/tests/file-2-module/metrics.json new file mode 100644 index 00000000..56b8060e --- /dev/null +++ b/recipes/create-require-from-path/tests/file-2-module/metrics.json @@ -0,0 +1,24 @@ +{ + "create-require-from-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "create-require-from-path-migrations": [ + { + "cardinality": { + "kind": "function-call" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "import-named" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/create-require-from-path/tests/file-2/metrics.json b/recipes/create-require-from-path/tests/file-2/metrics.json new file mode 100644 index 00000000..6b97314b --- /dev/null +++ b/recipes/create-require-from-path/tests/file-2/metrics.json @@ -0,0 +1,24 @@ +{ + "create-require-from-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "create-require-from-path-migrations": [ + { + "cardinality": { + "kind": "function-call" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "require-destructure" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/create-require-from-path/tests/import-with-alias-module/metrics.json b/recipes/create-require-from-path/tests/import-with-alias-module/metrics.json new file mode 100644 index 00000000..710a2084 --- /dev/null +++ b/recipes/create-require-from-path/tests/import-with-alias-module/metrics.json @@ -0,0 +1,19 @@ +{ + "create-require-from-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "create-require-from-path-migrations": [ + { + "cardinality": { + "kind": "renamed-alias", + "source": "esm" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/create-require-from-path/tests/require-with-alias-common/metrics.json b/recipes/create-require-from-path/tests/require-with-alias-common/metrics.json new file mode 100644 index 00000000..d77ab1c3 --- /dev/null +++ b/recipes/create-require-from-path/tests/require-with-alias-common/metrics.json @@ -0,0 +1,19 @@ +{ + "create-require-from-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "create-require-from-path-migrations": [ + { + "cardinality": { + "kind": "renamed-alias", + "source": "cjs" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/createCredentials-to-createSecureContext/src/workflow.ts b/recipes/createCredentials-to-createSecureContext/src/workflow.ts index b02d0c82..81f993f8 100644 --- a/recipes/createCredentials-to-createSecureContext/src/workflow.ts +++ b/recipes/createCredentials-to-createSecureContext/src/workflow.ts @@ -1,16 +1,15 @@ import { EOL } from 'node:os'; import { getNodeRequireCalls } from '@nodejs/codemod-utils/ast-grep/require-call'; -import { - getNodeImportCalls, - getNodeImportStatements, -} from '@nodejs/codemod-utils/ast-grep/import-statement'; +import { useMetricAtom } from 'codemod:metrics'; +import type { Codemod, SgRoot, Edit, SgNode } from 'codemod:ast-grep'; +import type Js from 'codemod:ast-grep/langs/javascript'; +import { getNodeImportCalls, getNodeImportStatements } from '@nodejs/codemod-utils/ast-grep/import-statement'; import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; -import type { SgRoot, Edit, SgNode } from '@codemod.com/jssg-types/main'; -import type Js from '@codemod.com/jssg-types/langs/javascript'; type SourceHandler = (statement: SgNode, rootNode: SgRoot) => Edit[]; -type SourceTuple = [SgNode[], SourceHandler]; +type SourceTuple = [SgNode[], SourceHandler, SourceKind]; +type SourceKind = 'require' | 'static-import' | 'dynamic-import'; const newImportFunction = 'createSecureContext'; const newImportModule = 'node:tls'; @@ -18,6 +17,10 @@ const oldFunctionName = 'createCredentials'; const oldImportModule = 'node:crypto'; const newNamespace = 'tls'; +const importMetric = useMetricAtom('crypto-createcredentials-imports'); +const usageMetric = useMetricAtom('crypto-createcredentials-usages'); +const filesMetric = useMetricAtom('crypto-createcredentials-files'); + function replaceUsagesForResolvedPath( rootNode: SgRoot, resolvedPath: string, @@ -39,10 +42,14 @@ function replaceUsagesForResolvedPath( }, }); - return usages + const edits = usages .map((u) => u.field('function')) .filter((f): f is SgNode => Boolean(f)) .map((f) => f.replace(`${newNamespace}.${newImportFunction}`)); + + if (edits.length) usageMetric.increment({ kind: 'member-expression', count: edits.length.toString() }); + + return edits; } // Destructured identifier usage @@ -59,10 +66,14 @@ function replaceUsagesForResolvedPath( }, }); - return usages + const edits = usages .map((usage) => usage.field('function')) .filter((id): id is SgNode => Boolean(id)) .map((id) => id.replace(newImportFunction)); + + if (edits.length) usageMetric.increment({ kind: 'identifier', count: edits.length.toString() }); + + return edits; } // Aliased destructured identifier => keep usages intact @@ -82,6 +93,7 @@ function handleRequire(statement: SgNode, rootNode: SgRoot): Edit[] { if (idNode.kind() === 'identifier') { // Namespace require: replace import and usages + importMetric.increment({ source: 'require', shape: 'namespace' }); return [ ...usageEdits, declaration.replace( @@ -114,6 +126,11 @@ function handleRequire(statement: SgNode, rootNode: SgRoot): Edit[] { : `{ ${newImportFunction} }`; const newImportStatement = `const ${newImportSpecifier} = require('${newImportModule}');`; + importMetric.increment({ + source: 'require', + shape: isAliased ? 'named-aliased' : 'named', + }); + if (otherSpecifiers.length > 0) { const othersText = otherSpecifiers.map((s) => s.text()).join(', '); const modifiedOldImport = `const { ${othersText} } = require('${oldImportModule}');`; @@ -146,6 +163,7 @@ function handleStaticImport( // Namespace imports: import * as ns from '...' if (content.kind() === 'namespace_import') { + importMetric.increment({ source: 'static-import', shape: 'namespace' }); return [ ...usageEdits, statement.replace( @@ -169,6 +187,11 @@ function handleStaticImport( : `{ ${newImportFunction} }`; const newStmt = `import ${newSpec} from '${newImportModule}';`; + importMetric.increment({ + source: 'static-import', + shape: isAliased ? 'named-aliased' : 'named', + }); + return [ ...usageEdits, otherSpecs.length @@ -200,6 +223,7 @@ function handleDynamicImport( // Case 1: `const ns = await import(...)` if (idNode?.kind() === 'identifier') { + importMetric.increment({ source: 'dynamic-import', shape: 'namespace' }); return [ ...usageEdits, declaration.replace( @@ -232,6 +256,11 @@ function handleDynamicImport( : `{ ${newImportFunction} }`; const newImportStmt = `const ${newImportSpecifier} = await import('${newImportModule}');`; + importMetric.increment({ + source: 'dynamic-import', + shape: isAliased ? 'named-aliased' : 'named', + }); + return [ ...usageEdits, otherSpecifiers.length @@ -245,19 +274,23 @@ function handleDynamicImport( return []; } -export default function transform(root: SgRoot): string | null { +const transform: Codemod = async (root) => { const rootNode = root.root(); const allEdits: Edit[] = []; const sources: SourceTuple[] = [ - [getNodeRequireCalls(root, 'crypto'), handleRequire], - [getNodeImportStatements(root, 'crypto'), handleStaticImport], - [getNodeImportCalls(root, 'crypto'), handleDynamicImport], + [getNodeRequireCalls(root, 'crypto'), handleRequire, 'require'], + [getNodeImportStatements(root, 'crypto'), handleStaticImport, 'static-import'], + [getNodeImportCalls(root, 'crypto'), handleDynamicImport, 'dynamic-import'], ]; + let sawSource = false; + for (const [nodes, handler] of sources) { // if no nodes found, skip to next source type if (!nodes.length) continue; + sawSource = true; + for (const node of nodes) { const edits = handler(node, root); @@ -267,7 +300,16 @@ export default function transform(root: SgRoot): string | null { } } - if (!allEdits.length) return null; + if (sawSource) filesMetric.increment({ status: 'has-crypto-import' }); + + if (!allEdits.length) { + filesMetric.increment({ status: 'no-changes' }); + return null; + } + + filesMetric.increment({ status: 'migrated' }); return rootNode.commitEdits(allEdits); } + +export default transform; diff --git a/recipes/createCredentials-to-createSecureContext/tests/file-1-common/metrics.json b/recipes/createCredentials-to-createSecureContext/tests/file-1-common/metrics.json new file mode 100644 index 00000000..5dafec23 --- /dev/null +++ b/recipes/createCredentials-to-createSecureContext/tests/file-1-common/metrics.json @@ -0,0 +1,34 @@ +{ + "crypto-createcredentials-files": [ + { + "cardinality": { + "status": "has-crypto-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-createcredentials-imports": [ + { + "cardinality": { + "shape": "named", + "source": "require" + }, + "count": 1 + } + ], + "crypto-createcredentials-usages": [ + { + "cardinality": { + "count": "1", + "kind": "identifier" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/createCredentials-to-createSecureContext/tests/file-1-module/metrics.json b/recipes/createCredentials-to-createSecureContext/tests/file-1-module/metrics.json new file mode 100644 index 00000000..9427a852 --- /dev/null +++ b/recipes/createCredentials-to-createSecureContext/tests/file-1-module/metrics.json @@ -0,0 +1,34 @@ +{ + "crypto-createcredentials-files": [ + { + "cardinality": { + "status": "has-crypto-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-createcredentials-imports": [ + { + "cardinality": { + "shape": "named", + "source": "static-import" + }, + "count": 1 + } + ], + "crypto-createcredentials-usages": [ + { + "cardinality": { + "count": "1", + "kind": "identifier" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/createCredentials-to-createSecureContext/tests/file-1-pair-common/metrics.json b/recipes/createCredentials-to-createSecureContext/tests/file-1-pair-common/metrics.json new file mode 100644 index 00000000..cc3fe15d --- /dev/null +++ b/recipes/createCredentials-to-createSecureContext/tests/file-1-pair-common/metrics.json @@ -0,0 +1,25 @@ +{ + "crypto-createcredentials-files": [ + { + "cardinality": { + "status": "has-crypto-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-createcredentials-imports": [ + { + "cardinality": { + "shape": "named-aliased", + "source": "require" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/createCredentials-to-createSecureContext/tests/file-1-pair-module/metrics.json b/recipes/createCredentials-to-createSecureContext/tests/file-1-pair-module/metrics.json new file mode 100644 index 00000000..0c3357da --- /dev/null +++ b/recipes/createCredentials-to-createSecureContext/tests/file-1-pair-module/metrics.json @@ -0,0 +1,25 @@ +{ + "crypto-createcredentials-files": [ + { + "cardinality": { + "status": "has-crypto-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-createcredentials-imports": [ + { + "cardinality": { + "shape": "named-aliased", + "source": "static-import" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/createCredentials-to-createSecureContext/tests/file-2-common/metrics.json b/recipes/createCredentials-to-createSecureContext/tests/file-2-common/metrics.json new file mode 100644 index 00000000..4db041b5 --- /dev/null +++ b/recipes/createCredentials-to-createSecureContext/tests/file-2-common/metrics.json @@ -0,0 +1,34 @@ +{ + "crypto-createcredentials-files": [ + { + "cardinality": { + "status": "has-crypto-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-createcredentials-imports": [ + { + "cardinality": { + "shape": "namespace", + "source": "require" + }, + "count": 1 + } + ], + "crypto-createcredentials-usages": [ + { + "cardinality": { + "count": "1", + "kind": "member-expression" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/createCredentials-to-createSecureContext/tests/file-2-module/metrics.json b/recipes/createCredentials-to-createSecureContext/tests/file-2-module/metrics.json new file mode 100644 index 00000000..72d71eaf --- /dev/null +++ b/recipes/createCredentials-to-createSecureContext/tests/file-2-module/metrics.json @@ -0,0 +1,34 @@ +{ + "crypto-createcredentials-files": [ + { + "cardinality": { + "status": "has-crypto-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-createcredentials-imports": [ + { + "cardinality": { + "shape": "namespace", + "source": "static-import" + }, + "count": 1 + } + ], + "crypto-createcredentials-usages": [ + { + "cardinality": { + "count": "1", + "kind": "member-expression" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/createCredentials-to-createSecureContext/tests/file-3-common/metrics.json b/recipes/createCredentials-to-createSecureContext/tests/file-3-common/metrics.json new file mode 100644 index 00000000..5dafec23 --- /dev/null +++ b/recipes/createCredentials-to-createSecureContext/tests/file-3-common/metrics.json @@ -0,0 +1,34 @@ +{ + "crypto-createcredentials-files": [ + { + "cardinality": { + "status": "has-crypto-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-createcredentials-imports": [ + { + "cardinality": { + "shape": "named", + "source": "require" + }, + "count": 1 + } + ], + "crypto-createcredentials-usages": [ + { + "cardinality": { + "count": "1", + "kind": "identifier" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/createCredentials-to-createSecureContext/tests/file-3-module/metrics.json b/recipes/createCredentials-to-createSecureContext/tests/file-3-module/metrics.json new file mode 100644 index 00000000..9427a852 --- /dev/null +++ b/recipes/createCredentials-to-createSecureContext/tests/file-3-module/metrics.json @@ -0,0 +1,34 @@ +{ + "crypto-createcredentials-files": [ + { + "cardinality": { + "status": "has-crypto-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-createcredentials-imports": [ + { + "cardinality": { + "shape": "named", + "source": "static-import" + }, + "count": 1 + } + ], + "crypto-createcredentials-usages": [ + { + "cardinality": { + "count": "1", + "kind": "identifier" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/createCredentials-to-createSecureContext/tests/file-3-pair-common/metrics.json b/recipes/createCredentials-to-createSecureContext/tests/file-3-pair-common/metrics.json new file mode 100644 index 00000000..cc3fe15d --- /dev/null +++ b/recipes/createCredentials-to-createSecureContext/tests/file-3-pair-common/metrics.json @@ -0,0 +1,25 @@ +{ + "crypto-createcredentials-files": [ + { + "cardinality": { + "status": "has-crypto-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-createcredentials-imports": [ + { + "cardinality": { + "shape": "named-aliased", + "source": "require" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/createCredentials-to-createSecureContext/tests/file-3-pair-module/metrics.json b/recipes/createCredentials-to-createSecureContext/tests/file-3-pair-module/metrics.json new file mode 100644 index 00000000..0c3357da --- /dev/null +++ b/recipes/createCredentials-to-createSecureContext/tests/file-3-pair-module/metrics.json @@ -0,0 +1,25 @@ +{ + "crypto-createcredentials-files": [ + { + "cardinality": { + "status": "has-crypto-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-createcredentials-imports": [ + { + "cardinality": { + "shape": "named-aliased", + "source": "static-import" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/createCredentials-to-createSecureContext/tests/file-5-common/metrics.json b/recipes/createCredentials-to-createSecureContext/tests/file-5-common/metrics.json new file mode 100644 index 00000000..4db041b5 --- /dev/null +++ b/recipes/createCredentials-to-createSecureContext/tests/file-5-common/metrics.json @@ -0,0 +1,34 @@ +{ + "crypto-createcredentials-files": [ + { + "cardinality": { + "status": "has-crypto-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-createcredentials-imports": [ + { + "cardinality": { + "shape": "namespace", + "source": "require" + }, + "count": 1 + } + ], + "crypto-createcredentials-usages": [ + { + "cardinality": { + "count": "1", + "kind": "member-expression" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/createCredentials-to-createSecureContext/tests/file-5-module/metrics.json b/recipes/createCredentials-to-createSecureContext/tests/file-5-module/metrics.json new file mode 100644 index 00000000..72d71eaf --- /dev/null +++ b/recipes/createCredentials-to-createSecureContext/tests/file-5-module/metrics.json @@ -0,0 +1,34 @@ +{ + "crypto-createcredentials-files": [ + { + "cardinality": { + "status": "has-crypto-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-createcredentials-imports": [ + { + "cardinality": { + "shape": "namespace", + "source": "static-import" + }, + "count": 1 + } + ], + "crypto-createcredentials-usages": [ + { + "cardinality": { + "count": "1", + "kind": "member-expression" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/createCredentials-to-createSecureContext/tests/file-6-module/metrics.json b/recipes/createCredentials-to-createSecureContext/tests/file-6-module/metrics.json new file mode 100644 index 00000000..c1703428 --- /dev/null +++ b/recipes/createCredentials-to-createSecureContext/tests/file-6-module/metrics.json @@ -0,0 +1,34 @@ +{ + "crypto-createcredentials-files": [ + { + "cardinality": { + "status": "has-crypto-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-createcredentials-imports": [ + { + "cardinality": { + "shape": "named", + "source": "dynamic-import" + }, + "count": 1 + } + ], + "crypto-createcredentials-usages": [ + { + "cardinality": { + "count": "1", + "kind": "identifier" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/createCredentials-to-createSecureContext/tests/file-6-pair-module/metrics.json b/recipes/createCredentials-to-createSecureContext/tests/file-6-pair-module/metrics.json new file mode 100644 index 00000000..9d8637c3 --- /dev/null +++ b/recipes/createCredentials-to-createSecureContext/tests/file-6-pair-module/metrics.json @@ -0,0 +1,25 @@ +{ + "crypto-createcredentials-files": [ + { + "cardinality": { + "status": "has-crypto-import" + }, + "count": 1 + }, + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-createcredentials-imports": [ + { + "cardinality": { + "shape": "named-aliased", + "source": "dynamic-import" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-createcipheriv-migration/src/workflow.ts b/recipes/crypto-createcipheriv-migration/src/workflow.ts index 45491d24..e3592679 100644 --- a/recipes/crypto-createcipheriv-migration/src/workflow.ts +++ b/recipes/crypto-createcipheriv-migration/src/workflow.ts @@ -1,9 +1,10 @@ import { EOL } from 'node:os'; +import { useMetricAtom } from 'codemod:metrics'; +import type { Edit, SgNode, SgRoot } from 'codemod:ast-grep'; +import type Js from 'codemod:ast-grep/langs/javascript'; import dedent from 'dedent'; import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; -import type { Edit, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; -import type Js from '@codemod.com/jssg-types/langs/javascript'; type CallKind = 'cipher' | 'decipher'; @@ -16,6 +17,10 @@ type CollectParams = { seenCallIds: Set; }; +const callMetric = useMetricAtom('crypto-createcipher-calls'); +const importMetric = useMetricAtom('crypto-createcipher-imports'); +const filesMetric = useMetricAtom('crypto-createcipher-files'); + /** * Transform deprecated crypto.createCipher()/createDecipher() usage to the * supported crypto.createCipheriv()/createDecipheriv() APIs. @@ -51,7 +56,12 @@ export default function transform(root: SgRoot): string | null { }); } - if (!edits.length) return null; + if (!edits.length) { + filesMetric.increment({ status: 'no-changes' }); + return null; + } + + filesMetric.increment({ status: 'migrated' }); return rootNode.commitEdits(edits); } @@ -103,6 +113,7 @@ function collectCallEdits({ kind, ); edits.push(call.replace(replacement)); + callMetric.increment({ kind, hasOptions: Boolean(optionsText).toString() }); // Update the corresponding import/require binding if present. // Rename `createCipher`/`createDecipher` -> `createCipheriv`/`createDecipheriv` @@ -120,6 +131,7 @@ function collectCallEdits({ sourceName, targetName, additions, + kind, ); if (explicit) { @@ -203,6 +215,7 @@ function updateDestructuredStatement( oldName: string, targetName: string, additions: string[], + kind: CallKind, ): Edit | undefined { const namedImports = statement.find({ rule: { kind: 'named_imports' } }); if (namedImports) { @@ -222,6 +235,9 @@ function updateDestructuredStatement( for (const a of additions) { if (!existingNames.has(a)) entries.push(a); } + + importMetric.increment({ kind, shape: 'named-imports', esm: isEsm.toString() }); + return namedImports.replace(`{ ${entries.join(', ')} }`); } @@ -261,10 +277,10 @@ function updateDestructuredStatement( if (!existingNames.has(a)) entries.push(a); } + importMetric.increment({ kind, shape: 'object-pattern' }); + return objectPattern.replace(`{ ${entries.join(', ')} }`); } return undefined; } - - diff --git a/recipes/crypto-createcipheriv-migration/tests/commonjs-alias/metrics.json b/recipes/crypto-createcipheriv-migration/tests/commonjs-alias/metrics.json new file mode 100644 index 00000000..a1b6b11c --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/commonjs-alias/metrics.json @@ -0,0 +1,28 @@ +{ + "crypto-createcipher-calls": [ + { + "cardinality": { + "hasOptions": "false", + "kind": "cipher" + }, + "count": 1 + } + ], + "crypto-createcipher-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-createcipher-imports": [ + { + "cardinality": { + "kind": "cipher", + "shape": "object-pattern" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-createcipheriv-migration/tests/commonjs-decipher-destructured/metrics.json b/recipes/crypto-createcipheriv-migration/tests/commonjs-decipher-destructured/metrics.json new file mode 100644 index 00000000..90611110 --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/commonjs-decipher-destructured/metrics.json @@ -0,0 +1,28 @@ +{ + "crypto-createcipher-calls": [ + { + "cardinality": { + "hasOptions": "false", + "kind": "decipher" + }, + "count": 1 + } + ], + "crypto-createcipher-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-createcipher-imports": [ + { + "cardinality": { + "kind": "decipher", + "shape": "object-pattern" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-createcipheriv-migration/tests/commonjs-decipher-namespace/metrics.json b/recipes/crypto-createcipheriv-migration/tests/commonjs-decipher-namespace/metrics.json new file mode 100644 index 00000000..d4fbdcdb --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/commonjs-decipher-namespace/metrics.json @@ -0,0 +1,19 @@ +{ + "crypto-createcipher-calls": [ + { + "cardinality": { + "hasOptions": "false", + "kind": "decipher" + }, + "count": 1 + } + ], + "crypto-createcipher-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-createcipheriv-migration/tests/commonjs-destructured/metrics.json b/recipes/crypto-createcipheriv-migration/tests/commonjs-destructured/metrics.json new file mode 100644 index 00000000..a1b6b11c --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/commonjs-destructured/metrics.json @@ -0,0 +1,28 @@ +{ + "crypto-createcipher-calls": [ + { + "cardinality": { + "hasOptions": "false", + "kind": "cipher" + }, + "count": 1 + } + ], + "crypto-createcipher-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-createcipher-imports": [ + { + "cardinality": { + "kind": "cipher", + "shape": "object-pattern" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-createcipheriv-migration/tests/commonjs-namespace/metrics.json b/recipes/crypto-createcipheriv-migration/tests/commonjs-namespace/metrics.json new file mode 100644 index 00000000..7ddaeb1d --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/commonjs-namespace/metrics.json @@ -0,0 +1,19 @@ +{ + "crypto-createcipher-calls": [ + { + "cardinality": { + "hasOptions": "false", + "kind": "cipher" + }, + "count": 1 + } + ], + "crypto-createcipher-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-createcipheriv-migration/tests/commonjs-options/metrics.json b/recipes/crypto-createcipheriv-migration/tests/commonjs-options/metrics.json new file mode 100644 index 00000000..2d38dd93 --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/commonjs-options/metrics.json @@ -0,0 +1,19 @@ +{ + "crypto-createcipher-calls": [ + { + "cardinality": { + "hasOptions": "true", + "kind": "cipher" + }, + "count": 1 + } + ], + "crypto-createcipher-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-createcipheriv-migration/tests/esm-named-decipher/metrics.json b/recipes/crypto-createcipheriv-migration/tests/esm-named-decipher/metrics.json new file mode 100644 index 00000000..bbec51ef --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/esm-named-decipher/metrics.json @@ -0,0 +1,29 @@ +{ + "crypto-createcipher-calls": [ + { + "cardinality": { + "hasOptions": "false", + "kind": "decipher" + }, + "count": 1 + } + ], + "crypto-createcipher-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-createcipher-imports": [ + { + "cardinality": { + "esm": "true", + "kind": "decipher", + "shape": "named-imports" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-createcipheriv-migration/tests/esm-namespace/metrics.json b/recipes/crypto-createcipheriv-migration/tests/esm-namespace/metrics.json new file mode 100644 index 00000000..7ddaeb1d --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/esm-namespace/metrics.json @@ -0,0 +1,19 @@ +{ + "crypto-createcipher-calls": [ + { + "cardinality": { + "hasOptions": "false", + "kind": "cipher" + }, + "count": 1 + } + ], + "crypto-createcipher-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-fips-to-getFips/src/workflow.ts b/recipes/crypto-fips-to-getFips/src/workflow.ts index 27feb4b5..d47bcab6 100644 --- a/recipes/crypto-fips-to-getFips/src/workflow.ts +++ b/recipes/crypto-fips-to-getFips/src/workflow.ts @@ -1,8 +1,9 @@ +import { useMetricAtom } from 'codemod:metrics'; +import type { Codemod, SgRoot, SgNode, Edit, Range } from 'codemod:ast-grep'; +import type Js from 'codemod:ast-grep/langs/javascript'; import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; import { updateBinding } from '@nodejs/codemod-utils/ast-grep/update-binding'; import { removeLines } from '@nodejs/codemod-utils/ast-grep/remove-lines'; -import type { SgRoot, SgNode, Edit, Range } from '@codemod.com/jssg-types/main'; -import type Js from '@codemod.com/jssg-types/langs/javascript'; import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; type Binding = { @@ -11,53 +12,9 @@ type Binding = { node: SgNode; }; -/** - * Transform function that converts deprecated crypto.fips calls - * to the new crypto.getFips() and crypto.setFips() syntax. - * - * Handles: - * 1. crypto.fips → crypto.getFips() - * 2. crypto.fips = value → crypto.setFips(value) - * 3. const { fips } = require("crypto") → const { getFips, setFips } = require("crypto") - * 4. import { fips } from "crypto" → import { getFips, setFips } from "crypto") - * 5. const { fips } = await import("crypto") → const { getFips, setFips } = await import("crypto") - * 6. Aliased imports: { fips: alias } → { getFips, setFips } - */ -export default function transform(root: SgRoot): string | null { - const rootNode = root.root(); - const edits: Edit[] = []; - const linesToRemove: Range[] = []; - - const bindings = collectCryptoFipsBindings(root); - - if (bindings.length === 0) return null; - - for (const binding of bindings) { - if (binding.type === 'namespace') { - edits.push(...transformNamespaceUsage(rootNode, binding.binding)); - } else { - edits.push(...transformDestructuredUsage(rootNode, binding.binding)); - - const result = updateBinding(binding.node, { - old: binding.binding, - new: ['getFips', 'setFips'], - }); - if (result?.edit) { - edits.push(result.edit); - } - if (result?.lineToRemove) { - linesToRemove.push(result.lineToRemove); - } - } - } - - if (edits.length === 0 && linesToRemove.length === 0) return null; - - const sourceCode = rootNode.commitEdits(edits); - return linesToRemove.length > 0 - ? removeLines(sourceCode, linesToRemove) - : sourceCode; -} +const bindingMetric = useMetricAtom('crypto-fips-bindings'); +const usageMetric = useMetricAtom('crypto-fips-usages'); +const filesMetric = useMetricAtom('crypto-fips-files'); /** * Collect all crypto.fips bindings from the file @@ -111,6 +68,7 @@ function transformNamespaceUsage(rootNode: SgNode, base: string): Edit[] { `${base}.getFips()`, ); edits.push(assignment.replace(`${base}.setFips(${value})`)); + usageMetric.increment({ style: 'namespace', kind: 'write' }); } } @@ -131,6 +89,7 @@ function transformNamespaceUsage(rootNode: SgNode, base: string): Edit[] { for (const read of reads) { edits.push(read.replace(`${base}.getFips()`)); + usageMetric.increment({ style: 'namespace', kind: 'read' }); } return edits; @@ -157,6 +116,7 @@ function transformDestructuredUsage( let value = valueNode.text(); value = value.replace(new RegExp(`\\b${binding}\\b`, 'g'), 'getFips()'); edits.push(assignment.replace(`setFips(${value})`)); + usageMetric.increment({ style: 'destructured', kind: 'write' }); } } @@ -187,7 +147,65 @@ function transformDestructuredUsage( for (const read of reads) { edits.push(read.replace('getFips()')); + usageMetric.increment({ style: 'destructured', kind: 'read' }); } return edits; } + +/** + * Transform function that converts deprecated crypto.fips calls + * to the new crypto.getFips() and crypto.setFips() syntax. + * + * Handles: + * 1. crypto.fips → crypto.getFips() + * 2. crypto.fips = value → crypto.setFips(value) + * 3. const { fips } = require("crypto") → const { getFips, setFips } = require("crypto") + * 4. import { fips } from "crypto" → import { getFips, setFips } from "crypto") + * 5. const { fips } = await import("crypto") → const { getFips, setFips } = await import("crypto") + * 6. Aliased imports: { fips: alias } → { getFips, setFips } + */ +const transform: Codemod = async (root: SgRoot): Promise => { + const rootNode = root.root(); + const edits: Edit[] = []; + const linesToRemove: Range[] = []; + + const bindings = collectCryptoFipsBindings(root); + + if (bindings.length === 0) return null; + + for (const binding of bindings) { + bindingMetric.increment({ type: binding.type }); + + if (binding.type === 'namespace') { + edits.push(...transformNamespaceUsage(rootNode, binding.binding)); + } else { + edits.push(...transformDestructuredUsage(rootNode, binding.binding)); + + const result = updateBinding(binding.node, { + old: binding.binding, + new: ['getFips', 'setFips'], + }); + if (result?.edit) { + edits.push(result.edit); + } + if (result?.lineToRemove) { + linesToRemove.push(result.lineToRemove); + } + } + } + + if (edits.length === 0 && linesToRemove.length === 0) { + filesMetric.increment({ status: 'no-changes' }); + return null; + } + + filesMetric.increment({ status: 'migrated' }); + + const sourceCode = rootNode.commitEdits(edits); + return linesToRemove.length > 0 + ? removeLines(sourceCode, linesToRemove) + : sourceCode; +} + +export default transform; diff --git a/recipes/crypto-fips-to-getFips/tests/file-1/metrics.json b/recipes/crypto-fips-to-getFips/tests/file-1/metrics.json new file mode 100644 index 00000000..de9bd219 --- /dev/null +++ b/recipes/crypto-fips-to-getFips/tests/file-1/metrics.json @@ -0,0 +1,27 @@ +{ + "crypto-fips-bindings": [ + { + "cardinality": { + "type": "namespace" + }, + "count": 1 + } + ], + "crypto-fips-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-fips-usages": [ + { + "cardinality": { + "kind": "read", + "style": "namespace" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-fips-to-getFips/tests/file-10/metrics.json b/recipes/crypto-fips-to-getFips/tests/file-10/metrics.json new file mode 100644 index 00000000..49239c18 --- /dev/null +++ b/recipes/crypto-fips-to-getFips/tests/file-10/metrics.json @@ -0,0 +1,34 @@ +{ + "crypto-fips-bindings": [ + { + "cardinality": { + "type": "destructured" + }, + "count": 1 + } + ], + "crypto-fips-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-fips-usages": [ + { + "cardinality": { + "kind": "read", + "style": "destructured" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "write", + "style": "destructured" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-fips-to-getFips/tests/file-11/metrics.json b/recipes/crypto-fips-to-getFips/tests/file-11/metrics.json new file mode 100644 index 00000000..49239c18 --- /dev/null +++ b/recipes/crypto-fips-to-getFips/tests/file-11/metrics.json @@ -0,0 +1,34 @@ +{ + "crypto-fips-bindings": [ + { + "cardinality": { + "type": "destructured" + }, + "count": 1 + } + ], + "crypto-fips-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-fips-usages": [ + { + "cardinality": { + "kind": "read", + "style": "destructured" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "write", + "style": "destructured" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-fips-to-getFips/tests/file-2/metrics.json b/recipes/crypto-fips-to-getFips/tests/file-2/metrics.json new file mode 100644 index 00000000..3632c66e --- /dev/null +++ b/recipes/crypto-fips-to-getFips/tests/file-2/metrics.json @@ -0,0 +1,27 @@ +{ + "crypto-fips-bindings": [ + { + "cardinality": { + "type": "namespace" + }, + "count": 1 + } + ], + "crypto-fips-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-fips-usages": [ + { + "cardinality": { + "kind": "write", + "style": "namespace" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-fips-to-getFips/tests/file-3/metrics.json b/recipes/crypto-fips-to-getFips/tests/file-3/metrics.json new file mode 100644 index 00000000..7f47ef72 --- /dev/null +++ b/recipes/crypto-fips-to-getFips/tests/file-3/metrics.json @@ -0,0 +1,34 @@ +{ + "crypto-fips-bindings": [ + { + "cardinality": { + "type": "namespace" + }, + "count": 1 + } + ], + "crypto-fips-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-fips-usages": [ + { + "cardinality": { + "kind": "read", + "style": "namespace" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "write", + "style": "namespace" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-fips-to-getFips/tests/file-4/metrics.json b/recipes/crypto-fips-to-getFips/tests/file-4/metrics.json new file mode 100644 index 00000000..7f47ef72 --- /dev/null +++ b/recipes/crypto-fips-to-getFips/tests/file-4/metrics.json @@ -0,0 +1,34 @@ +{ + "crypto-fips-bindings": [ + { + "cardinality": { + "type": "namespace" + }, + "count": 1 + } + ], + "crypto-fips-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-fips-usages": [ + { + "cardinality": { + "kind": "read", + "style": "namespace" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "write", + "style": "namespace" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-fips-to-getFips/tests/file-5/metrics.json b/recipes/crypto-fips-to-getFips/tests/file-5/metrics.json new file mode 100644 index 00000000..7f47ef72 --- /dev/null +++ b/recipes/crypto-fips-to-getFips/tests/file-5/metrics.json @@ -0,0 +1,34 @@ +{ + "crypto-fips-bindings": [ + { + "cardinality": { + "type": "namespace" + }, + "count": 1 + } + ], + "crypto-fips-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-fips-usages": [ + { + "cardinality": { + "kind": "read", + "style": "namespace" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "write", + "style": "namespace" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-fips-to-getFips/tests/file-6/metrics.json b/recipes/crypto-fips-to-getFips/tests/file-6/metrics.json new file mode 100644 index 00000000..2902b030 --- /dev/null +++ b/recipes/crypto-fips-to-getFips/tests/file-6/metrics.json @@ -0,0 +1,34 @@ +{ + "crypto-fips-bindings": [ + { + "cardinality": { + "type": "namespace" + }, + "count": 1 + } + ], + "crypto-fips-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-fips-usages": [ + { + "cardinality": { + "kind": "read", + "style": "namespace" + }, + "count": 2 + }, + { + "cardinality": { + "kind": "write", + "style": "namespace" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-fips-to-getFips/tests/file-7/metrics.json b/recipes/crypto-fips-to-getFips/tests/file-7/metrics.json new file mode 100644 index 00000000..49239c18 --- /dev/null +++ b/recipes/crypto-fips-to-getFips/tests/file-7/metrics.json @@ -0,0 +1,34 @@ +{ + "crypto-fips-bindings": [ + { + "cardinality": { + "type": "destructured" + }, + "count": 1 + } + ], + "crypto-fips-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-fips-usages": [ + { + "cardinality": { + "kind": "read", + "style": "destructured" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "write", + "style": "destructured" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-fips-to-getFips/tests/file-8/metrics.json b/recipes/crypto-fips-to-getFips/tests/file-8/metrics.json new file mode 100644 index 00000000..49239c18 --- /dev/null +++ b/recipes/crypto-fips-to-getFips/tests/file-8/metrics.json @@ -0,0 +1,34 @@ +{ + "crypto-fips-bindings": [ + { + "cardinality": { + "type": "destructured" + }, + "count": 1 + } + ], + "crypto-fips-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-fips-usages": [ + { + "cardinality": { + "kind": "read", + "style": "destructured" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "write", + "style": "destructured" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-fips-to-getFips/tests/file-9/metrics.json b/recipes/crypto-fips-to-getFips/tests/file-9/metrics.json new file mode 100644 index 00000000..49239c18 --- /dev/null +++ b/recipes/crypto-fips-to-getFips/tests/file-9/metrics.json @@ -0,0 +1,34 @@ +{ + "crypto-fips-bindings": [ + { + "cardinality": { + "type": "destructured" + }, + "count": 1 + } + ], + "crypto-fips-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-fips-usages": [ + { + "cardinality": { + "kind": "read", + "style": "destructured" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "write", + "style": "destructured" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-rsa-pss-update/src/workflow.ts b/recipes/crypto-rsa-pss-update/src/workflow.ts index cea2c820..f48ea031 100644 --- a/recipes/crypto-rsa-pss-update/src/workflow.ts +++ b/recipes/crypto-rsa-pss-update/src/workflow.ts @@ -1,5 +1,6 @@ -import type { SgRoot, SgNode, Edit } from '@codemod.com/jssg-types/main'; -import type JS from '@codemod.com/jssg-types/langs/javascript'; +import { useMetricAtom } from 'codemod:metrics'; +import type { Codemod, SgRoot, SgNode, Edit } from 'codemod:ast-grep'; +import type JS from 'codemod:ast-grep/langs/javascript'; import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; @@ -10,21 +11,8 @@ const HASH_MAPPINGS = { mgf1Hash: 'mgf1HashAlgorithm', } as const; -export default function transform(root: SgRoot): string | null { - const rootNode = root.root(); - const cryptoBindings = getCryptoBindings(root); - const allCalls = findCryptoCalls(rootNode, cryptoBindings); - - const allEdits = [ - ...transformRsaPssCalls(rootNode, allCalls), - ...transformSpreadObjectDeclarations(rootNode, allCalls), - ...processThisPropertyReferences(rootNode, allCalls), - ...transformPropertyAssignments(rootNode), - ...transformVariableHashStrings(rootNode), - ]; - - return allEdits.length ? rootNode.commitEdits(allEdits) : null; -} +const propertyMetric = useMetricAtom('crypto-rsa-pss-properties'); +const filesMetric = useMetricAtom('crypto-rsa-pss-files'); function transformRsaPssCalls( rootNode: SgNode, @@ -50,7 +38,9 @@ function transformRsaPssCalls( }); if (directObject) { - edits.push(...transformHashPropertiesInObject(directObject)); + edits.push( + ...transformHashPropertiesInObject(directObject, 'call-direct-object'), + ); } else { edits.push(...processOptionsReference(rootNode, optionsMatch)); } @@ -181,7 +171,10 @@ function isRsaPssType(rootNode: SgNode, typeText: string): boolean { ); } -function transformHashPropertiesInObject(objectNode: SgNode): Edit[] { +function transformHashPropertiesInObject( + objectNode: SgNode, + site: string, +): Edit[] { return objectNode .findAll({ rule: { kind: 'pair' } }) .map((pair) => { @@ -214,6 +207,8 @@ function transformHashPropertiesInObject(objectNode: SgNode): Edit[] { const value = getText(valueNode); if (!value) return null; + propertyMetric.increment({ site, key }); + return pair.replace(`${HASH_MAPPINGS[key]}: ${value}`); }) .filter(Boolean); @@ -227,18 +222,24 @@ function processOptionsReference( if (!optionsText) return []; if (IDENTIFIER_REGEX.test(optionsText)) { - return findAndTransformObjects(rootNode, [ - `const ${optionsText} = { $$$PROPS }`, - ]); + return findAndTransformObjects( + rootNode, + [`const ${optionsText} = { $$$PROPS }`], + 'options-identifier', + ); } if (optionsMatch.find({ rule: { kind: 'call_expression' } })) { const functionName = optionsText.replace(/\(\).*$/, ''); - return findAndTransformObjects(rootNode, [ - `function ${functionName}() { return { $$$PROPS } }`, - `const ${functionName} = () => ({ $$$PROPS })`, - `const ${functionName} = function() { return { $$$PROPS } }`, - ]); + return findAndTransformObjects( + rootNode, + [ + `function ${functionName}() { return { $$$PROPS } }`, + `const ${functionName} = () => ({ $$$PROPS })`, + `const ${functionName} = function() { return { $$$PROPS } }`, + ], + 'options-function', + ); } return []; @@ -255,11 +256,12 @@ function uniqueArray(items: T[]): T[] { function findAndTransformObjects( rootNode: SgNode, patterns: string[], + site: string, ): Edit[] { return patterns.flatMap((pattern) => rootNode .findAll({ rule: { pattern } }) - .flatMap((decl) => transformHashPropertiesInObject(decl)), + .flatMap((decl) => transformHashPropertiesInObject(decl, site)), ); } @@ -279,7 +281,7 @@ function transformSpreadObjectDeclarations( const patterns = spreadNames.map( (spreadName) => `const ${spreadName} = { $$$PROPS }`, ); - return findAndTransformObjects(rootNode, patterns); + return findAndTransformObjects(rootNode, patterns, 'spread-declaration'); } function processThisPropertyReferences( @@ -296,7 +298,7 @@ function processThisPropertyReferences( const patterns = propertyNames.map( (propName) => `this.${propName} = { $$$PROPS }`, ); - return findAndTransformObjects(rootNode, patterns); + return findAndTransformObjects(rootNode, patterns, 'this-property'); } function transformAssignmentPattern( @@ -320,6 +322,10 @@ function transformAssignmentPattern( const valueText = getText(valueMatch); if (objectText && valueText) { + propertyMetric.increment({ + site: 'direct-assignment', + key: oldProperty, + }); return assignment.replace( `${objectText}.${newProperty} = ${valueText}`, ); @@ -361,6 +367,34 @@ function findAndTransformVariableDeclarations( ], }, }) - .map((decl) => decl.replace(decl.text().replace(from, to))), + .map((decl) => { + propertyMetric.increment({ site: 'variable-string', key: from }); + return decl.replace(decl.text().replace(from, to)); + }), ); } + +const transform: Codemod = async (root) => { + const rootNode = root.root(); + const cryptoBindings = getCryptoBindings(root); + const allCalls = findCryptoCalls(rootNode, cryptoBindings); + + const allEdits = [ + ...transformRsaPssCalls(rootNode, allCalls), + ...transformSpreadObjectDeclarations(rootNode, allCalls), + ...processThisPropertyReferences(rootNode, allCalls), + ...transformPropertyAssignments(rootNode), + ...transformVariableHashStrings(rootNode), + ]; + + if (!allEdits.length) { + filesMetric.increment({ status: 'no-changes' }); + return null; + } + + filesMetric.increment({ status: 'migrated' }); + + return rootNode.commitEdits(allEdits); +} + +export default transform; diff --git a/recipes/crypto-rsa-pss-update/tests/basic/metrics.json b/recipes/crypto-rsa-pss-update/tests/basic/metrics.json new file mode 100644 index 00000000..ed313373 --- /dev/null +++ b/recipes/crypto-rsa-pss-update/tests/basic/metrics.json @@ -0,0 +1,26 @@ +{ + "crypto-rsa-pss-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-rsa-pss-properties": [ + { + "cardinality": { + "key": "hash", + "site": "call-direct-object" + }, + "count": 2 + }, + { + "cardinality": { + "key": "mgf1Hash", + "site": "call-direct-object" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-rsa-pss-update/tests/complex-ast-patterns/metrics.json b/recipes/crypto-rsa-pss-update/tests/complex-ast-patterns/metrics.json new file mode 100644 index 00000000..b5b98777 --- /dev/null +++ b/recipes/crypto-rsa-pss-update/tests/complex-ast-patterns/metrics.json @@ -0,0 +1,26 @@ +{ + "crypto-rsa-pss-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-rsa-pss-properties": [ + { + "cardinality": { + "key": "hash", + "site": "call-direct-object" + }, + "count": 5 + }, + { + "cardinality": { + "key": "mgf1Hash", + "site": "call-direct-object" + }, + "count": 3 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-rsa-pss-update/tests/computed-properties/metrics.json b/recipes/crypto-rsa-pss-update/tests/computed-properties/metrics.json new file mode 100644 index 00000000..7ab376e4 --- /dev/null +++ b/recipes/crypto-rsa-pss-update/tests/computed-properties/metrics.json @@ -0,0 +1,47 @@ +{ + "crypto-rsa-pss-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-rsa-pss-properties": [ + { + "cardinality": { + "key": "'hash'", + "site": "variable-string" + }, + "count": 2 + }, + { + "cardinality": { + "key": "'mgf1' + 'Hash'", + "site": "variable-string" + }, + "count": 1 + }, + { + "cardinality": { + "key": "'mgf1Hash'", + "site": "variable-string" + }, + "count": 1 + }, + { + "cardinality": { + "key": "hash", + "site": "options-identifier" + }, + "count": 1 + }, + { + "cardinality": { + "key": "mgf1Hash", + "site": "options-identifier" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-rsa-pss-update/tests/destructured/metrics.json b/recipes/crypto-rsa-pss-update/tests/destructured/metrics.json new file mode 100644 index 00000000..e7b6b486 --- /dev/null +++ b/recipes/crypto-rsa-pss-update/tests/destructured/metrics.json @@ -0,0 +1,40 @@ +{ + "crypto-rsa-pss-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-rsa-pss-properties": [ + { + "cardinality": { + "key": "hash", + "site": "call-direct-object" + }, + "count": 5 + }, + { + "cardinality": { + "key": "hash", + "site": "options-identifier" + }, + "count": 1 + }, + { + "cardinality": { + "key": "mgf1Hash", + "site": "call-direct-object" + }, + "count": 3 + }, + { + "cardinality": { + "key": "mgf1Hash", + "site": "options-identifier" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-rsa-pss-update/tests/dynamic-options/metrics.json b/recipes/crypto-rsa-pss-update/tests/dynamic-options/metrics.json new file mode 100644 index 00000000..62a91987 --- /dev/null +++ b/recipes/crypto-rsa-pss-update/tests/dynamic-options/metrics.json @@ -0,0 +1,26 @@ +{ + "crypto-rsa-pss-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-rsa-pss-properties": [ + { + "cardinality": { + "key": "hash", + "site": "options-identifier" + }, + "count": 1 + }, + { + "cardinality": { + "key": "mgf1Hash", + "site": "options-function" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-rsa-pss-update/tests/method-chaining/metrics.json b/recipes/crypto-rsa-pss-update/tests/method-chaining/metrics.json new file mode 100644 index 00000000..9705fec0 --- /dev/null +++ b/recipes/crypto-rsa-pss-update/tests/method-chaining/metrics.json @@ -0,0 +1,40 @@ +{ + "crypto-rsa-pss-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-rsa-pss-properties": [ + { + "cardinality": { + "key": "hash", + "site": "call-direct-object" + }, + "count": 3 + }, + { + "cardinality": { + "key": "hash", + "site": "direct-assignment" + }, + "count": 1 + }, + { + "cardinality": { + "key": "mgf1Hash", + "site": "call-direct-object" + }, + "count": 3 + }, + { + "cardinality": { + "key": "mgf1Hash", + "site": "direct-assignment" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-rsa-pss-update/tests/namespace-import-module/metrics.json b/recipes/crypto-rsa-pss-update/tests/namespace-import-module/metrics.json new file mode 100644 index 00000000..aab86a0d --- /dev/null +++ b/recipes/crypto-rsa-pss-update/tests/namespace-import-module/metrics.json @@ -0,0 +1,26 @@ +{ + "crypto-rsa-pss-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-rsa-pss-properties": [ + { + "cardinality": { + "key": "hash", + "site": "call-direct-object" + }, + "count": 1 + }, + { + "cardinality": { + "key": "mgf1Hash", + "site": "call-direct-object" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-rsa-pss-update/tests/nested-objects/metrics.json b/recipes/crypto-rsa-pss-update/tests/nested-objects/metrics.json new file mode 100644 index 00000000..868d8da9 --- /dev/null +++ b/recipes/crypto-rsa-pss-update/tests/nested-objects/metrics.json @@ -0,0 +1,26 @@ +{ + "crypto-rsa-pss-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-rsa-pss-properties": [ + { + "cardinality": { + "key": "hash", + "site": "call-direct-object" + }, + "count": 3 + }, + { + "cardinality": { + "key": "mgf1Hash", + "site": "call-direct-object" + }, + "count": 3 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-rsa-pss-update/tests/non-rsa-pss/metrics.json b/recipes/crypto-rsa-pss-update/tests/non-rsa-pss/metrics.json new file mode 100644 index 00000000..417e8869 --- /dev/null +++ b/recipes/crypto-rsa-pss-update/tests/non-rsa-pss/metrics.json @@ -0,0 +1,19 @@ +{ + "crypto-rsa-pss-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-rsa-pss-properties": [ + { + "cardinality": { + "key": "hash", + "site": "call-direct-object" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-rsa-pss-update/tests/promisified-wrappers/metrics.json b/recipes/crypto-rsa-pss-update/tests/promisified-wrappers/metrics.json new file mode 100644 index 00000000..eb49e060 --- /dev/null +++ b/recipes/crypto-rsa-pss-update/tests/promisified-wrappers/metrics.json @@ -0,0 +1,26 @@ +{ + "crypto-rsa-pss-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-rsa-pss-properties": [ + { + "cardinality": { + "key": "hash", + "site": "call-direct-object" + }, + "count": 5 + }, + { + "cardinality": { + "key": "mgf1Hash", + "site": "call-direct-object" + }, + "count": 5 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-rsa-pss-update/tests/spread-operators/metrics.json b/recipes/crypto-rsa-pss-update/tests/spread-operators/metrics.json new file mode 100644 index 00000000..d5b0ed0a --- /dev/null +++ b/recipes/crypto-rsa-pss-update/tests/spread-operators/metrics.json @@ -0,0 +1,33 @@ +{ + "crypto-rsa-pss-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-rsa-pss-properties": [ + { + "cardinality": { + "key": "hash", + "site": "call-direct-object" + }, + "count": 1 + }, + { + "cardinality": { + "key": "hash", + "site": "spread-declaration" + }, + "count": 1 + }, + { + "cardinality": { + "key": "mgf1Hash", + "site": "call-direct-object" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-rsa-pss-update/tests/template-literals/metrics.json b/recipes/crypto-rsa-pss-update/tests/template-literals/metrics.json new file mode 100644 index 00000000..aab86a0d --- /dev/null +++ b/recipes/crypto-rsa-pss-update/tests/template-literals/metrics.json @@ -0,0 +1,26 @@ +{ + "crypto-rsa-pss-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-rsa-pss-properties": [ + { + "cardinality": { + "key": "hash", + "site": "call-direct-object" + }, + "count": 1 + }, + { + "cardinality": { + "key": "mgf1Hash", + "site": "call-direct-object" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-rsa-pss-update/tests/ternary-operators/metrics.json b/recipes/crypto-rsa-pss-update/tests/ternary-operators/metrics.json new file mode 100644 index 00000000..a7ca4da8 --- /dev/null +++ b/recipes/crypto-rsa-pss-update/tests/ternary-operators/metrics.json @@ -0,0 +1,33 @@ +{ + "crypto-rsa-pss-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-rsa-pss-properties": [ + { + "cardinality": { + "key": "hash", + "site": "call-direct-object" + }, + "count": 1 + }, + { + "cardinality": { + "key": "hash", + "site": "options-identifier" + }, + "count": 2 + }, + { + "cardinality": { + "key": "mgf1Hash", + "site": "options-identifier" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-rsa-pss-update/tests/this-property/metrics.json b/recipes/crypto-rsa-pss-update/tests/this-property/metrics.json new file mode 100644 index 00000000..dd81d267 --- /dev/null +++ b/recipes/crypto-rsa-pss-update/tests/this-property/metrics.json @@ -0,0 +1,33 @@ +{ + "crypto-rsa-pss-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-rsa-pss-properties": [ + { + "cardinality": { + "key": "hash", + "site": "options-identifier" + }, + "count": 1 + }, + { + "cardinality": { + "key": "hash", + "site": "this-property" + }, + "count": 4 + }, + { + "cardinality": { + "key": "mgf1Hash", + "site": "this-property" + }, + "count": 3 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-rsa-pss-update/tests/variable-key-type/metrics.json b/recipes/crypto-rsa-pss-update/tests/variable-key-type/metrics.json new file mode 100644 index 00000000..aab86a0d --- /dev/null +++ b/recipes/crypto-rsa-pss-update/tests/variable-key-type/metrics.json @@ -0,0 +1,26 @@ +{ + "crypto-rsa-pss-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-rsa-pss-properties": [ + { + "cardinality": { + "key": "hash", + "site": "call-direct-object" + }, + "count": 1 + }, + { + "cardinality": { + "key": "mgf1Hash", + "site": "call-direct-object" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/crypto-rsa-pss-update/tests/variable-value/metrics.json b/recipes/crypto-rsa-pss-update/tests/variable-value/metrics.json new file mode 100644 index 00000000..0279fc1d --- /dev/null +++ b/recipes/crypto-rsa-pss-update/tests/variable-value/metrics.json @@ -0,0 +1,26 @@ +{ + "crypto-rsa-pss-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "crypto-rsa-pss-properties": [ + { + "cardinality": { + "key": "hash", + "site": "call-direct-object" + }, + "count": 1 + }, + { + "cardinality": { + "key": "mgf1Hash", + "site": "call-direct-object" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/recipes/dirent-path-to-parent-path/src/workflow.ts b/recipes/dirent-path-to-parent-path/src/workflow.ts index 0ec6cf07..70169251 100644 --- a/recipes/dirent-path-to-parent-path/src/workflow.ts +++ b/recipes/dirent-path-to-parent-path/src/workflow.ts @@ -1,8 +1,9 @@ +import { useMetricAtom } from 'codemod:metrics'; +import type { Codemod, Edit, Range, SgNode } from 'codemod:ast-grep'; +import type Js from 'codemod:ast-grep/langs/javascript'; import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; import { removeLines } from '@nodejs/codemod-utils/ast-grep/remove-lines'; import { getScope } from '@nodejs/codemod-utils/ast-grep/get-scope'; -import type { Edit, Range, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; -import type Js from '@codemod.com/jssg-types/langs/javascript'; import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; type BindingToReplace = { @@ -37,11 +38,13 @@ const handledFn = ['$.readdir', '$.readdirSync', '$.opendir']; const handledModules = ['fs', 'fs/promises']; -/* - * Transforms `dirent.path` usage to `dirent.parentPath`. - * - */ -export default function transform(root: SgRoot): string | null { +const bindingMetric = useMetricAtom('fs-dirent-path-bindings'); +const dirArrayMetric = useMetricAtom('fs-dirent-path-dir-arrays'); +const dirValueMetric = useMetricAtom('fs-dirent-path-dir-values'); +const rewriteMetric = useMetricAtom('fs-dirent-path-rewrites'); +const filesMetric = useMetricAtom('fs-dirent-path-files'); + +const transform: Codemod = async (root) => { const rootNode = root.root(); const edits: Edit[] = []; const linesToRemove: Range[] = []; @@ -63,6 +66,8 @@ export default function transform(root: SgRoot): string | null { if (!bind) continue; + bindingMetric.increment({ fn }); + bindsToReplace.push({ node, binding: bind, @@ -114,6 +119,7 @@ export default function transform(root: SgRoot): string | null { for (const match of matches) { dirArrays.push({ node: match, scope: getScope(match) }); + dirArrayMetric.increment({ source: 'variable-declarator' }); } const functionCalls = rootNode.findAll<'call_expression'>({ @@ -154,6 +160,7 @@ export default function transform(root: SgRoot): string | null { if (fnParams.length === 2) { dirArrays.push({ node: fnParams[1], scope: arrowFn.field('body') }); + dirArrayMetric.increment({ source: 'callback-param' }); } } } @@ -186,6 +193,7 @@ export default function transform(root: SgRoot): string | null { node: leftBind, scope: forOfBody, }); + dirValueMetric.increment({ source: 'for-in' }); } const forScenarios = dirArray.scope.findAll<'for_statement'>({ @@ -211,6 +219,7 @@ export default function transform(root: SgRoot): string | null { node: parent as SgNode, scope: forScenario, }); + dirValueMetric.increment({ source: 'for-subscript-member' }); } if (parent.parent().kind() === 'variable_declarator') { const dirVar = ( @@ -221,6 +230,7 @@ export default function transform(root: SgRoot): string | null { node: dirVar, scope: forScenario, }); + dirValueMetric.increment({ source: 'for-subscript-declarator' }); } } } @@ -266,6 +276,7 @@ export default function transform(root: SgRoot): string | null { node: param, scope: fnBody, }); + dirValueMetric.increment({ source: 'array-method-param' }); } const paramDestructured = parameters?.find<'object_pattern'>({ @@ -279,6 +290,7 @@ export default function transform(root: SgRoot): string | null { node: paramDestructured, scope: fnBody, }); + dirValueMetric.increment({ source: 'array-method-destructured' }); } } } @@ -294,6 +306,7 @@ export default function transform(root: SgRoot): string | null { for (const uses of pathUses) { edits.push(uses.field('property').replace('parentPath')); + rewriteMetric.increment({ style: 'member-expression' }); } } @@ -308,6 +321,7 @@ export default function transform(root: SgRoot): string | null { if (pathBind) { edits.push(pathBind.replace('parentPath')); + rewriteMetric.increment({ style: 'destructured-binding' }); const pathUses = dirDestructuredValue.scope.findAll<'member_expression'>({ rule: { @@ -318,13 +332,21 @@ export default function transform(root: SgRoot): string | null { for (const pahtUse of pathUses) { edits.push(pahtUse.replace('parentPath')); + rewriteMetric.increment({ style: 'destructured-usage' }); } } } - if (!edits.length) return; + if (!edits.length) { + filesMetric.increment({ status: 'no-changes' }); + return; + } + + filesMetric.increment({ status: 'migrated' }); const sourceCode = rootNode.commitEdits(edits); return removeLines(sourceCode, linesToRemove); } + +export default transform; diff --git a/recipes/dirent-path-to-parent-path/tests/01/metrics.json b/recipes/dirent-path-to-parent-path/tests/01/metrics.json new file mode 100644 index 00000000..5032c981 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/01/metrics.json @@ -0,0 +1,42 @@ +{ + "fs-dirent-path-bindings": [ + { + "cardinality": { + "fn": "$.readdir" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-arrays": [ + { + "cardinality": { + "source": "variable-declarator" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-values": [ + { + "cardinality": { + "source": "for-in" + }, + "count": 1 + } + ], + "fs-dirent-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-dirent-path-rewrites": [ + { + "cardinality": { + "style": "member-expression" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/dirent-path-to-parent-path/tests/02/metrics.json b/recipes/dirent-path-to-parent-path/tests/02/metrics.json new file mode 100644 index 00000000..eaa82cd2 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/02/metrics.json @@ -0,0 +1,42 @@ +{ + "fs-dirent-path-bindings": [ + { + "cardinality": { + "fn": "$.readdir" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-arrays": [ + { + "cardinality": { + "source": "variable-declarator" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-values": [ + { + "cardinality": { + "source": "array-method-param" + }, + "count": 1 + } + ], + "fs-dirent-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-dirent-path-rewrites": [ + { + "cardinality": { + "style": "member-expression" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/dirent-path-to-parent-path/tests/03/metrics.json b/recipes/dirent-path-to-parent-path/tests/03/metrics.json new file mode 100644 index 00000000..e73821ef --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/03/metrics.json @@ -0,0 +1,60 @@ +{ + "fs-dirent-path-bindings": [ + { + "cardinality": { + "fn": "$.opendir" + }, + "count": 1 + }, + { + "cardinality": { + "fn": "$.readdir" + }, + "count": 1 + }, + { + "cardinality": { + "fn": "$.readdirSync" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-arrays": [ + { + "cardinality": { + "source": "callback-param" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-values": [ + { + "cardinality": { + "source": "array-method-destructured" + }, + "count": 1 + } + ], + "fs-dirent-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-dirent-path-rewrites": [ + { + "cardinality": { + "style": "destructured-binding" + }, + "count": 1 + }, + { + "cardinality": { + "style": "destructured-usage" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/dirent-path-to-parent-path/tests/04/metrics.json b/recipes/dirent-path-to-parent-path/tests/04/metrics.json new file mode 100644 index 00000000..ef1fc48f --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/04/metrics.json @@ -0,0 +1,42 @@ +{ + "fs-dirent-path-bindings": [ + { + "cardinality": { + "fn": "$.opendir" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-arrays": [ + { + "cardinality": { + "source": "variable-declarator" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-values": [ + { + "cardinality": { + "source": "for-in" + }, + "count": 1 + } + ], + "fs-dirent-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-dirent-path-rewrites": [ + { + "cardinality": { + "style": "member-expression" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/dirent-path-to-parent-path/tests/05/metrics.json b/recipes/dirent-path-to-parent-path/tests/05/metrics.json new file mode 100644 index 00000000..8d7808c8 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/05/metrics.json @@ -0,0 +1,42 @@ +{ + "fs-dirent-path-bindings": [ + { + "cardinality": { + "fn": "$.readdirSync" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-arrays": [ + { + "cardinality": { + "source": "variable-declarator" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-values": [ + { + "cardinality": { + "source": "array-method-param" + }, + "count": 1 + } + ], + "fs-dirent-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-dirent-path-rewrites": [ + { + "cardinality": { + "style": "member-expression" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/dirent-path-to-parent-path/tests/06/metrics.json b/recipes/dirent-path-to-parent-path/tests/06/metrics.json new file mode 100644 index 00000000..50e669e3 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/06/metrics.json @@ -0,0 +1,42 @@ +{ + "fs-dirent-path-bindings": [ + { + "cardinality": { + "fn": "$.readdir" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-arrays": [ + { + "cardinality": { + "source": "variable-declarator" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-values": [ + { + "cardinality": { + "source": "array-method-param" + }, + "count": 2 + } + ], + "fs-dirent-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-dirent-path-rewrites": [ + { + "cardinality": { + "style": "member-expression" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/dirent-path-to-parent-path/tests/07/metrics.json b/recipes/dirent-path-to-parent-path/tests/07/metrics.json new file mode 100644 index 00000000..0d5f467e --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/07/metrics.json @@ -0,0 +1,54 @@ +{ + "fs-dirent-path-bindings": [ + { + "cardinality": { + "fn": "$.opendir" + }, + "count": 1 + }, + { + "cardinality": { + "fn": "$.readdir" + }, + "count": 1 + }, + { + "cardinality": { + "fn": "$.readdirSync" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-arrays": [ + { + "cardinality": { + "source": "variable-declarator" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-values": [ + { + "cardinality": { + "source": "array-method-param" + }, + "count": 1 + } + ], + "fs-dirent-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-dirent-path-rewrites": [ + { + "cardinality": { + "style": "member-expression" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/recipes/dirent-path-to-parent-path/tests/08/metrics.json b/recipes/dirent-path-to-parent-path/tests/08/metrics.json new file mode 100644 index 00000000..5032c981 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/08/metrics.json @@ -0,0 +1,42 @@ +{ + "fs-dirent-path-bindings": [ + { + "cardinality": { + "fn": "$.readdir" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-arrays": [ + { + "cardinality": { + "source": "variable-declarator" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-values": [ + { + "cardinality": { + "source": "for-in" + }, + "count": 1 + } + ], + "fs-dirent-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-dirent-path-rewrites": [ + { + "cardinality": { + "style": "member-expression" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/dirent-path-to-parent-path/tests/09/metrics.json b/recipes/dirent-path-to-parent-path/tests/09/metrics.json new file mode 100644 index 00000000..1ac0a6c7 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/09/metrics.json @@ -0,0 +1,42 @@ +{ + "fs-dirent-path-bindings": [ + { + "cardinality": { + "fn": "$.readdir" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-arrays": [ + { + "cardinality": { + "source": "variable-declarator" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-values": [ + { + "cardinality": { + "source": "for-subscript-member" + }, + "count": 1 + } + ], + "fs-dirent-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-dirent-path-rewrites": [ + { + "cardinality": { + "style": "member-expression" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/dirent-path-to-parent-path/tests/10/metrics.json b/recipes/dirent-path-to-parent-path/tests/10/metrics.json new file mode 100644 index 00000000..84b90af4 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/10/metrics.json @@ -0,0 +1,42 @@ +{ + "fs-dirent-path-bindings": [ + { + "cardinality": { + "fn": "$.readdir" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-arrays": [ + { + "cardinality": { + "source": "variable-declarator" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-values": [ + { + "cardinality": { + "source": "for-subscript-declarator" + }, + "count": 1 + } + ], + "fs-dirent-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-dirent-path-rewrites": [ + { + "cardinality": { + "style": "member-expression" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/dirent-path-to-parent-path/tests/11/metrics.json b/recipes/dirent-path-to-parent-path/tests/11/metrics.json new file mode 100644 index 00000000..a710d930 --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/11/metrics.json @@ -0,0 +1,48 @@ +{ + "fs-dirent-path-bindings": [ + { + "cardinality": { + "fn": "$.readdir" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-arrays": [ + { + "cardinality": { + "source": "variable-declarator" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-values": [ + { + "cardinality": { + "source": "array-method-destructured" + }, + "count": 1 + } + ], + "fs-dirent-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-dirent-path-rewrites": [ + { + "cardinality": { + "style": "destructured-binding" + }, + "count": 1 + }, + { + "cardinality": { + "style": "destructured-usage" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/dirent-path-to-parent-path/tests/12/metrics.json b/recipes/dirent-path-to-parent-path/tests/12/metrics.json new file mode 100644 index 00000000..3342413a --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/12/metrics.json @@ -0,0 +1,60 @@ +{ + "fs-dirent-path-bindings": [ + { + "cardinality": { + "fn": "$.opendir" + }, + "count": 1 + }, + { + "cardinality": { + "fn": "$.readdir" + }, + "count": 1 + }, + { + "cardinality": { + "fn": "$.readdirSync" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-arrays": [ + { + "cardinality": { + "source": "variable-declarator" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-values": [ + { + "cardinality": { + "source": "array-method-destructured" + }, + "count": 1 + } + ], + "fs-dirent-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-dirent-path-rewrites": [ + { + "cardinality": { + "style": "destructured-binding" + }, + "count": 1 + }, + { + "cardinality": { + "style": "destructured-usage" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/dirent-path-to-parent-path/tests/13/metrics.json b/recipes/dirent-path-to-parent-path/tests/13/metrics.json new file mode 100644 index 00000000..e73821ef --- /dev/null +++ b/recipes/dirent-path-to-parent-path/tests/13/metrics.json @@ -0,0 +1,60 @@ +{ + "fs-dirent-path-bindings": [ + { + "cardinality": { + "fn": "$.opendir" + }, + "count": 1 + }, + { + "cardinality": { + "fn": "$.readdir" + }, + "count": 1 + }, + { + "cardinality": { + "fn": "$.readdirSync" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-arrays": [ + { + "cardinality": { + "source": "callback-param" + }, + "count": 1 + } + ], + "fs-dirent-path-dir-values": [ + { + "cardinality": { + "source": "array-method-destructured" + }, + "count": 1 + } + ], + "fs-dirent-path-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-dirent-path-rewrites": [ + { + "cardinality": { + "style": "destructured-binding" + }, + "count": 1 + }, + { + "cardinality": { + "style": "destructured-usage" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/err-invalid-callback/src/workflow.ts b/recipes/err-invalid-callback/src/workflow.ts index 86b492b9..6afb6214 100644 --- a/recipes/err-invalid-callback/src/workflow.ts +++ b/recipes/err-invalid-callback/src/workflow.ts @@ -1,27 +1,64 @@ -import type { Edit, SgRoot } from '@codemod.com/jssg-types/main'; -import type JS from '@codemod.com/jssg-types/langs/javascript'; +import { useMetricAtom } from 'codemod:metrics'; +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep'; +import type JS from 'codemod:ast-grep/langs/javascript'; const OLD_CODE = 'ERR_INVALID_CALLBACK'; const NEW_CODE = 'ERR_INVALID_ARG_TYPE'; +const contextMetric = useMetricAtom('err-invalid-callback-contexts'); +const filesMetric = useMetricAtom('err-invalid-callback-files'); + /** - * Transform function that replaces references to the deprecated - * ERR_INVALID_CALLBACK error code with ERR_INVALID_ARG_TYPE. - * - * See DEP0159: https://nodejs.org/api/deprecations.html#DEP0159 - * - * Only matches string literals in error-code-related contexts: - * - Binary comparisons: err.code === "ERR_INVALID_CALLBACK" - * - Object properties: { code: "ERR_INVALID_CALLBACK" } - * - Switch cases: case "ERR_INVALID_CALLBACK": - * - String matching calls: .includes("ERR_INVALID_CALLBACK") + * Classify which error-code context matched a given string fragment, for + * metrics purposes only. Mirrors the `any` branches in the match rule above. + */ +function classifyContext(fragment: SgNode): string { + const stringNode = fragment.parent(); + if (!stringNode) return 'unknown'; + + let node = stringNode.parent(); + + while (node) { + switch (node.kind()) { + case 'binary_expression': + return 'binary-expression'; + case 'pair': + return 'object-property'; + case 'switch_case': + return 'switch-case'; + case 'arguments': { + const call = node.parent(); + const fn = call?.find({ rule: { kind: 'member_expression' } }); + const prop = fn?.find({ rule: { kind: 'property_identifier' } }); + return prop?.text() ? `string-method:${prop.text()}` : 'string-method'; + } + default: + node = node.parent(); + } + } + + return 'unknown'; +} + +/** + * Remove duplicate operands in || expressions that arise from the replacement. * - * Does NOT match strings used in non-error-code contexts such as - * console.warn("ERR_INVALID_CALLBACK") or throw new Error("ERR_INVALID_CALLBACK"). + * After replacing ERR_INVALID_CALLBACK → ERR_INVALID_ARG_TYPE, code that previously + * checked for both codes (e.g., `a === "ERR_INVALID_CALLBACK" || a === "ERR_INVALID_ARG_TYPE"`) + * will have two identical conditions that should be collapsed into one. * - * Deduplicates redundant checks after replacement (e.g., a === "X" || a === "X"). + * The regex captures a ` === ERR_INVALID_ARG_TYPE` expression, + * then matches `|| `. The lhs is captured with [\w.[\]"']+ to + * support property access patterns like `err.code`, `err["code"]`, and simple identifiers. */ -export default function transform(root: SgRoot): string | null { +function deduplicateBinaryExpressions(code: string): string { + return code.replace( + /([\w.[\]"']+\s*===\s*["']ERR_INVALID_ARG_TYPE["'])\s*\|\|\s*\n?\s*\1/g, + '$1', + ); +} + +const transform: Codemod = async (root) => { const rootNode = root.root(); const edits: Edit[] = []; @@ -61,10 +98,14 @@ export default function transform(root: SgRoot): string | null { }); for (const fragment of stringFragments) { + contextMetric.increment({ context: classifyContext(fragment) }); edits.push(fragment.replace(NEW_CODE)); } - if (!edits.length) return null; + if (!edits.length) { + filesMetric.increment({ status: 'no-changes' }); + return null; + } let result = rootNode.commitEdits(edits); @@ -73,23 +114,9 @@ export default function transform(root: SgRoot): string | null { // becomes `err.code === "ERR_INVALID_ARG_TYPE"` result = deduplicateBinaryExpressions(result); + filesMetric.increment({ status: 'migrated' }); + return result; } -/** - * Remove duplicate operands in || expressions that arise from the replacement. - * - * After replacing ERR_INVALID_CALLBACK → ERR_INVALID_ARG_TYPE, code that previously - * checked for both codes (e.g., `a === "ERR_INVALID_CALLBACK" || a === "ERR_INVALID_ARG_TYPE"`) - * will have two identical conditions that should be collapsed into one. - * - * The regex captures a ` === ERR_INVALID_ARG_TYPE` expression, - * then matches `|| `. The lhs is captured with [\w.[\]"']+ to - * support property access patterns like `err.code`, `err["code"]`, and simple identifiers. - */ -function deduplicateBinaryExpressions(code: string): string { - return code.replace( - /([\w.[\]"']+\s*===\s*["']ERR_INVALID_ARG_TYPE["'])\s*\|\|\s*\n?\s*\1/g, - '$1', - ); -} +export default transform; diff --git a/recipes/err-invalid-callback/tests/assert-throws-object/metrics.json b/recipes/err-invalid-callback/tests/assert-throws-object/metrics.json new file mode 100644 index 00000000..7425b73b --- /dev/null +++ b/recipes/err-invalid-callback/tests/assert-throws-object/metrics.json @@ -0,0 +1,18 @@ +{ + "err-invalid-callback-contexts": [ + { + "cardinality": { + "context": "object-property" + }, + "count": 1 + } + ], + "err-invalid-callback-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/err-invalid-callback/tests/deduplication/metrics.json b/recipes/err-invalid-callback/tests/deduplication/metrics.json new file mode 100644 index 00000000..a78eb2d6 --- /dev/null +++ b/recipes/err-invalid-callback/tests/deduplication/metrics.json @@ -0,0 +1,18 @@ +{ + "err-invalid-callback-contexts": [ + { + "cardinality": { + "context": "binary-expression" + }, + "count": 1 + } + ], + "err-invalid-callback-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/err-invalid-callback/tests/false-positive-console-warn/metrics.json b/recipes/err-invalid-callback/tests/false-positive-console-warn/metrics.json new file mode 100644 index 00000000..2f0e57fb --- /dev/null +++ b/recipes/err-invalid-callback/tests/false-positive-console-warn/metrics.json @@ -0,0 +1,10 @@ +{ + "err-invalid-callback-files": [ + { + "cardinality": { + "status": "no-changes" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/err-invalid-callback/tests/if-statement-comparison/metrics.json b/recipes/err-invalid-callback/tests/if-statement-comparison/metrics.json new file mode 100644 index 00000000..a78eb2d6 --- /dev/null +++ b/recipes/err-invalid-callback/tests/if-statement-comparison/metrics.json @@ -0,0 +1,18 @@ +{ + "err-invalid-callback-contexts": [ + { + "cardinality": { + "context": "binary-expression" + }, + "count": 1 + } + ], + "err-invalid-callback-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/err-invalid-callback/tests/no-match-unchanged/metrics.json b/recipes/err-invalid-callback/tests/no-match-unchanged/metrics.json new file mode 100644 index 00000000..2f0e57fb --- /dev/null +++ b/recipes/err-invalid-callback/tests/no-match-unchanged/metrics.json @@ -0,0 +1,10 @@ +{ + "err-invalid-callback-files": [ + { + "cardinality": { + "status": "no-changes" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/err-invalid-callback/tests/single-quoted-string/metrics.json b/recipes/err-invalid-callback/tests/single-quoted-string/metrics.json new file mode 100644 index 00000000..a78eb2d6 --- /dev/null +++ b/recipes/err-invalid-callback/tests/single-quoted-string/metrics.json @@ -0,0 +1,18 @@ +{ + "err-invalid-callback-contexts": [ + { + "cardinality": { + "context": "binary-expression" + }, + "count": 1 + } + ], + "err-invalid-callback-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/err-invalid-callback/tests/string-includes-check/metrics.json b/recipes/err-invalid-callback/tests/string-includes-check/metrics.json new file mode 100644 index 00000000..96d9cccd --- /dev/null +++ b/recipes/err-invalid-callback/tests/string-includes-check/metrics.json @@ -0,0 +1,18 @@ +{ + "err-invalid-callback-contexts": [ + { + "cardinality": { + "context": "string-method:toString" + }, + "count": 1 + } + ], + "err-invalid-callback-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/err-invalid-callback/tests/switch-case/metrics.json b/recipes/err-invalid-callback/tests/switch-case/metrics.json new file mode 100644 index 00000000..d0607c5c --- /dev/null +++ b/recipes/err-invalid-callback/tests/switch-case/metrics.json @@ -0,0 +1,18 @@ +{ + "err-invalid-callback-contexts": [ + { + "cardinality": { + "context": "switch-case" + }, + "count": 1 + } + ], + "err-invalid-callback-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/fs-access-mode-constants/src/workflow.ts b/recipes/fs-access-mode-constants/src/workflow.ts index 8e827fcf..d52b7c89 100644 --- a/recipes/fs-access-mode-constants/src/workflow.ts +++ b/recipes/fs-access-mode-constants/src/workflow.ts @@ -1,8 +1,9 @@ +import { useMetricAtom } from 'codemod:metrics'; +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep'; +import type Js from 'codemod:ast-grep/langs/javascript'; import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; import { updateBinding } from '@nodejs/codemod-utils/ast-grep/update-binding'; -import type { Edit, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; -import type Js from '@codemod.com/jssg-types/langs/javascript'; const PATTERN_SET = ['F_OK', 'R_OK', 'W_OK', 'X_OK']; @@ -20,45 +21,10 @@ type RemovedBinding = { local: string; }; -export default function transform(root: SgRoot): string | null { - const rootNode = root.root(); - const edits: Edit[] = []; - const localBindings = new Map(); - const namespaceBindings = new Map(); - - const depStatements = getModuleDependencies(root, 'fs'); - - if (!depStatements) return null; - - for (const statement of depStatements) { - const promisesBinding = resolveBindingPath(statement, '$.promises'); - const rewritten = rewriteBindings(statement, promisesBinding); - edits.push(...rewritten.edits); - - for (const mapping of rewritten.mappings) { - localBindings.set(mapping.local, mapping.replacement); - } - - for (const pattern of PATTERN_SET) { - const resolved = resolveBindingPath(statement, `$.${pattern}`); - if (!resolved?.includes('.') || resolved.includes('.constants.')) { - continue; - } - - namespaceBindings.set( - resolved, - resolved.replace(`.${pattern}`, `.constants.${pattern}`), - ); - } - } - - applyNamespaceReplacements(rootNode, edits, namespaceBindings); - applyLocalReplacements(rootNode, edits, localBindings); - - if (!edits.length) return null; - - return rootNode.commitEdits(edits); -} +const bindingMetric = useMetricAtom('fs-access-constants-bindings'); +const namespaceMetric = useMetricAtom('fs-access-constants-namespace-rewrites'); +const localMetric = useMetricAtom('fs-access-constants-local-rewrites'); +const filesMetric = useMetricAtom('fs-access-constants-files'); function rewriteBindings( statement: SgNode, @@ -123,6 +89,7 @@ function rewriteObjectPattern( imported: name, local: name, }); + bindingMetric.increment({ shape: 'object-pattern', constant: name }); } else { kept.push(name); } @@ -134,6 +101,10 @@ function rewriteObjectPattern( imported: binding.imported, local: binding.local, }); + bindingMetric.increment({ + shape: 'object-pattern', + constant: binding.imported, + }); } else { kept.push(binding.text); } @@ -169,6 +140,7 @@ export function rewriteNamedImports( imported, local, }); + bindingMetric.increment({ shape: 'named-imports', constant: imported }); } else { kept.push(specifier.text()); } @@ -190,9 +162,11 @@ export function applyNamespaceReplacements( ): void { for (const [path, replacement] of replacements) { const nodes = rootNode.findAll({ rule: { pattern: path } }); + const constant = path.split('.').pop() ?? path; for (const node of nodes) { edits.push(node.replace(replacement)); + namespaceMetric.increment({ constant }); } } } @@ -219,6 +193,7 @@ export function applyLocalReplacements( } edits.push(identifier.replace(replacement)); + localMetric.increment({ local }); } } } @@ -268,3 +243,50 @@ export function rewriteCollectedBindings({ export function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + +const transform: Codemod = async (root) => { + const rootNode = root.root(); + const edits: Edit[] = []; + const localBindings = new Map(); + const namespaceBindings = new Map(); + + const depStatements = getModuleDependencies(root, 'fs'); + + if (!depStatements) return null; + + for (const statement of depStatements) { + const promisesBinding = resolveBindingPath(statement, '$.promises'); + const rewritten = rewriteBindings(statement, promisesBinding); + edits.push(...rewritten.edits); + + for (const mapping of rewritten.mappings) { + localBindings.set(mapping.local, mapping.replacement); + } + + for (const pattern of PATTERN_SET) { + const resolved = resolveBindingPath(statement, `$.${pattern}`); + if (!resolved?.includes('.') || resolved.includes('.constants.')) { + continue; + } + + namespaceBindings.set( + resolved, + resolved.replace(`.${pattern}`, `.constants.${pattern}`), + ); + } + } + + applyNamespaceReplacements(rootNode, edits, namespaceBindings); + applyLocalReplacements(rootNode, edits, localBindings); + + if (!edits.length) { + filesMetric.increment({ status: 'no-changes' }); + return null; + } + + filesMetric.increment({ status: 'migrated' }); + + return rootNode.commitEdits(edits); +} + +export default transform; diff --git a/recipes/fs-access-mode-constants/tests/file-00/metrics.json b/recipes/fs-access-mode-constants/tests/file-00/metrics.json new file mode 100644 index 00000000..c5d63291 --- /dev/null +++ b/recipes/fs-access-mode-constants/tests/file-00/metrics.json @@ -0,0 +1,30 @@ +{ + "fs-access-constants-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-access-constants-namespace-rewrites": [ + { + "cardinality": { + "constant": "F_OK" + }, + "count": 1 + }, + { + "cardinality": { + "constant": "R_OK" + }, + "count": 1 + }, + { + "cardinality": { + "constant": "W_OK" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/fs-access-mode-constants/tests/file-01/metrics.json b/recipes/fs-access-mode-constants/tests/file-01/metrics.json new file mode 100644 index 00000000..12a9cd14 --- /dev/null +++ b/recipes/fs-access-mode-constants/tests/file-01/metrics.json @@ -0,0 +1,24 @@ +{ + "fs-access-constants-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-access-constants-namespace-rewrites": [ + { + "cardinality": { + "constant": "F_OK" + }, + "count": 1 + }, + { + "cardinality": { + "constant": "X_OK" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/fs-access-mode-constants/tests/file-02/metrics.json b/recipes/fs-access-mode-constants/tests/file-02/metrics.json new file mode 100644 index 00000000..ac6fb008 --- /dev/null +++ b/recipes/fs-access-mode-constants/tests/file-02/metrics.json @@ -0,0 +1,53 @@ +{ + "fs-access-constants-bindings": [ + { + "cardinality": { + "constant": "F_OK", + "shape": "object-pattern" + }, + "count": 1 + }, + { + "cardinality": { + "constant": "R_OK", + "shape": "object-pattern" + }, + "count": 1 + }, + { + "cardinality": { + "constant": "W_OK", + "shape": "object-pattern" + }, + "count": 1 + } + ], + "fs-access-constants-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-access-constants-local-rewrites": [ + { + "cardinality": { + "local": "F_OK" + }, + "count": 1 + }, + { + "cardinality": { + "local": "R_OK" + }, + "count": 1 + }, + { + "cardinality": { + "local": "W_OK" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/fs-access-mode-constants/tests/file-03/metrics.json b/recipes/fs-access-mode-constants/tests/file-03/metrics.json new file mode 100644 index 00000000..27748160 --- /dev/null +++ b/recipes/fs-access-mode-constants/tests/file-03/metrics.json @@ -0,0 +1,66 @@ +{ + "fs-access-constants-bindings": [ + { + "cardinality": { + "constant": "F_OK", + "shape": "named-imports" + }, + "count": 1 + }, + { + "cardinality": { + "constant": "R_OK", + "shape": "named-imports" + }, + "count": 1 + }, + { + "cardinality": { + "constant": "W_OK", + "shape": "named-imports" + }, + "count": 1 + }, + { + "cardinality": { + "constant": "X_OK", + "shape": "named-imports" + }, + "count": 1 + } + ], + "fs-access-constants-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-access-constants-local-rewrites": [ + { + "cardinality": { + "local": "F_OK" + }, + "count": 1 + }, + { + "cardinality": { + "local": "R_OK" + }, + "count": 1 + }, + { + "cardinality": { + "local": "W_OK" + }, + "count": 1 + }, + { + "cardinality": { + "local": "X_OK" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/fs-access-mode-constants/tests/file-04/metrics.json b/recipes/fs-access-mode-constants/tests/file-04/metrics.json new file mode 100644 index 00000000..98078fb5 --- /dev/null +++ b/recipes/fs-access-mode-constants/tests/file-04/metrics.json @@ -0,0 +1,48 @@ +{ + "fs-access-constants-bindings": [ + { + "cardinality": { + "constant": "F_OK", + "shape": "object-pattern" + }, + "count": 1 + }, + { + "cardinality": { + "constant": "R_OK", + "shape": "object-pattern" + }, + "count": 1 + } + ], + "fs-access-constants-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-access-constants-local-rewrites": [ + { + "cardinality": { + "local": "F_OK" + }, + "count": 1 + }, + { + "cardinality": { + "local": "R_OK" + }, + "count": 1 + } + ], + "fs-access-constants-namespace-rewrites": [ + { + "cardinality": { + "constant": "W_OK" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/fs-access-mode-constants/tests/file-05/metrics.json b/recipes/fs-access-mode-constants/tests/file-05/metrics.json new file mode 100644 index 00000000..ad8e2c7f --- /dev/null +++ b/recipes/fs-access-mode-constants/tests/file-05/metrics.json @@ -0,0 +1,34 @@ +{ + "fs-access-constants-bindings": [ + { + "cardinality": { + "constant": "F_OK", + "shape": "named-imports" + }, + "count": 1 + }, + { + "cardinality": { + "constant": "R_OK", + "shape": "named-imports" + }, + "count": 1 + } + ], + "fs-access-constants-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-access-constants-local-rewrites": [ + { + "cardinality": { + "local": "F_OK" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/fs-access-mode-constants/tests/file-06/metrics.json b/recipes/fs-access-mode-constants/tests/file-06/metrics.json new file mode 100644 index 00000000..c5d63291 --- /dev/null +++ b/recipes/fs-access-mode-constants/tests/file-06/metrics.json @@ -0,0 +1,30 @@ +{ + "fs-access-constants-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-access-constants-namespace-rewrites": [ + { + "cardinality": { + "constant": "F_OK" + }, + "count": 1 + }, + { + "cardinality": { + "constant": "R_OK" + }, + "count": 1 + }, + { + "cardinality": { + "constant": "W_OK" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/fs-access-mode-constants/tests/file-07/metrics.json b/recipes/fs-access-mode-constants/tests/file-07/metrics.json new file mode 100644 index 00000000..27748160 --- /dev/null +++ b/recipes/fs-access-mode-constants/tests/file-07/metrics.json @@ -0,0 +1,66 @@ +{ + "fs-access-constants-bindings": [ + { + "cardinality": { + "constant": "F_OK", + "shape": "named-imports" + }, + "count": 1 + }, + { + "cardinality": { + "constant": "R_OK", + "shape": "named-imports" + }, + "count": 1 + }, + { + "cardinality": { + "constant": "W_OK", + "shape": "named-imports" + }, + "count": 1 + }, + { + "cardinality": { + "constant": "X_OK", + "shape": "named-imports" + }, + "count": 1 + } + ], + "fs-access-constants-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "fs-access-constants-local-rewrites": [ + { + "cardinality": { + "local": "F_OK" + }, + "count": 1 + }, + { + "cardinality": { + "local": "R_OK" + }, + "count": 1 + }, + { + "cardinality": { + "local": "W_OK" + }, + "count": 1 + }, + { + "cardinality": { + "local": "X_OK" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/fs-truncate-fd-deprecation/package.json b/recipes/fs-truncate-fd-deprecation/package.json index 8ea2a5fb..8caa3657 100644 --- a/recipes/fs-truncate-fd-deprecation/package.json +++ b/recipes/fs-truncate-fd-deprecation/package.json @@ -16,6 +16,7 @@ "license": "MIT", "homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/fs-truncate-fd-deprecation/README.md", "devDependencies": { + "@ast-grep/napi": "^0.44.1", "@codemod.com/jssg-types": "^1.6.2" }, "dependencies": { diff --git a/recipes/fs-truncate-fd-deprecation/src/workflow.ts b/recipes/fs-truncate-fd-deprecation/src/workflow.ts index 77aad829..555755de 100644 --- a/recipes/fs-truncate-fd-deprecation/src/workflow.ts +++ b/recipes/fs-truncate-fd-deprecation/src/workflow.ts @@ -1,11 +1,9 @@ -import { - getNodeImportStatements, - getNodeImportCalls, -} from '@nodejs/codemod-utils/ast-grep/import-statement'; +import { useMetricAtom } from 'codemod:metrics'; +import type { Codemod, Edit, SgNode, SgRoot } from 'codemod:ast-grep'; +import type Js from 'codemod:ast-grep/langs/javascript'; +import { getNodeImportStatements, getNodeImportCalls } 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'; -import type { SgRoot, Edit, SgNode } from '@codemod.com/jssg-types/main'; -import type Js from '@codemod.com/jssg-types/langs/javascript'; import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; // Bindings we care about and their replacements for truncate ➜ ftruncate @@ -30,129 +28,8 @@ const checks = [ }, ]; -/** - * Transform function that converts deprecated fs.truncate calls to fs.ftruncate. - * - * See DEP0081: https://nodejs.org/api/deprecations.html#DEP0081 - * - * Handles: - * 1. fs.truncate(fd, len, callback) → fs.ftruncate(fd, len, callback) - * 2. fs.truncateSync(fd, len) → fs.ftruncateSync(fd, len) - * 3. truncate(fd, len, callback) → ftruncate(fd, len, callback) (destructured imports) - * 4. truncateSync(fd, len) → ftruncateSync(fd, len) (destructured imports) - * 5. Import/require statement updates to replace truncate/truncateSync with ftruncate/ftruncateSync - */ -export default function transform(root: SgRoot): string | null { - const rootNode = root.root(); - const edits: Edit[] = []; - - // Gather fs import/require statements to resolve local binding names - const stmtNodes = [ - ...getNodeRequireCalls(root, 'fs'), - ...getNodeImportStatements(root, 'fs'), - ]; - - let usedTruncate = false; - let usedTruncateSync = false; - - for (const stmt of stmtNodes) { - for (const check of checks) { - const local = resolveBindingPath(stmt, check.path); - if (!local) continue; - - // property name to look for on fs (e.g. 'truncate' or 'truncateSync') - const propName = check.prop; - - // Find call sites for the resolved local binding and for fs. - const calls = rootNode.findAll({ - rule: { - any: [ - { pattern: `${local}($FD, $LEN, $CALLBACK)` }, - { pattern: `${local}($FD, $LEN)` }, - { pattern: `fs.${propName}($FD, $LEN, $CALLBACK)` }, - { pattern: `fs.${propName}($FD, $LEN)` }, - ], - }, - }); - - let transformedAny = false; - for (const call of calls) { - const fdMatch = call.getMatch('FD'); - if (!fdMatch) continue; - - const fdText = fdMatch.text(); - - // only transform when first arg is likely a file descriptor - if (!isLikelyFileDescriptor(fdText, rootNode)) continue; - - // Instead of replacing the whole call text (which can mangle - // indentation and inner formatting), replace only the callee - // identifier or property node (e.g. `truncate` → `ftruncate`). - let replacedAny = false; - - // Try to replace a simple identifier callee (destructured import: `truncate(...)`) - const localName = local.split('.').at(-1) || local; - const idNode = call.find({ - rule: { kind: 'identifier', regex: `^${localName}$` }, - }); - if (idNode) { - edits.push(idNode.replace(check.replaceFn(idNode.text()))); - replacedAny = true; - } - - // Try to replace a member expression property (e.g. `fs.truncate(...)` or `myFS.truncate(...)`) - if (!replacedAny) { - const propNode = call.find({ - rule: { kind: 'property_identifier', regex: `^${propName}$` }, - }); - if (propNode) { - edits.push(propNode.replace(check.replaceFn(propNode.text()))); - replacedAny = true; - } - } - - if (!replacedAny) continue; - - transformedAny = true; - if (check.isSync) usedTruncateSync = true; - else usedTruncate = true; - } - - // Update import/destructure to include/rename to ftruncate/ftruncateSync where necessary - const namedNode = - stmt.find({ rule: { kind: 'object_pattern' } }) || - stmt.find({ rule: { kind: 'named_imports' } }); - if (transformedAny && namedNode?.text().includes(propName)) { - const original = namedNode.text(); - const newText = original.replace( - new RegExp(`\\b${propName}\\b`, 'g'), - check.replaceFn(propName), - ); - if (newText !== original) { - edits.push(namedNode.replace(newText)); - } - } - } - } - - // Update import/require statements to reflect renamed bindings - updateImportsAndRequires(root, usedTruncate, usedTruncateSync, edits); - - // If no edits were produced but the file imports fs via dynamic import, - // trigger a no-op replacement to force a reprint. This normalizes - // indentation (tabs → spaces) to match expected fixtures. - if (!edits.length) { - const dynImportCalls = getNodeImportCalls(root, 'fs'); - - for (const dynImport of dynImportCalls) { - edits.push(dynImport.replace(dynImport.text())); - } - } - - if (!edits.length) return null; - - return rootNode.commitEdits(edits); -} +const callMetric = useMetricAtom('fs-truncate-fd-calls'); +const filesMetric = useMetricAtom('fs-truncate-fd-files'); /** * Update import and require statements to replace truncate functions with ftruncate @@ -200,7 +77,7 @@ function updateImportsAndRequires( * @param param The parameter to check (e.g., 'fd'). * @param rootNode The root node of the AST to search within. */ -function isLikelyFileDescriptor(param: string, rootNode: SgNode): boolean { +export function isLikelyFileDescriptor(param: string, rootNode: SgNode): boolean { // Check if it's obviously a string literal (path) if (/^['"`]/.test(param.trim())) return false; @@ -224,7 +101,7 @@ function isLikelyFileDescriptor(param: string, rootNode: SgNode): boolean { * @param param The parameter name to check * @param rootNode The root node of the AST */ -function isInCallbackContext(param: string, rootNode: SgNode): boolean { +export function isInCallbackContext(param: string, rootNode: SgNode): boolean { const parameterUsages = rootNode.findAll({ rule: { kind: 'identifier', @@ -273,7 +150,7 @@ function isInCallbackContext(param: string, rootNode: SgNode): boolean { * @param param The parameter name to check * @param rootNode The root node of the AST */ -function isAssignedFromOpenSync(param: string, rootNode: SgNode): boolean { +export function isAssignedFromOpenSync(param: string, rootNode: SgNode): boolean { // Search for variable declarations or assignments from fs.openSync or openSync const openSyncAssignments = rootNode.findAll({ rule: { @@ -364,3 +241,127 @@ function isAssignedFromOpenSync(param: string, rootNode: SgNode): boolean { return openSyncAssignments.length > 0; } + +const transform: Codemod = async (root) => { + const rootNode = root.root(); + const edits: Edit[] = []; + + // Gather fs import/require statements to resolve local binding names + const stmtNodes = [ + ...getNodeRequireCalls(root, 'fs'), + ...getNodeImportStatements(root, 'fs'), + ]; + + let usedTruncate = false; + let usedTruncateSync = false; + + for (const stmt of stmtNodes) { + for (const check of checks) { + const local = resolveBindingPath(stmt, check.path); + if (!local) continue; + + // property name to look for on fs (e.g. 'truncate' or 'truncateSync') + const propName = check.prop; + + // Find call sites for the resolved local binding and for fs. + const calls = rootNode.findAll({ + rule: { + any: [ + { pattern: `${local}($FD, $LEN, $CALLBACK)` }, + { pattern: `${local}($FD, $LEN)` }, + { pattern: `fs.${propName}($FD, $LEN, $CALLBACK)` }, + { pattern: `fs.${propName}($FD, $LEN)` }, + ], + }, + }); + + let transformedAny = false; + for (const call of calls) { + const fdMatch = call.getMatch('FD'); + if (!fdMatch) continue; + + const fdText = fdMatch.text(); + + // only transform when first arg is likely a file descriptor + if (!isLikelyFileDescriptor(fdText, rootNode)) continue; + + // Instead of replacing the whole call text (which can mangle + // indentation and inner formatting), replace only the callee + // identifier or property node (e.g. `truncate` → `ftruncate`). + let replacedAny = false; + + // Try to replace a simple identifier callee (destructured import: `truncate(...)`) + const localName = local.split('.').at(-1) || local; + const idNode = call.find({ + rule: { kind: 'identifier', regex: `^${localName}$` }, + }); + if (idNode) { + edits.push(idNode.replace(check.replaceFn(idNode.text()))); + replacedAny = true; + } + + // Try to replace a member expression property (e.g. `fs.truncate(...)` or `myFS.truncate(...)`) + if (!replacedAny) { + const propNode = call.find({ + rule: { kind: 'property_identifier', regex: `^${propName}$` }, + }); + if (propNode) { + edits.push(propNode.replace(check.replaceFn(propNode.text()))); + replacedAny = true; + } + } + + if (!replacedAny) continue; + + transformedAny = true; + if (check.isSync) { + usedTruncateSync = true; + callMetric.increment({ fn: 'truncateSync' }); + } else { + usedTruncate = true; + callMetric.increment({ fn: 'truncate' }); + } + } + + // Update import/destructure to include/rename to ftruncate/ftruncateSync where necessary + const namedNode = + stmt.find({ rule: { kind: 'object_pattern' } }) || + stmt.find({ rule: { kind: 'named_imports' } }); + if (transformedAny && namedNode?.text().includes(propName)) { + const original = namedNode.text(); + const newText = original.replace( + new RegExp(`\\b${propName}\\b`, 'g'), + check.replaceFn(propName), + ); + if (newText !== original) { + edits.push(namedNode.replace(newText)); + } + } + } + } + + // Update import/require statements to reflect renamed bindings + updateImportsAndRequires(root, usedTruncate, usedTruncateSync, edits); + + // If no edits were produced but the file imports fs via dynamic import, + // trigger a no-op replacement to force a reprint. This normalizes + // indentation (tabs → spaces) to match expected fixtures. + if (!edits.length) { + const dynImportCalls = getNodeImportCalls(root, 'fs'); + + for (const dynImport of dynImportCalls) { + edits.push(dynImport.replace(dynImport.text())); + } + } + + if (!edits.length) { + filesMetric.increment({ status: 'no-changes' }); + return null; + } + + filesMetric.increment({ status: 'migrated' }); + + return rootNode.commitEdits(edits); +}; + +export default transform; diff --git a/recipes/fs-truncate-fd-deprecation/tests/dynamic-import-await-module/metrics.json b/recipes/fs-truncate-fd-deprecation/tests/dynamic-import-await-module/metrics.json new file mode 100644 index 00000000..e832f7b3 --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/dynamic-import-await-module/metrics.json @@ -0,0 +1,10 @@ +{ + "fs-truncate-fd-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/fs-truncate-fd-deprecation/tests/dynamic-import-module/metrics.json b/recipes/fs-truncate-fd-deprecation/tests/dynamic-import-module/metrics.json new file mode 100644 index 00000000..e832f7b3 --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/dynamic-import-module/metrics.json @@ -0,0 +1,10 @@ +{ + "fs-truncate-fd-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/fs-truncate-fd-deprecation/tests/edge-case-module/metrics.json b/recipes/fs-truncate-fd-deprecation/tests/edge-case-module/metrics.json new file mode 100644 index 00000000..7f6edcff --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/edge-case-module/metrics.json @@ -0,0 +1,24 @@ +{ + "fs-truncate-fd-calls": [ + { + "cardinality": { + "fn": "truncate" + }, + "count": 2 + }, + { + "cardinality": { + "fn": "truncateSync" + }, + "count": 1 + } + ], + "fs-truncate-fd-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/fs-truncate-fd-deprecation/tests/file-1/metrics.json b/recipes/fs-truncate-fd-deprecation/tests/file-1/metrics.json new file mode 100644 index 00000000..acf639ba --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/file-1/metrics.json @@ -0,0 +1,18 @@ +{ + "fs-truncate-fd-calls": [ + { + "cardinality": { + "fn": "truncate" + }, + "count": 2 + } + ], + "fs-truncate-fd-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/fs-truncate-fd-deprecation/tests/file-2/metrics.json b/recipes/fs-truncate-fd-deprecation/tests/file-2/metrics.json new file mode 100644 index 00000000..b05ed542 --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/file-2/metrics.json @@ -0,0 +1,18 @@ +{ + "fs-truncate-fd-calls": [ + { + "cardinality": { + "fn": "truncateSync" + }, + "count": 1 + } + ], + "fs-truncate-fd-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/fs-truncate-fd-deprecation/tests/file-3/metrics.json b/recipes/fs-truncate-fd-deprecation/tests/file-3/metrics.json new file mode 100644 index 00000000..964b1cd7 --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/file-3/metrics.json @@ -0,0 +1,10 @@ +{ + "fs-truncate-fd-files": [ + { + "cardinality": { + "status": "no-changes" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/fs-truncate-fd-deprecation/tests/file-4-module/metrics.json b/recipes/fs-truncate-fd-deprecation/tests/file-4-module/metrics.json new file mode 100644 index 00000000..acf639ba --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/file-4-module/metrics.json @@ -0,0 +1,18 @@ +{ + "fs-truncate-fd-calls": [ + { + "cardinality": { + "fn": "truncate" + }, + "count": 2 + } + ], + "fs-truncate-fd-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/fs-truncate-fd-deprecation/tests/file-5-module/metrics.json b/recipes/fs-truncate-fd-deprecation/tests/file-5-module/metrics.json new file mode 100644 index 00000000..acf639ba --- /dev/null +++ b/recipes/fs-truncate-fd-deprecation/tests/file-5-module/metrics.json @@ -0,0 +1,18 @@ +{ + "fs-truncate-fd-calls": [ + { + "cardinality": { + "fn": "truncate" + }, + "count": 2 + } + ], + "fs-truncate-fd-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/http-classes-with-new/src/workflow.ts b/recipes/http-classes-with-new/src/workflow.ts index 634374b8..594c8fcd 100644 --- a/recipes/http-classes-with-new/src/workflow.ts +++ b/recipes/http-classes-with-new/src/workflow.ts @@ -1,8 +1,9 @@ +import { useMetricAtom } from 'codemod:metrics'; +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep'; +import type JS from 'codemod:ast-grep/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'; -import type { SgRoot, Edit, SgNode } from '@codemod.com/jssg-types/main'; -import type JS from '@codemod.com/jssg-types/langs/javascript'; /** * Classes of the http module @@ -16,6 +17,9 @@ const CLASS_NAMES = [ 'ServerResponse', ]; +const callMetric = useMetricAtom('http-classes-with-new-calls'); +const filesMetric = useMetricAtom('http-classes-with-new-files'); + /** * Transform function that converts deprecated node:http classes to use the `new` keyword * @@ -27,7 +31,7 @@ const CLASS_NAMES = [ * 5. `http.Server()` → `new http.Server()` * 6. `http.ServerResponse() → `new http.ServerResponse()` */ -export default function transform(root: SgRoot): string | null { +const transform: Codemod = async (root) => { const rootNode = root.root(); const edits: Edit[] = []; @@ -37,7 +41,10 @@ export default function transform(root: SgRoot): string | null { ]; // if no imports are present it means that we don't need to process the file - if (!allStatementNodes.length) return null; + if (!allStatementNodes.length) { + filesMetric.increment({ status: 'no-changes' }); + return null; + } const classes = new Set(getHttpClassBasePaths(allStatementNodes)); @@ -51,13 +58,21 @@ export default function transform(root: SgRoot): string | null { for (const clsWithoutNew of classesWithoutNew) { edits.push(clsWithoutNew.replace(`new ${clsWithoutNew.text()}`)); + // Extract just the class name from the resolved path (e.g. "http.Agent" → "Agent") + const className = cls.split('.').at(-1) ?? cls; + callMetric.increment({ class: className }); } } - if (edits.length === 0) return null; + if (edits.length === 0) { + filesMetric.increment({ status: 'no-changes' }); + return null; + } + + filesMetric.increment({ status: 'migrated' }); return rootNode.commitEdits(edits); -} +}; /** * Get the base path of the http classes @@ -75,3 +90,5 @@ function* getHttpClassBasePaths(statements: SgNode[]) { } } } + +export default transform; diff --git a/recipes/http-classes-with-new/tests/file-1/metrics.json b/recipes/http-classes-with-new/tests/file-1/metrics.json new file mode 100644 index 00000000..47ae961c --- /dev/null +++ b/recipes/http-classes-with-new/tests/file-1/metrics.json @@ -0,0 +1,48 @@ +{ + "http-classes-with-new-calls": [ + { + "cardinality": { + "class": "Agent" + }, + "count": 1 + }, + { + "cardinality": { + "class": "ClientRequest" + }, + "count": 1 + }, + { + "cardinality": { + "class": "IncomingMessage" + }, + "count": 1 + }, + { + "cardinality": { + "class": "OutgoingMessage" + }, + "count": 1 + }, + { + "cardinality": { + "class": "Server" + }, + "count": 1 + }, + { + "cardinality": { + "class": "ServerResponse" + }, + "count": 1 + } + ], + "http-classes-with-new-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/http-classes-with-new/tests/file-2/metrics.json b/recipes/http-classes-with-new/tests/file-2/metrics.json new file mode 100644 index 00000000..47ae961c --- /dev/null +++ b/recipes/http-classes-with-new/tests/file-2/metrics.json @@ -0,0 +1,48 @@ +{ + "http-classes-with-new-calls": [ + { + "cardinality": { + "class": "Agent" + }, + "count": 1 + }, + { + "cardinality": { + "class": "ClientRequest" + }, + "count": 1 + }, + { + "cardinality": { + "class": "IncomingMessage" + }, + "count": 1 + }, + { + "cardinality": { + "class": "OutgoingMessage" + }, + "count": 1 + }, + { + "cardinality": { + "class": "Server" + }, + "count": 1 + }, + { + "cardinality": { + "class": "ServerResponse" + }, + "count": 1 + } + ], + "http-classes-with-new-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/http-outgoingmessage-headers/src/workflow.ts b/recipes/http-outgoingmessage-headers/src/workflow.ts index 039231cc..44e06f8f 100644 --- a/recipes/http-outgoingmessage-headers/src/workflow.ts +++ b/recipes/http-outgoingmessage-headers/src/workflow.ts @@ -1,5 +1,6 @@ -import type { SgRoot, SgNode, Edit, Kinds } from '@codemod.com/jssg-types/main'; -import type Js from '@codemod.com/jssg-types/langs/javascript'; +import { useMetricAtom } from 'codemod:metrics'; +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep'; +import type Js from 'codemod:ast-grep/langs/javascript'; import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; @@ -17,7 +18,10 @@ const replaceMap = { }; const queue: QueueEvent[] = []; -function getVariableValue(node: SgNode>) { +const rewriteMetric = useMetricAtom('http-outgoingmessage-headers-rewrites'); +const filesMetric = useMetricAtom('http-outgoingmessage-headers-files'); + +function getVariableValue(node: SgNode) { if (node.is('identifier')) { const definition = node.definition(); if (!definition?.node) return; @@ -199,10 +203,10 @@ const parsers = { }); const resProperty = memberExpressionNode.field('property'); if (resProperty?.text() in replaceMap) { - const edit = resProperty.replace( - replaceMap[resProperty.text() as keyof typeof replaceMap], - ); + const propertyName = resProperty.text() as keyof typeof replaceMap; + const edit = resProperty.replace(replaceMap[propertyName]); edits.push(edit); + rewriteMetric.increment({ property: propertyName }); } } } @@ -211,7 +215,7 @@ const parsers = { }, }; -export default function transform(root: SgRoot): string | null { +const transform: Codemod = async (root) => { const rootNode = root.root(); let edits: Edit[] = []; @@ -273,6 +277,14 @@ export default function transform(root: SgRoot): string | null { queue.pop(); } - if (!edits.length) return null; + if (!edits.length) { + filesMetric.increment({ status: 'no-changes' }); + return null; + } + + filesMetric.increment({ status: 'migrated' }); + return rootNode.commitEdits(edits); -} +}; + +export default transform; diff --git a/recipes/http-outgoingmessage-headers/tests/cal-com-fixutres/metrics.json b/recipes/http-outgoingmessage-headers/tests/cal-com-fixutres/metrics.json new file mode 100644 index 00000000..65ccdaf0 --- /dev/null +++ b/recipes/http-outgoingmessage-headers/tests/cal-com-fixutres/metrics.json @@ -0,0 +1,10 @@ +{ + "http-outgoingmessage-headers-files": [ + { + "cardinality": { + "status": "no-changes" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/http-outgoingmessage-headers/tests/check-if-exists/metrics.json b/recipes/http-outgoingmessage-headers/tests/check-if-exists/metrics.json new file mode 100644 index 00000000..09ed04d0 --- /dev/null +++ b/recipes/http-outgoingmessage-headers/tests/check-if-exists/metrics.json @@ -0,0 +1,18 @@ +{ + "http-outgoingmessage-headers-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "http-outgoingmessage-headers-rewrites": [ + { + "cardinality": { + "property": "_headers" + }, + "count": 5 + } + ] +} \ No newline at end of file diff --git a/recipes/http-outgoingmessage-headers/tests/iterating-over-headers/metrics.json b/recipes/http-outgoingmessage-headers/tests/iterating-over-headers/metrics.json new file mode 100644 index 00000000..190f6160 --- /dev/null +++ b/recipes/http-outgoingmessage-headers/tests/iterating-over-headers/metrics.json @@ -0,0 +1,24 @@ +{ + "http-outgoingmessage-headers-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "http-outgoingmessage-headers-rewrites": [ + { + "cardinality": { + "property": "_headerNames" + }, + "count": 4 + }, + { + "cardinality": { + "property": "_headers" + }, + "count": 12 + } + ] +} \ No newline at end of file diff --git a/recipes/http-outgoingmessage-headers/tests/reading-headers/metrics.json b/recipes/http-outgoingmessage-headers/tests/reading-headers/metrics.json new file mode 100644 index 00000000..306d87b1 --- /dev/null +++ b/recipes/http-outgoingmessage-headers/tests/reading-headers/metrics.json @@ -0,0 +1,24 @@ +{ + "http-outgoingmessage-headers-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "http-outgoingmessage-headers-rewrites": [ + { + "cardinality": { + "property": "_headerNames" + }, + "count": 1 + }, + { + "cardinality": { + "property": "_headers" + }, + "count": 3 + } + ] +} \ No newline at end of file diff --git a/recipes/http-outgoingmessage-headers/tests/supported-methods/metrics.json b/recipes/http-outgoingmessage-headers/tests/supported-methods/metrics.json new file mode 100644 index 00000000..51848898 --- /dev/null +++ b/recipes/http-outgoingmessage-headers/tests/supported-methods/metrics.json @@ -0,0 +1,18 @@ +{ + "http-outgoingmessage-headers-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "http-outgoingmessage-headers-rewrites": [ + { + "cardinality": { + "property": "_headers" + }, + "count": 3 + } + ] +} \ No newline at end of file diff --git a/recipes/http2-priority-signaling/src/workflow.ts b/recipes/http2-priority-signaling/src/workflow.ts index 9e3e09c9..e9830f7d 100644 --- a/recipes/http2-priority-signaling/src/workflow.ts +++ b/recipes/http2-priority-signaling/src/workflow.ts @@ -1,108 +1,15 @@ -import type { Edit, Range, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; -import type Js from '@codemod.com/jssg-types/langs/javascript'; +import { useMetricAtom } from 'codemod:metrics'; +import type { Codemod, Edit, Range, SgNode } from 'codemod:ast-grep'; +import type Js from 'codemod:ast-grep/langs/javascript'; import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; import { removeLines } from '@nodejs/codemod-utils/ast-grep/remove-lines'; import { getScope } from '@nodejs/codemod-utils/ast-grep/get-scope'; -/* - * Transforms HTTP/2 priority-related options and methods. - * - * Steps: - * - * 1. Find all http2 imports and require calls - * 2. Find and remove priority property from connect() options - * 3. Find and remove priority property from request() options - * 4. Find and remove complete stream.priority() calls - * 5. Find and remove priority property from settings() options - */ -export default function transform(root: SgRoot): string | null { - const rootNode = root.root(); - const edits: Edit[] = []; - const linesToRemove: Range[] = []; - - const http2Statements = getModuleDependencies(root, 'http2'); - - // If no imports, nothing to do - if (!http2Statements.length) return null; - - // Resolve all local callee names for http2.connect (handles namespace, default, named, alias, require/import) - const connectCallees = new Set(); - const sessionVars: { name: string; decl: SgNode; scope: SgNode }[] = - []; - - for (const stmt of http2Statements) { - if (stmt.kind() === 'expression_statement') { - const binding = stmt.field('name'); - if (binding) { - sessionVars.push({ - name: binding.text(), - decl: stmt, - scope: getScope(stmt), - }); - } - } else { - const resolved = resolveBindingPath(stmt, '$.connect'); - if (resolved) connectCallees.add(resolved); - } - } - - // Discover session variables created via http2.connect or destructured connect calls by - // locating call expressions and climbing to the variable declarator. - const connectCalls: SgNode[] = [ - ...rootNode.findAll({ rule: { pattern: '$HTTP2.connect($$$_ARGS)' } }), - // Also include direct calls when `connect` is imported as a named binding or alias (e.g., `connect(...)` or `bar(...)`). - ...Array.from(connectCallees).flatMap((callee) => { - // If callee already includes a dot (e.g., http2.connect), the pattern above already matched it. - if (callee.includes('.')) return [] as SgNode[]; - return rootNode.findAll({ rule: { pattern: `${callee}($$$_ARGS)` } }); - }), - ]; - - for (const call of connectCalls) { - const variableDeclarator = call.find<'variable_declarator'>({ - rule: { - inside: { - kind: 'variable_declarator', - stopBy: 'end', - }, - }, - }); - const variable = variableDeclarator?.field('name'); - if (!variable) continue; - - sessionVars.push({ - name: variable.text(), - decl: variableDeclarator, - scope: getScope(variableDeclarator), - }); - } - - // Case 1: Remove priority object from http2.connect() options (direct call sites) - edits.push(...removeConnectPriority(rootNode)); - - // Case 2: Remove priority from session.request() options scoped to discovered session vars + chained connect().request. - edits.push(...removeRequestPriority(rootNode, sessionVars)); - - // Determine stream variables created from session.request() or connect().request(). - const streamVars = collectStreamVars(rootNode, sessionVars); - - // Case 3: Remove entire stream.priority() calls only for: - // - chained request().priority() - // - variables assigned from session.request/connect().request - const result3 = removePriorityMethodCalls(rootNode, streamVars); - edits.push(...result3.edits); - linesToRemove.push(...result3.linesToRemove); - - // Case 4: Remove priority property from session.settings() options scoped to session vars + chained connect().settings. - edits.push(...removeSettingsPriority(rootNode, sessionVars)); - - if (!edits.length && !linesToRemove.length) return null; - - const sourceCode = rootNode.commitEdits(edits); - - return removeLines(sourceCode, linesToRemove); -} +const varMetric = useMetricAtom('http2-priority-vars'); +const optionMetric = useMetricAtom('http2-priority-options'); +const methodCallMetric = useMetricAtom('http2-priority-method-calls'); +const filesMetric = useMetricAtom('http2-priority-files'); /** * Remove priority property from http2.connect() call options @@ -136,9 +43,10 @@ function removeConnectPriority(rootNode: SgNode): Edit[] { if (allPriority && hasPriority) { // Remove the entire object argument from the call using AST ranges edits.push(buildDeletionEdit(obj)); + optionMetric.increment({ site: 'connect', removal: 'whole-object' }); } else if (hasPriority) { // Object has other properties, so just remove priority pair(s) - edits.push(...removePriorityPairFromObject(obj)); + edits.push(...removePriorityPairFromObject(obj, 'connect')); } } } @@ -197,7 +105,7 @@ function removeRequestPriority( } if (!allPriority || !hasPriority) { - edits.push(...removePriorityPairFromObject(obj)); + edits.push(...removePriorityPairFromObject(obj, 'request')); } } } @@ -224,7 +132,10 @@ function removePriorityMethodCalls( }, }); - const safeCalls = new Set>([...chained, ...chainedConnect]); + const safeCalls = new Map, string>(); + for (const c of chained) safeCalls.set(c, 'chained-request-priority'); + for (const c of chainedConnect) + safeCalls.set(c, 'chained-connect-request-priority'); // Priority on identified stream variable names within their scope. for (const stream of streamVars) { @@ -239,11 +150,11 @@ function removePriorityMethodCalls( }, }); - for (const c of calls) safeCalls.add(c); + for (const c of calls) safeCalls.set(c, 'stream-variable'); } // Remove expression statements containing safe priority calls. - for (const call of safeCalls) { + for (const [call, source] of safeCalls) { let node: SgNode | undefined = call; while (node) { @@ -251,6 +162,7 @@ function removePriorityMethodCalls( if (parent?.kind() === 'expression_statement') { linesToRemove.push(parent.range()); + methodCallMetric.increment({ source }); break; } node = parent; @@ -307,7 +219,7 @@ function removeSettingsPriority( } if (!allPriority || !hasPriority) { - edits.push(...removePriorityPairFromObject(obj)); + edits.push(...removePriorityPairFromObject(obj, 'settings')); } } } @@ -340,6 +252,7 @@ function collectStreamVars( for (const d of decls) { const nameNode = d.field('name'); streamVars.push({ name: nameNode.text(), scope: getScope(d) }); + varMetric.increment({ kind: 'stream', source: 'session-request' }); } } // From connect().request(...) chained assignments. @@ -366,6 +279,7 @@ function collectStreamVars( if (chainMatch) { const nameNode = d.field('name'); streamVars.push({ name: nameNode.text(), scope: getScope(d) }); + varMetric.increment({ kind: 'stream', source: 'chained-connect-request' }); } } return streamVars; @@ -374,7 +288,10 @@ function collectStreamVars( /** * Find and remove priority pair from an object, handling commas properly */ -function removePriorityPairFromObject(obj: SgNode): Edit[] { +function removePriorityPairFromObject( + obj: SgNode, + site: 'connect' | 'request' | 'settings', +): Edit[] { const edits: Edit[] = []; const pairs = obj.findAll({ rule: { kind: 'pair' } }); @@ -394,6 +311,7 @@ function removePriorityPairFromObject(obj: SgNode): Edit[] { // If all pairs are priority, remove the entire object if (priorityPairs.length === pairs.length) { edits.push(buildDeletionEdit(obj)); + optionMetric.increment({ site, removal: 'whole-object' }); return edits; } @@ -402,6 +320,7 @@ function removePriorityPairFromObject(obj: SgNode): Edit[] { // Build deletion edits for each pair, preferring to include an adjacent comma. for (const pair of priorityPairs) { edits.push(buildDeletionEdit(pair)); + optionMetric.increment({ site, removal: 'partial-pair' }); } return edits; @@ -435,3 +354,100 @@ function buildDeletionEdit(node: SgNode): Edit { return { startPos: start, endPos: end, insertedText: '' }; } + +const transform: Codemod = async (root) => { + const rootNode = root.root(); + const edits: Edit[] = []; + const linesToRemove: Range[] = []; + + const http2Statements = getModuleDependencies(root, 'http2'); + + // If no imports, nothing to do + if (!http2Statements.length) return null; + + // Resolve all local callee names for http2.connect (handles namespace, default, named, alias, require/import) + const connectCallees = new Set(); + const sessionVars: { name: string; decl: SgNode; scope: SgNode }[] = + []; + + for (const stmt of http2Statements) { + if (stmt.kind() === 'expression_statement') { + const binding = stmt.field('name'); + if (binding) { + sessionVars.push({ + name: binding.text(), + decl: stmt, + scope: getScope(stmt), + }); + varMetric.increment({ kind: 'session', source: 'expression-statement' }); + } + } else { + const resolved = resolveBindingPath(stmt, '$.connect'); + if (resolved) connectCallees.add(resolved); + } + } + + // Discover session variables created via http2.connect or destructured connect calls by + // locating call expressions and climbing to the variable declarator. + const connectCalls: SgNode[] = [ + ...rootNode.findAll({ rule: { pattern: '$HTTP2.connect($$$_ARGS)' } }), + // Also include direct calls when `connect` is imported as a named binding or alias (e.g., `connect(...)` or `bar(...)`). + ...Array.from(connectCallees).flatMap((callee) => { + // If callee already includes a dot (e.g., http2.connect), the pattern above already matched it. + if (callee.includes('.')) return [] as SgNode[]; + return rootNode.findAll({ rule: { pattern: `${callee}($$$_ARGS)` } }); + }), + ]; + + for (const call of connectCalls) { + const variableDeclarator = call.find<'variable_declarator'>({ + rule: { + inside: { + kind: 'variable_declarator', + stopBy: 'end', + }, + }, + }); + const variable = variableDeclarator?.field('name'); + if (!variable) continue; + + sessionVars.push({ + name: variable.text(), + decl: variableDeclarator, + scope: getScope(variableDeclarator), + }); + varMetric.increment({ kind: 'session', source: 'connect-call' }); + } + + // Case 1: Remove priority object from http2.connect() options (direct call sites) + edits.push(...removeConnectPriority(rootNode)); + + // Case 2: Remove priority from session.request() options scoped to discovered session vars + chained connect().request. + edits.push(...removeRequestPriority(rootNode, sessionVars)); + + // Determine stream variables created from session.request() or connect().request(). + const streamVars = collectStreamVars(rootNode, sessionVars); + + // Case 3: Remove entire stream.priority() calls only for: + // - chained request().priority() + // - variables assigned from session.request/connect().request + const result3 = removePriorityMethodCalls(rootNode, streamVars); + edits.push(...result3.edits); + linesToRemove.push(...result3.linesToRemove); + + // Case 4: Remove priority property from session.settings() options scoped to session vars + chained connect().settings. + edits.push(...removeSettingsPriority(rootNode, sessionVars)); + + if (!edits.length && !linesToRemove.length) { + filesMetric.increment({ status: 'no-changes' }); + return null; + } + + filesMetric.increment({ status: 'migrated' }); + + const sourceCode = rootNode.commitEdits(edits); + + return removeLines(sourceCode, linesToRemove); +} + +export default transform; diff --git a/recipes/http2-priority-signaling/tests/case1-connect-priority/metrics.json b/recipes/http2-priority-signaling/tests/case1-connect-priority/metrics.json new file mode 100644 index 00000000..56ef085a --- /dev/null +++ b/recipes/http2-priority-signaling/tests/case1-connect-priority/metrics.json @@ -0,0 +1,28 @@ +{ + "http2-priority-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "http2-priority-options": [ + { + "cardinality": { + "removal": "whole-object", + "site": "connect" + }, + "count": 1 + } + ], + "http2-priority-vars": [ + { + "cardinality": { + "kind": "session", + "source": "connect-call" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/case10-import-namespace/metrics.json b/recipes/http2-priority-signaling/tests/case10-import-namespace/metrics.json new file mode 100644 index 00000000..43890570 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/case10-import-namespace/metrics.json @@ -0,0 +1,28 @@ +{ + "http2-priority-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "http2-priority-options": [ + { + "cardinality": { + "removal": "partial-pair", + "site": "settings" + }, + "count": 1 + } + ], + "http2-priority-vars": [ + { + "cardinality": { + "kind": "session", + "source": "connect-call" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/case11-import-connect/metrics.json b/recipes/http2-priority-signaling/tests/case11-import-connect/metrics.json new file mode 100644 index 00000000..43890570 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/case11-import-connect/metrics.json @@ -0,0 +1,28 @@ +{ + "http2-priority-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "http2-priority-options": [ + { + "cardinality": { + "removal": "partial-pair", + "site": "settings" + }, + "count": 1 + } + ], + "http2-priority-vars": [ + { + "cardinality": { + "kind": "session", + "source": "connect-call" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/case12-import-connect-alias/metrics.json b/recipes/http2-priority-signaling/tests/case12-import-connect-alias/metrics.json new file mode 100644 index 00000000..43890570 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/case12-import-connect-alias/metrics.json @@ -0,0 +1,28 @@ +{ + "http2-priority-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "http2-priority-options": [ + { + "cardinality": { + "removal": "partial-pair", + "site": "settings" + }, + "count": 1 + } + ], + "http2-priority-vars": [ + { + "cardinality": { + "kind": "session", + "source": "connect-call" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/case2-request-priority/metrics.json b/recipes/http2-priority-signaling/tests/case2-request-priority/metrics.json new file mode 100644 index 00000000..e6ad8378 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/case2-request-priority/metrics.json @@ -0,0 +1,35 @@ +{ + "http2-priority-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "http2-priority-options": [ + { + "cardinality": { + "removal": "partial-pair", + "site": "request" + }, + "count": 1 + } + ], + "http2-priority-vars": [ + { + "cardinality": { + "kind": "session", + "source": "connect-call" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "stream", + "source": "session-request" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/case3-stream-priority-method/metrics.json b/recipes/http2-priority-signaling/tests/case3-stream-priority-method/metrics.json new file mode 100644 index 00000000..928e2aff --- /dev/null +++ b/recipes/http2-priority-signaling/tests/case3-stream-priority-method/metrics.json @@ -0,0 +1,34 @@ +{ + "http2-priority-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "http2-priority-method-calls": [ + { + "cardinality": { + "source": "stream-variable" + }, + "count": 1 + } + ], + "http2-priority-vars": [ + { + "cardinality": { + "kind": "session", + "source": "connect-call" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "stream", + "source": "session-request" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/case4-settings-priority-esm/metrics.json b/recipes/http2-priority-signaling/tests/case4-settings-priority-esm/metrics.json new file mode 100644 index 00000000..43890570 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/case4-settings-priority-esm/metrics.json @@ -0,0 +1,28 @@ +{ + "http2-priority-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "http2-priority-options": [ + { + "cardinality": { + "removal": "partial-pair", + "site": "settings" + }, + "count": 1 + } + ], + "http2-priority-vars": [ + { + "cardinality": { + "kind": "session", + "source": "connect-call" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/case5-dynamic-import/metrics.json b/recipes/http2-priority-signaling/tests/case5-dynamic-import/metrics.json new file mode 100644 index 00000000..43890570 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/case5-dynamic-import/metrics.json @@ -0,0 +1,28 @@ +{ + "http2-priority-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "http2-priority-options": [ + { + "cardinality": { + "removal": "partial-pair", + "site": "settings" + }, + "count": 1 + } + ], + "http2-priority-vars": [ + { + "cardinality": { + "kind": "session", + "source": "connect-call" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/case6-dynamic-import-await/metrics.json b/recipes/http2-priority-signaling/tests/case6-dynamic-import-await/metrics.json new file mode 100644 index 00000000..43890570 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/case6-dynamic-import-await/metrics.json @@ -0,0 +1,28 @@ +{ + "http2-priority-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "http2-priority-options": [ + { + "cardinality": { + "removal": "partial-pair", + "site": "settings" + }, + "count": 1 + } + ], + "http2-priority-vars": [ + { + "cardinality": { + "kind": "session", + "source": "connect-call" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/case7-require-myHttp2/metrics.json b/recipes/http2-priority-signaling/tests/case7-require-myHttp2/metrics.json new file mode 100644 index 00000000..43890570 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/case7-require-myHttp2/metrics.json @@ -0,0 +1,28 @@ +{ + "http2-priority-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "http2-priority-options": [ + { + "cardinality": { + "removal": "partial-pair", + "site": "settings" + }, + "count": 1 + } + ], + "http2-priority-vars": [ + { + "cardinality": { + "kind": "session", + "source": "connect-call" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/case8-require-connect/metrics.json b/recipes/http2-priority-signaling/tests/case8-require-connect/metrics.json new file mode 100644 index 00000000..43890570 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/case8-require-connect/metrics.json @@ -0,0 +1,28 @@ +{ + "http2-priority-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "http2-priority-options": [ + { + "cardinality": { + "removal": "partial-pair", + "site": "settings" + }, + "count": 1 + } + ], + "http2-priority-vars": [ + { + "cardinality": { + "kind": "session", + "source": "connect-call" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/case9-require-connect-alias/metrics.json b/recipes/http2-priority-signaling/tests/case9-require-connect-alias/metrics.json new file mode 100644 index 00000000..43890570 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/case9-require-connect-alias/metrics.json @@ -0,0 +1,28 @@ +{ + "http2-priority-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "http2-priority-options": [ + { + "cardinality": { + "removal": "partial-pair", + "site": "settings" + }, + "count": 1 + } + ], + "http2-priority-vars": [ + { + "cardinality": { + "kind": "session", + "source": "connect-call" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/import-assertions-to-attributes/src/workflow.ts b/recipes/import-assertions-to-attributes/src/workflow.ts index 03a2514e..b5b9284e 100644 --- a/recipes/import-assertions-to-attributes/src/workflow.ts +++ b/recipes/import-assertions-to-attributes/src/workflow.ts @@ -1,18 +1,11 @@ -import type { SgRoot, Edit } from '@codemod.com/jssg-types/main'; -import type JS from "@codemod.com/jssg-types/langs/javascript"; - -/** - * Transform function that converts import assertions to import attributes - * - * Handles: - * 1. import { something } from './module.json' assert { type: 'json' }; - * 2. import('./module.json', { assert: { type: 'json' } }) - * - * Converts them to: - * 1. import { something } from './module.json' with { type: 'json' }; - * 2. import('./module.json', { with: { type: 'json' } }) - */ -export default async function transform(root: SgRoot): Promise { +import { useMetricAtom } from 'codemod:metrics'; +import type { Codemod, Edit } from 'codemod:ast-grep'; +import type JS from "codemod:ast-grep/langs/javascript"; + +const rewriteMetric = useMetricAtom('import-assert-to-with-rewrites'); +const filesMetric = useMetricAtom('import-assert-to-with-files'); + +const transform: Codemod = async (root) => { const rootNode = root.root(); const edits: Edit[] = []; @@ -25,12 +18,14 @@ export default async function transform(root: SgRoot): Promise { for (const importNode of importStatements) { // Replace 'assert' with 'with' in the import statement - importNode.children().forEach((child) => { + const childrens = importNode.children(); + + for (const child of childrens) { if (child.kind() === 'assert' && child.text() === 'assert') { - //return child.replace('with'); edits.push(child.replace('with')); + rewriteMetric.increment({ kind: 'static-import-attribute' }); } - }); + } } // Handle dynamic import call expressions with assert attributes @@ -57,7 +52,12 @@ export default async function transform(root: SgRoot): Promise { for (const assertNode of assertIdentifiers) { edits.push(assertNode.replace('with')); + rewriteMetric.increment({ kind: 'dynamic-import-assert' }); } + filesMetric.increment({ status: edits.length ? 'migrated' : 'no-changes' }); + return rootNode.commitEdits(edits); } + +export default transform; diff --git a/recipes/import-assertions-to-attributes/tests/file-common/metrics.json b/recipes/import-assertions-to-attributes/tests/file-common/metrics.json new file mode 100644 index 00000000..aafc3784 --- /dev/null +++ b/recipes/import-assertions-to-attributes/tests/file-common/metrics.json @@ -0,0 +1,18 @@ +{ + "import-assert-to-with-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "import-assert-to-with-rewrites": [ + { + "cardinality": { + "kind": "dynamic-import-assert" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/import-assertions-to-attributes/tests/file-edge-case/metrics.json b/recipes/import-assertions-to-attributes/tests/file-edge-case/metrics.json new file mode 100644 index 00000000..e55f76fb --- /dev/null +++ b/recipes/import-assertions-to-attributes/tests/file-edge-case/metrics.json @@ -0,0 +1,24 @@ +{ + "import-assert-to-with-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "import-assert-to-with-rewrites": [ + { + "cardinality": { + "kind": "dynamic-import-assert" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "static-import-attribute" + }, + "count": 4 + } + ] +} \ No newline at end of file diff --git a/recipes/import-assertions-to-attributes/tests/file-module/metrics.json b/recipes/import-assertions-to-attributes/tests/file-module/metrics.json new file mode 100644 index 00000000..912a454e --- /dev/null +++ b/recipes/import-assertions-to-attributes/tests/file-module/metrics.json @@ -0,0 +1,24 @@ +{ + "import-assert-to-with-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "import-assert-to-with-rewrites": [ + { + "cardinality": { + "kind": "dynamic-import-assert" + }, + "count": 3 + }, + { + "cardinality": { + "kind": "static-import-attribute" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/import-assertions-to-attributes/tests/file/metrics.json b/recipes/import-assertions-to-attributes/tests/file/metrics.json new file mode 100644 index 00000000..912a454e --- /dev/null +++ b/recipes/import-assertions-to-attributes/tests/file/metrics.json @@ -0,0 +1,24 @@ +{ + "import-assert-to-with-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "import-assert-to-with-rewrites": [ + { + "cardinality": { + "kind": "dynamic-import-assert" + }, + "count": 3 + }, + { + "cardinality": { + "kind": "static-import-attribute" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/mocha-to-node-test-runner/src/remove-dependencies.ts b/recipes/mocha-to-node-test-runner/src/remove-dependencies.ts index be2895c0..f4bdb629 100644 --- a/recipes/mocha-to-node-test-runner/src/remove-dependencies.ts +++ b/recipes/mocha-to-node-test-runner/src/remove-dependencies.ts @@ -1,8 +1,8 @@ -import type { Transform } from '@codemod.com/jssg-types/main'; -import type Json from '@codemod.com/jssg-types/langs/json'; +import type { Codemod } from 'codemod:ast-grep'; +import type Json from 'codemod:ast-grep/langs/json'; import removeDependencies from '@nodejs/codemod-utils/remove-dependencies'; -const transform: Transform = async (root) => { +const transform: Codemod = async (root) => { return removeDependencies(['mocha', '@types/mocha'], { packageJsonPath: root.filename(), runInstall: false, diff --git a/recipes/mocha-to-node-test-runner/src/workflow.ts b/recipes/mocha-to-node-test-runner/src/workflow.ts index c74f71f8..f80e44d3 100644 --- a/recipes/mocha-to-node-test-runner/src/workflow.ts +++ b/recipes/mocha-to-node-test-runner/src/workflow.ts @@ -1,35 +1,25 @@ +import type { Codemod, Edit, Kinds, SgNode } from 'codemod:ast-grep'; +import type JS from 'codemod:ast-grep/langs/javascript'; import isESM from '@nodejs/codemod-utils/is-esm'; import { getNodeImportStatements } from '@nodejs/codemod-utils/ast-grep/import-statement'; import { getNodeRequireCalls } from '@nodejs/codemod-utils/ast-grep/require-call'; -import type { Edit, Kinds, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; -import type JS from '@codemod.com/jssg-types/langs/javascript'; +import { useMetricAtom } from 'codemod:metrics'; const GLOBAL_IDENTIFIERS = ['describe']; const USED_GLOBAL_IDENTIFIERS = ['', '.skip', '.only']; -export default function transform(root: SgRoot): string | null { - const rootNode = root.root(); - const EOL = rootNode.text().includes('\r\n') ? '\r\n' : '\n'; +// --------------------------------------------------------------------------- +// Metrics +// --------------------------------------------------------------------------- +// Tracks the node:test import inserted (by module style), each done-callback +// signature rewritten, each `this.skip()` / `this.timeout()` conversion, and +// a per-file migration summary. - const usedGlobalIdentifiers = GLOBAL_IDENTIFIERS.filter((globalIdentifier) => - USED_GLOBAL_IDENTIFIERS.map( - (suffix) => `${globalIdentifier}${suffix}($$$)`, - ).some((pattern) => rootNode.findAll({ rule: { pattern } }).length > 0), - ); - - if (!usedGlobalIdentifiers.length) return null; - - const edits = [ - transformImport, - transformDoneCallbacks, - transformThisSkip, - transformThisTimeout, - ].flatMap((transform) => transform(rootNode, EOL)); - - if (!edits.length) return null; - - return rootNode.commitEdits(edits); -} +const importMetric = useMetricAtom('mocha-to-node-test-imports'); +const doneCallbackMetric = useMetricAtom('mocha-to-node-test-done-callbacks'); +const thisSkipMetric = useMetricAtom('mocha-to-node-test-this-skip'); +const thisTimeoutMetric = useMetricAtom('mocha-to-node-test-this-timeout'); +const filesMetric = useMetricAtom('mocha-to-node-test-files'); function transformImport(rootNode: SgNode, EOL: string): Edit[] { const mochaGlobalsNodes = rootNode.findAll({ @@ -77,6 +67,8 @@ function transformImport(rootNode: SgNode, EOL: string): Edit[] { ? `${EOL}import { ${imports} } from 'node:test';` : `${EOL}const { ${imports} } = require('node:test');`; + importMetric.increment({ style: esm ? 'esm' : 'cjs' }); + if (esm) { const importStatements = rootNode.findAll({ rule: { kind: 'import_statement' }, @@ -153,7 +145,11 @@ function transformDoneCallbacks(rootNode: SgNode, EOL: string): Edit[] { ], }, }) - .map((found) => found.getMatch('DONE').replace('t, done')); + .map((found) => { + const callee = found.getMatch('CALLEE')?.text(); + doneCallbackMetric.increment({ callee: callee ?? 'unknown' }); + return found.getMatch('DONE').replace('t, done'); + }); } function transformThisSkip(rootNode: SgNode): Edit[] { @@ -175,6 +171,8 @@ function transformThisSkip(rootNode: SgNode): Edit[] { edits.push(...addTParameter(params)); + if (edits.length) thisSkipMetric.increment({}); + return edits; }); } @@ -201,7 +199,10 @@ function transformThisTimeout(rootNode: SgNode, EOL: string): Edit[] { }); const fn = findEnclosingFunction(call); - if (!fn) return edits; + if (!fn) { + thisTimeoutMetric.increment({ appliedToEnclosingFn: false }); + return edits; + } const time = call.getMatch('TIME').text(); const fnRange = fn.range(); @@ -212,6 +213,8 @@ function transformThisTimeout(rootNode: SgNode, EOL: string): Edit[] { insertedText: `{ timeout: ${time} }, `, }); + thisTimeoutMetric.increment({ appliedToEnclosingFn: "true" }); + return edits; }); } @@ -265,3 +268,34 @@ function addTParameter(parameters: SgNode>): Edit[] { return edits; } + +const transform: Codemod = async (root) => { + const rootNode = root.root(); + const EOL = rootNode.text().includes('\r\n') ? '\r\n' : '\n'; + + const usedGlobalIdentifiers = GLOBAL_IDENTIFIERS.filter((globalIdentifier) => + USED_GLOBAL_IDENTIFIERS.map( + (suffix) => `${globalIdentifier}${suffix}($$$)`, + ).some((pattern) => rootNode.findAll({ rule: { pattern } }).length > 0), + ); + + if (!usedGlobalIdentifiers.length) return null; + + const edits = [ + transformImport, + transformDoneCallbacks, + transformThisSkip, + transformThisTimeout, + ].flatMap((transform) => transform(rootNode, EOL)); + + if (!edits.length) { + filesMetric.increment({ status: 'no-changes' }); + return null; + } + + filesMetric.increment({ status: 'migrated' }); + + return rootNode.commitEdits(edits); +} + +export default transform; diff --git a/recipes/mocha-to-node-test-runner/tests/async/metrics.json b/recipes/mocha-to-node-test-runner/tests/async/metrics.json new file mode 100644 index 00000000..833dcde1 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/async/metrics.json @@ -0,0 +1,26 @@ +{ + "mocha-to-node-test-done-callbacks": [ + { + "cardinality": { + "callee": "it" + }, + "count": 1 + } + ], + "mocha-to-node-test-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "mocha-to-node-test-imports": [ + { + "cardinality": { + "style": "cjs" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/mocha-to-node-test-runner/tests/basic/metrics.json b/recipes/mocha-to-node-test-runner/tests/basic/metrics.json new file mode 100644 index 00000000..1986bfcc --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/basic/metrics.json @@ -0,0 +1,18 @@ +{ + "mocha-to-node-test-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "mocha-to-node-test-imports": [ + { + "cardinality": { + "style": "cjs" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/mocha-to-node-test-runner/tests/done/metrics.json b/recipes/mocha-to-node-test-runner/tests/done/metrics.json new file mode 100644 index 00000000..833dcde1 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/done/metrics.json @@ -0,0 +1,26 @@ +{ + "mocha-to-node-test-done-callbacks": [ + { + "cardinality": { + "callee": "it" + }, + "count": 1 + } + ], + "mocha-to-node-test-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "mocha-to-node-test-imports": [ + { + "cardinality": { + "style": "cjs" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/mocha-to-node-test-runner/tests/dynamic/metrics.json b/recipes/mocha-to-node-test-runner/tests/dynamic/metrics.json new file mode 100644 index 00000000..1986bfcc --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/dynamic/metrics.json @@ -0,0 +1,18 @@ +{ + "mocha-to-node-test-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "mocha-to-node-test-imports": [ + { + "cardinality": { + "style": "cjs" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/mocha-to-node-test-runner/tests/esm-imports/metrics.json b/recipes/mocha-to-node-test-runner/tests/esm-imports/metrics.json new file mode 100644 index 00000000..94edbbae --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/esm-imports/metrics.json @@ -0,0 +1,18 @@ +{ + "mocha-to-node-test-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "mocha-to-node-test-imports": [ + { + "cardinality": { + "style": "esm" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/mocha-to-node-test-runner/tests/hooks/metrics.json b/recipes/mocha-to-node-test-runner/tests/hooks/metrics.json new file mode 100644 index 00000000..1986bfcc --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/hooks/metrics.json @@ -0,0 +1,18 @@ +{ + "mocha-to-node-test-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "mocha-to-node-test-imports": [ + { + "cardinality": { + "style": "cjs" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/mocha-to-node-test-runner/tests/skipped/metrics.json b/recipes/mocha-to-node-test-runner/tests/skipped/metrics.json new file mode 100644 index 00000000..46d03c9b --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/skipped/metrics.json @@ -0,0 +1,32 @@ +{ + "mocha-to-node-test-done-callbacks": [ + { + "cardinality": { + "callee": "it" + }, + "count": 1 + } + ], + "mocha-to-node-test-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "mocha-to-node-test-imports": [ + { + "cardinality": { + "style": "cjs" + }, + "count": 1 + } + ], + "mocha-to-node-test-this-skip": [ + { + "cardinality": {}, + "count": 3 + } + ] +} \ No newline at end of file diff --git a/recipes/mocha-to-node-test-runner/tests/timeouts/metrics.json b/recipes/mocha-to-node-test-runner/tests/timeouts/metrics.json new file mode 100644 index 00000000..b83924e0 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/timeouts/metrics.json @@ -0,0 +1,34 @@ +{ + "mocha-to-node-test-done-callbacks": [ + { + "cardinality": { + "callee": "it" + }, + "count": 2 + } + ], + "mocha-to-node-test-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ], + "mocha-to-node-test-imports": [ + { + "cardinality": { + "style": "cjs" + }, + "count": 1 + } + ], + "mocha-to-node-test-this-timeout": [ + { + "cardinality": { + "appliedToEnclosingFn": "true" + }, + "count": 3 + } + ] +} \ No newline at end of file diff --git a/recipes/mock-module-exports/src/workflow.ts b/recipes/mock-module-exports/src/workflow.ts index 76105183..5c289caf 100644 --- a/recipes/mock-module-exports/src/workflow.ts +++ b/recipes/mock-module-exports/src/workflow.ts @@ -1,5 +1,5 @@ -import type { SgRoot, Edit, SgNode, Kinds } from '@codemod.com/jssg-types/main'; -import type JS from '@codemod.com/jssg-types/langs/javascript'; +import type { Codemod, Edit, SgNode, Kinds } from 'codemod:ast-grep'; +import type JS from 'codemod:ast-grep/langs/javascript'; import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; import { @@ -7,6 +7,7 @@ import { getLineIndent, } from '@nodejs/codemod-utils/ast-grep/indent'; import { EOL } from 'node:os'; +import { useMetricAtom } from 'codemod:metrics'; type QueueEvent = { event: keyof typeof parsers; @@ -27,10 +28,31 @@ type ExportedValue = { }; const exportedValues: Map = new Map(); +// --------------------------------------------------------------------------- +// Metrics +// --------------------------------------------------------------------------- +// Two atoms total instead of one per event type: each atom renders as one +// table in the UI, so occurrence-level detail is pushed into tag columns +// (`stage`, `kind`) rather than multiplied across separate atoms. +// +// mock-module-events: every parse/rewrite occurrence. +// stage='options-call' kind='call' mock.module() call queued +// stage='options-kind' kind='object'|'identifier'|'call_expression' +// stage='default-export' kind='default' +// stage='named-export' kind='identifier-spread'|'pair' +// stage='spread-element' kind='spread' +// stage='rewrite' kind='node' final node replaced +// +// mock-module-files: one row per processed file. +// status='migrated'|'no-changes' +const eventsMetric = useMetricAtom('mock-module-events'); +const filesMetric = useMetricAtom('mock-module-files'); + const parsers = { parseOptions: (optionsNode: SgNode>) => { switch (optionsNode.kind()) { case 'object': + eventsMetric.increment({ stage: 'options-kind', kind: 'object' }); queue.push( { event: 'defaultExport', @@ -47,12 +69,17 @@ const parsers = { ); break; case 'identifier': + eventsMetric.increment({ stage: 'options-kind', kind: 'identifier' }); queue.push({ event: 'resolveVariables', handler: () => parsers.resolveVariables(optionsNode), }); break; case 'call_expression': + eventsMetric.increment({ + stage: 'options-kind', + kind: 'call_expression', + }); queue.push({ event: 'resolveVariables', handler: () => @@ -118,6 +145,8 @@ const parsers = { after: `default: ${defaultExport?.field('value').text()}`, }; + eventsMetric.increment({ stage: 'default-export', kind: 'default' }); + if (!exportedValues.has(node.id())) { exportedValues.set(node.id(), { node, @@ -162,6 +191,10 @@ const parsers = { before: namedExport, after: `...(${fieldValueNode.text()} || {})`, }); + eventsMetric.increment({ + stage: 'named-export', + kind: 'identifier-spread', + }); } for (const namedPair of fieldValueNode.children()) { if (namedPair.is('pair')) { @@ -169,6 +202,7 @@ const parsers = { before: namedPair, after: namedPair.text(), }); + eventsMetric.increment({ stage: 'named-export', kind: 'pair' }); } } } @@ -196,12 +230,13 @@ const parsers = { before: spread, after: spread.text(), }); + eventsMetric.increment({ stage: 'spread-element', kind: 'spread' }); } } }, } as const satisfies Record>) => void>; -export default function transform(root: SgRoot): string | null { +const transform: Codemod = async (root) => { const rootNode = root.root(); const edits: Edit[] = []; @@ -236,6 +271,9 @@ export default function transform(root: SgRoot): string | null { if (args.length < 2) continue; const optionsArg = args[1]; + + eventsMetric.increment({ stage: 'options-call', kind: 'call' }); + queue.push({ event: 'parseOptions', handler: () => parsers.parseOptions(optionsArg), @@ -273,9 +311,17 @@ export default function transform(root: SgRoot): string | null { newValue += `${exportsLevel}},${EOL}` + `${indentLevel}}`; edits.push(change.node.replace(newValue)); + eventsMetric.increment({ stage: 'rewrite', kind: 'node' }); + } + + if (!edits.length) { + filesMetric.increment({ status: 'no-changes' }); + return null; } - if (!edits.length) return null; + filesMetric.increment({ status: 'migrated' }); return rootNode.commitEdits(edits); -} +}; + +export default transform; diff --git a/recipes/mock-module-exports/tests/basic/metrics.json b/recipes/mock-module-exports/tests/basic/metrics.json new file mode 100644 index 00000000..4bc70b8c --- /dev/null +++ b/recipes/mock-module-exports/tests/basic/metrics.json @@ -0,0 +1,54 @@ +{ + "mock-module-events": [ + { + "cardinality": { + "kind": "call", + "stage": "options-call" + }, + "count": 6 + }, + { + "cardinality": { + "kind": "default", + "stage": "default-export" + }, + "count": 4 + }, + { + "cardinality": { + "kind": "identifier-spread", + "stage": "named-export" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "node", + "stage": "rewrite" + }, + "count": 6 + }, + { + "cardinality": { + "kind": "object", + "stage": "options-kind" + }, + "count": 6 + }, + { + "cardinality": { + "kind": "pair", + "stage": "named-export" + }, + "count": 4 + } + ], + "mock-module-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/mock-module-exports/tests/destructuring-example/metrics.json b/recipes/mock-module-exports/tests/destructuring-example/metrics.json new file mode 100644 index 00000000..7dc8741c --- /dev/null +++ b/recipes/mock-module-exports/tests/destructuring-example/metrics.json @@ -0,0 +1,54 @@ +{ + "mock-module-events": [ + { + "cardinality": { + "kind": "call", + "stage": "options-call" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "default", + "stage": "default-export" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "identifier", + "stage": "options-kind" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "node", + "stage": "rewrite" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "object", + "stage": "options-kind" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "spread", + "stage": "spread-element" + }, + "count": 1 + } + ], + "mock-module-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/mock-module-exports/tests/esm-default-import/metrics.json b/recipes/mock-module-exports/tests/esm-default-import/metrics.json new file mode 100644 index 00000000..7c1f3ba6 --- /dev/null +++ b/recipes/mock-module-exports/tests/esm-default-import/metrics.json @@ -0,0 +1,47 @@ +{ + "mock-module-events": [ + { + "cardinality": { + "kind": "call", + "stage": "options-call" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "default", + "stage": "default-export" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "node", + "stage": "rewrite" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "object", + "stage": "options-kind" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "pair", + "stage": "named-export" + }, + "count": 1 + } + ], + "mock-module-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/mock-module-exports/tests/fn-return-example/metrics.json b/recipes/mock-module-exports/tests/fn-return-example/metrics.json new file mode 100644 index 00000000..defd7e27 --- /dev/null +++ b/recipes/mock-module-exports/tests/fn-return-example/metrics.json @@ -0,0 +1,54 @@ +{ + "mock-module-events": [ + { + "cardinality": { + "kind": "call", + "stage": "options-call" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "call_expression", + "stage": "options-kind" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "default", + "stage": "default-export" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "node", + "stage": "rewrite" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "object", + "stage": "options-kind" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "spread", + "stage": "spread-element" + }, + "count": 1 + } + ], + "mock-module-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/mock-module-exports/tests/option-as-variable/metrics.json b/recipes/mock-module-exports/tests/option-as-variable/metrics.json new file mode 100644 index 00000000..f5687abc --- /dev/null +++ b/recipes/mock-module-exports/tests/option-as-variable/metrics.json @@ -0,0 +1,54 @@ +{ + "mock-module-events": [ + { + "cardinality": { + "kind": "call", + "stage": "options-call" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "default", + "stage": "default-export" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "identifier", + "stage": "options-kind" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "node", + "stage": "rewrite" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "object", + "stage": "options-kind" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "pair", + "stage": "named-export" + }, + "count": 1 + } + ], + "mock-module-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/mock-module-exports/tests/without-default-export/metrics.json b/recipes/mock-module-exports/tests/without-default-export/metrics.json new file mode 100644 index 00000000..602a3ae6 --- /dev/null +++ b/recipes/mock-module-exports/tests/without-default-export/metrics.json @@ -0,0 +1,40 @@ +{ + "mock-module-events": [ + { + "cardinality": { + "kind": "call", + "stage": "options-call" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "node", + "stage": "rewrite" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "object", + "stage": "options-kind" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "pair", + "stage": "named-export" + }, + "count": 1 + } + ], + "mock-module-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/recipes/mock-module-exports/tests/without-named-export/metrics.json b/recipes/mock-module-exports/tests/without-named-export/metrics.json new file mode 100644 index 00000000..4ed4c8ad --- /dev/null +++ b/recipes/mock-module-exports/tests/without-named-export/metrics.json @@ -0,0 +1,40 @@ +{ + "mock-module-events": [ + { + "cardinality": { + "kind": "call", + "stage": "options-call" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "default", + "stage": "default-export" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "node", + "stage": "rewrite" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "object", + "stage": "options-kind" + }, + "count": 1 + } + ], + "mock-module-files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file 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 ce694125..1280e6ac 100644 --- a/recipes/node-url-to-whatwg-url/src/import-process.ts +++ b/recipes/node-url-to-whatwg-url/src/import-process.ts @@ -1,17 +1,20 @@ +import { useMetricAtom } from 'codemod:metrics'; +import type { Codemod, Edit, SgNode, Range } from 'codemod:ast-grep'; +import type JS from 'codemod:ast-grep/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"; -import type { SgRoot, Edit, SgNode, Range } from "@codemod.com/jssg-types/main"; -import type JS from "@codemod.com/jssg-types/langs/javascript"; + + +const unusedEsModuleImportsRemovedMetric = useMetricAtom('unused_es_module_imports_removed'); +const unusedRequireCallsRemovedMetric = useMetricAtom('unused_require_calls_removed'); +const filesMetric = useMetricAtom('url_import_cleanup_files'); // Heuristic: declaration counts as one; any other usage yields > 1 const isBindingUsed = (rootNode: SgNode, name: string): boolean => rootNode.findAll({ rule: { pattern: name } }).length > 1; -/** - * Clean up unused imports/requires from 'node:url' after transforms using shared utils - */ -export default function transform(root: SgRoot): string | null { +const transform: Codemod = async (root) => { const rootNode = root.root(); const edits: Edit[] = []; const linesToRemove: Range[] = []; @@ -28,6 +31,7 @@ export default function transform(root: SgRoot): string | null { if (nsId && !isBindingUsed(rootNode, nsId.text())) { linesToRemove.push(imp.range()); + unusedEsModuleImportsRemovedMetric.increment({ kind: 'namespace-import' }); removed = true; } @@ -39,6 +43,7 @@ export default function transform(root: SgRoot): string | null { const defaultId = clause.find({ rule: { kind: "identifier" } }); if (defaultId && !isBindingUsed(rootNode, defaultId.text())) { linesToRemove.push(imp.range()); + unusedEsModuleImportsRemovedMetric.increment({ kind: 'default-import' }); removed = true; } if (removed) continue; @@ -53,9 +58,11 @@ export default function transform(root: SgRoot): string | null { } if (keepTexts.length === 0) { linesToRemove.push(imp.range()); + unusedEsModuleImportsRemovedMetric.increment({ kind: 'named-imports-all' }); } else if (keepTexts.length !== specs.length) { const namedImportsNode = clause.find({ rule: { kind: "named_imports" } }); if (namedImportsNode) edits.push(namedImportsNode.replace(`{ ${keepTexts.join(", ")} }`)); + unusedEsModuleImportsRemovedMetric.increment({ kind: 'named-imports-partial' }); } } } @@ -69,7 +76,10 @@ export default function transform(root: SgRoot): string | null { const hasObjectPattern = decl.find({ rule: { kind: "object_pattern" } }); if (id && !hasObjectPattern) { - if (!isBindingUsed(rootNode, id.text())) linesToRemove.push(decl.parent().range()); + if (!isBindingUsed(rootNode, id.text())) { + linesToRemove.push(decl.parent().range()); + unusedRequireCallsRemovedMetric.increment({ kind: 'default-require' }); + } continue; } @@ -85,7 +95,6 @@ export default function transform(root: SgRoot): string | null { for (const pair of pairs) { const aliasId = pair.find({ rule: { kind: "identifier" } }); - if (aliasId) names.push(aliasId.text()); } @@ -96,23 +105,26 @@ export default function transform(root: SgRoot): string | null { for (const pair of pairs) { const aliasId = pair.find({ rule: { kind: "identifier" } }); - if (aliasId && isBindingUsed(rootNode, aliasId.text())) usedTexts.push(pair.text()); } if (usedTexts.length === 0) { linesToRemove.push(decl.parent().range()); + unusedRequireCallsRemovedMetric.increment({ kind: 'destructured-require-all' }); } else if (usedTexts.length !== names.length) { const objPat = decl.find({ rule: { kind: "object_pattern" } }); - if (objPat) edits.push(objPat.replace(`{ ${usedTexts.join(", ")} }`)); + unusedRequireCallsRemovedMetric.increment({ kind: 'destructured-require-partial' }); } } } + filesMetric.increment({ status: (edits.length || linesToRemove.length) ? 'migrated' : 'no-changes' }); + if (edits.length === 0 && linesToRemove.length === 0) return null; const source = rootNode.commitEdits(edits); - return removeLines(source, linesToRemove); }; + +export default transform; 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 96626cf9..bfcddc04 100644 --- a/recipes/node-url-to-whatwg-url/src/url-format.ts +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -1,8 +1,8 @@ -import { getNodeImportStatements } from '@nodejs/codemod-utils/ast-grep/import-statement'; -import { getNodeRequireCalls } from '@nodejs/codemod-utils/ast-grep/require-call'; +import { useMetricAtom } from 'codemod:metrics'; +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep'; +import type JS from 'codemod:ast-grep/langs/javascript'; +import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; -import type { SgRoot, Edit, SgNode } from '@codemod.com/jssg-types/main'; -import type JS from '@codemod.com/jssg-types/langs/javascript'; type V = { literal: true; text: string } | { literal: false; code: string }; @@ -29,11 +29,13 @@ const handledProps: (keyof UrlState)[] = [ 'hash', ]; -// Escape sequences for template literals const ESCAPE_BACKTICK = /`/g; const ESCAPE_BACKSLASH_DOLLAR = /\\\$/g; const ESCAPE_DOLLAR_CURLY = /\$\{/g; +const formatCallsTransformedMetric = useMetricAtom('url_format_calls_transformed'); +const filesMetric = useMetricAtom('url_format_migration_files'); + const isHandledProp = (key: string): key is (typeof handledProps)[number] => { return handledProps.includes(key as (typeof handledProps)[number]); }; @@ -297,32 +299,20 @@ function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { const hadSemi = /;\s*$/.test(call.text()); const replacement = `new URL(${finalExpr}).toString()${hadSemi ? ';' : ''}`; edits.push(call.replace(replacement)); + formatCallsTransformedMetric.increment({ kind: 'format-call-transformation' }); } } -/** - * 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 transform: Codemod = async (root) => { const rootNode = root.root(); const edits: Edit[] = []; - const requiresImports = [ - ...getNodeImportStatements(root, 'url'), - ...getNodeRequireCalls(root, 'url'), - ]; + const requiresImports = getModuleDependencies(root, 'url'); - // No imports/requires from 'url' found, exit early - if (!requiresImports.length) return null; + if (!requiresImports.length) { + filesMetric.increment({ status: 'no-changes' }); + return null; + } const parseCallPatterns = new Set(); @@ -337,7 +327,11 @@ export default function transform(root: SgRoot): string | null { if (calls.length) urlFormatToUrlToString(calls, edits); } + filesMetric.increment({ status: edits.length ? 'migrated' : 'no-changes' }); + if (!edits.length) return null; return rootNode.commitEdits(edits); } + +export default transform; 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 ec9046a7..91dcae21 100644 --- a/recipes/node-url-to-whatwg-url/src/url-parse.ts +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -1,13 +1,23 @@ -import { getNodeImportStatements } from '@nodejs/codemod-utils/ast-grep/import-statement'; -import { getNodeRequireCalls } from '@nodejs/codemod-utils/ast-grep/require-call'; +import { useMetricAtom } from 'codemod:metrics'; +import type { Codemod, Edit } from 'codemod:ast-grep'; +import type JS from 'codemod:ast-grep/langs/javascript'; +import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; -import type { SgRoot, Edit } from '@codemod.com/jssg-types/main'; -import type JS from '@codemod.com/jssg-types/langs/javascript'; + const VALID_VARIABLE_NAME_REGEX = /^[$A-Z_a-z][$\w]*$/; const DESTRUCTURING_ASSIGNMENT_REGEX = /^[^{]+{\s*[^}]+\s*}\s*=\s*/; const HOSTNAME_BRACKETS_REGEX = /^\[|\]$/; +const parseCallsReplacedMetric = useMetricAtom('url_parse_calls_replaced'); +const legacyPropertyAccessesMetric = useMetricAtom( + 'legacy_url_property_accesses_transformed', +); +const destructuringAssignmentsMetric = useMetricAtom( + 'url_destructuring_assignments_updated', +); +const filesMetric = useMetricAtom('url_migration_files'); + const fieldsToReplace = [ { key: 'auth', @@ -46,35 +56,23 @@ const fieldsToReplace = [ }, }, ]; -/** - * Transforms `url.parse` usage to `new URL()`. - * - * 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 transform: Codemod = async (root) => { const rootNode = root.root(); const edits: Edit[] = []; - const importsAndRequires = [ - ...getNodeImportStatements(root, 'url'), - ...getNodeRequireCalls(root, 'url'), - ]; + const importsAndRequires = getModuleDependencies(root, 'url'); - if (!importsAndRequires.length) return null; + if (!importsAndRequires.length) { + filesMetric.increment({ status: 'no-changes' }); + return null; + } // 1) Replace parse calls with new URL() using binding-aware patterns const parseCallPatterns = new Set(); for (const node of importsAndRequires) { const binding = resolveBindingPath(node, '$.parse'); - if (binding) parseCallPatterns.add(`${binding}($ARG)`); } @@ -102,7 +100,6 @@ 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 } }); @@ -111,15 +108,12 @@ export default function transform(root: SgRoot): string | null { if (!arg) continue; edits.push(call.replace(`new URL(${arg.text()})`)); + parseCallsReplacedMetric.increment({ kind: 'parse-call-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) - - for (const { key, replaceFn } of fieldsToReplace) { + for (const { key } of fieldsToReplace) { // 2.a) Handle property access for identifiers that originate from parse(...) for (const varName of parseResultVars) { const propertyAccesses = rootNode.findAll({ @@ -136,6 +130,10 @@ export default function transform(root: SgRoot): string | null { replacement = `${varName}.hostname.replace(/^\\[|\\]$/, '')`; } edits.push(node.replace(replacement)); + legacyPropertyAccessesMetric.increment({ + kind: 'property-access', + property: key, + }); } // destructuring for identifiers without looping kinds @@ -156,8 +154,14 @@ export default function transform(root: SgRoot): string | null { : text.trimStart().startsWith('const ') ? 'const' : 'let'; - const replacement = replaceFn(varName, hadSemi, declKind); + const replacement = fieldsToReplace + .find((f) => f.key === key)! + .replaceFn(varName, hadSemi, declKind); edits.push(node.replace(replacement)); + destructuringAssignmentsMetric.increment({ + kind: 'destructuring-assignment', + property: key, + }); } } @@ -168,7 +172,6 @@ export default function transform(root: SgRoot): string | null { 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 = ''; @@ -181,9 +184,13 @@ export default function transform(root: SgRoot): string | null { } edits.push(node.replace(replacement)); + legacyPropertyAccessesMetric.increment({ + kind: 'direct-property-access', + property: key, + }); } - // direct destructuring from parse(...), cover all kinds in a single query + // direct destructuring from parse(...) const directDestructures = rootNode.findAll({ rule: { any: [ @@ -205,8 +212,14 @@ export default function transform(root: SgRoot): string | null { : text.trimStart().startsWith('const ') ? 'const' : 'let'; - const replacement = replaceFn(rhsText, hadSemi, declKind); + const replacement = fieldsToReplace + .find((f) => f.key === key)! + .replaceFn(rhsText, hadSemi, declKind); edits.push(node.replace(replacement)); + destructuringAssignmentsMetric.increment({ + kind: 'direct-destructuring', + property: key, + }); } } @@ -228,9 +241,13 @@ export default function transform(root: SgRoot): string | null { replacement = `${baseExpr}.hostname.replace(/^\\[|\\]$/, '')`; } edits.push(node.replace(replacement)); + legacyPropertyAccessesMetric.increment({ + kind: 'new-url-property-access', + property: key, + }); } - // destructuring from new URL, single query for all kinds + // destructuring from new URL const newURLDestructures = rootNode.findAll({ rule: { any: [ @@ -250,12 +267,22 @@ export default function transform(root: SgRoot): string | null { : text.trimStart().startsWith('const ') ? 'const' : 'let'; - const replacement = replaceFn(rhsText, hadSemi, declKind); + const replacement = fieldsToReplace + .find((f) => f.key === key)! + .replaceFn(rhsText, hadSemi, declKind); edits.push(node.replace(replacement)); + destructuringAssignmentsMetric.increment({ + kind: 'new-url-destructuring', + property: key, + }); } } + filesMetric.increment({ status: edits.length ? 'migrated' : 'no-changes' }); + if (!edits.length) return null; return rootNode.commitEdits(edits); -} +}; + +export default transform; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/metrics.json b/recipes/node-url-to-whatwg-url/tests/import-process/input/metrics.json new file mode 100644 index 00000000..947a4294 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/metrics.json @@ -0,0 +1,50 @@ +{ + "unused_es_module_imports_removed": [ + { + "cardinality": { + "kind": "default-import" + }, + "count": 2 + }, + { + "cardinality": { + "kind": "named-imports-partial" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "namespace-import" + }, + "count": 1 + } + ], + "unused_require_calls_removed": [ + { + "cardinality": { + "kind": "default-require" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "destructured-require-all" + }, + "count": 1 + }, + { + "cardinality": { + "kind": "destructured-require-partial" + }, + "count": 1 + } + ], + "url_import_cleanup_files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 7 + } + ] +} \ No newline at end of file diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/metrics.json b/recipes/node-url-to-whatwg-url/tests/url-format/input/metrics.json new file mode 100644 index 00000000..db0117b8 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/metrics.json @@ -0,0 +1,18 @@ +{ + "url_format_calls_transformed": [ + { + "cardinality": { + "kind": "format-call-transformation" + }, + "count": 11 + } + ], + "url_format_migration_files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 10 + } + ] +} \ No newline at end of file diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/metrics.json b/recipes/node-url-to-whatwg-url/tests/url-parse/input/metrics.json new file mode 100644 index 00000000..2d207f70 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/metrics.json @@ -0,0 +1,64 @@ +{ + "legacy_url_property_accesses_transformed": [ + { + "cardinality": { + "kind": "property-access", + "property": "auth" + }, + "count": 10 + }, + { + "cardinality": { + "kind": "property-access", + "property": "hostname" + }, + "count": 10 + }, + { + "cardinality": { + "kind": "property-access", + "property": "path" + }, + "count": 10 + } + ], + "url_destructuring_assignments_updated": [ + { + "cardinality": { + "kind": "destructuring-assignment", + "property": "auth" + }, + "count": 10 + }, + { + "cardinality": { + "kind": "destructuring-assignment", + "property": "hostname" + }, + "count": 10 + }, + { + "cardinality": { + "kind": "destructuring-assignment", + "property": "path" + }, + "count": 10 + } + ], + "url_migration_files": [ + { + "cardinality": { + "status": "migrated" + }, + "count": 10 + } + ], + "url_parse_calls_replaced": [ + { + "cardinality": { + "kind": "parse-call-replacement" + }, + "count": 10 + } + ] +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 42ac8099..eb1037cf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,8 @@ "ESNext" ], "types": [ - "node" + "node", + "@codemod.com/jssg-types" ], /* Modules */ "module": "NodeNext", @@ -39,7 +40,7 @@ "strictFunctionTypes": true, /* Completeness */ - "skipLibCheck": true + "skipLibCheck": true, }, "include": [ "./recipes/", diff --git a/utils/types/jssg-context.d.ts b/utils/types/jssg-context.d.ts deleted file mode 100644 index 3039ab72..00000000 --- a/utils/types/jssg-context.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// note that codemod didn't expose everything -// but okay for now -declare module "codemod:ast-grep" { - export * from "@ast-grep/napi"; -}