Skip to content

Commit 450f3bd

Browse files
feat(util-extend-to-object-assign): introduce (#280)
Co-authored-by: Jacob Smith <3012099+JakobJingleheimer@users.noreply.github.com>
1 parent 2830763 commit 450f3bd

27 files changed

+663
-34
lines changed

package-lock.json

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# `util._extend` DEP0060
2+
3+
This recipe transforms the usage of deprecated `util._extend()` to use `Object.assign()`.
4+
5+
See [DEP0060](https://nodejs.org/api/deprecations.html#DEP0060).
6+
7+
## Example
8+
9+
```diff
10+
- const util = require("node:util");
11+
const target = { a: 1 };
12+
const source = { b: 2 };
13+
- const result = util._extend(target, source);
14+
+ const result = Object.assign(target, source);
15+
```
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
schema_version: "1.0"
2+
name: "@nodejs/util-extend-to-object-assign"
3+
version: 1.0.0
4+
description: "Handle DEP0060 by replacing `util._extend()` with `Object.assign()`."
5+
author: GitHub Copilot
6+
license: MIT
7+
workflow: workflow.yaml
8+
category: migration
9+
10+
targets:
11+
languages:
12+
- javascript
13+
- typescript
14+
15+
keywords:
16+
- transformation
17+
- migration
18+
19+
registry:
20+
access: public
21+
visibility: public
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "@nodejs/util-extend-to-object-assign",
3+
"version": "1.0.0",
4+
"description": "Handle DEP0060 by replacing `util._extend()` with `Object.assign()`.",
5+
"type": "module",
6+
"scripts": {
7+
"test": "npx codemod jssg test -l typescript ./src/workflow.ts ./"
8+
},
9+
"repository": {
10+
"type": "git",
11+
"url": "git+https://github.com/nodejs/userland-migrations.git",
12+
"directory": "recipes/util-extend-to-object-assign",
13+
"bugs": "https://github.com/nodejs/userland-migrations/issues"
14+
},
15+
"author": "GitHub Copilot",
16+
"license": "MIT",
17+
"homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/util-extend-to-object-assign/README.md",
18+
"devDependencies": {
19+
"@codemod.com/jssg-types": "^1.0.9"
20+
},
21+
"dependencies": {
22+
"@nodejs/codemod-utils": "*"
23+
}
24+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import {
2+
getNodeImportStatements,
3+
getNodeImportCalls,
4+
getDefaultImportIdentifier,
5+
} from '@nodejs/codemod-utils/ast-grep/import-statement';
6+
import {
7+
getNodeRequireCalls,
8+
getRequireNamespaceIdentifier,
9+
} from '@nodejs/codemod-utils/ast-grep/require-call';
10+
import { removeBinding } from '@nodejs/codemod-utils/ast-grep/remove-binding';
11+
import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path';
12+
import { removeLines } from '@nodejs/codemod-utils/ast-grep/remove-lines';
13+
import type { SgRoot, Edit, Range } from '@codemod.com/jssg-types/main';
14+
import type JS from '@codemod.com/jssg-types/langs/javascript';
15+
16+
const method = '_extend';
17+
18+
/**
19+
* Transform function that converts deprecated util._extend() calls
20+
* to Object.assign().
21+
*
22+
* Handles:
23+
* 1. util._extend(target, source) → Object.assign(target, source)
24+
* 2. const { _extend } = require('util'); _extend(target, source) → Object.assign(target, source)
25+
* 3. import { _extend } from 'node:util'; _extend(target, source) → Object.assign(target, source)
26+
* 4. Aliased imports: const { _extend: extend } = require('util'); extend(target, source) → Object.assign(target, source)
27+
*
28+
* Also cleans up unused imports and requires.
29+
*/
30+
export default function transform(root: SgRoot<JS>): string | null {
31+
const rootNode = root.root();
32+
const edits: Edit[] = [];
33+
const linesToRemove: Range[] = [];
34+
const editRanges: Range[] = [];
35+
36+
const importOrRequireNodes = [
37+
...getNodeRequireCalls(root, 'util'),
38+
...getNodeImportStatements(root, 'util'),
39+
...getNodeImportCalls(root, 'util'),
40+
];
41+
42+
// If no util imports/requires, nothing to do
43+
if (!importOrRequireNodes.length) return null;
44+
45+
// 1. Resolve local bindings for util._extend and replace invocations
46+
const localRefs = new Set<string>();
47+
48+
for (const node of importOrRequireNodes) {
49+
const resolved = resolveBindingPath(node, `$.${method}`);
50+
if (resolved) localRefs.add(resolved);
51+
52+
// Workaround for mixed imports (e.g. import util, { _extend } from 'util')
53+
if (node.kind() === 'import_statement') {
54+
const namedSpecifiers = node.findAll({
55+
rule: {
56+
kind: 'import_specifier',
57+
},
58+
});
59+
60+
for (const specifier of namedSpecifiers) {
61+
const nameNode = specifier.field('name');
62+
const aliasNode = specifier.field('alias');
63+
64+
if (nameNode && nameNode.text() === method) {
65+
const localName = aliasNode ? aliasNode.text() : nameNode.text();
66+
localRefs.add(localName);
67+
}
68+
}
69+
}
70+
}
71+
72+
for (const ref of localRefs) {
73+
const calls = rootNode.findAll({
74+
rule: {
75+
kind: 'call_expression',
76+
pattern: `${ref}($$$ARGS)`,
77+
},
78+
});
79+
80+
for (const call of calls) {
81+
const args = call.find({
82+
rule: { kind: 'arguments' },
83+
});
84+
85+
edits.push(call.replace(`Object.assign${args.text()}`));
86+
editRanges.push(call.range());
87+
}
88+
}
89+
90+
// if no edits were made, don't try to clean up imports
91+
if (!edits.length) return null;
92+
93+
// 2. Cleanup imports
94+
for (const node of importOrRequireNodes) {
95+
let nsBinding = '';
96+
97+
const reqNs = getRequireNamespaceIdentifier(node);
98+
const defaultImport = getDefaultImportIdentifier(node);
99+
const namespaceImport = node.find({
100+
rule: {
101+
kind: 'identifier',
102+
inside: {
103+
kind: 'namespace_import',
104+
},
105+
},
106+
});
107+
108+
if (reqNs) {
109+
nsBinding = reqNs.text();
110+
} else if (defaultImport) {
111+
nsBinding = defaultImport.text();
112+
} else if (namespaceImport) {
113+
nsBinding = namespaceImport.text();
114+
}
115+
116+
// Check if namespace binding is still used
117+
if (nsBinding) {
118+
const change = removeBinding(node, nsBinding, {
119+
usageCheck: { ignoredRanges: editRanges },
120+
root: rootNode,
121+
});
122+
if (change?.lineToRemove) linesToRemove.push(change.lineToRemove);
123+
}
124+
125+
const change = removeBinding(node, method);
126+
if (change?.edit) edits.push(change.edit);
127+
if (change?.lineToRemove) linesToRemove.push(change.lineToRemove);
128+
}
129+
130+
const sourceCode = rootNode.commitEdits(edits);
131+
132+
return removeLines(sourceCode, linesToRemove);
133+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Object.assign({}, {});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Object.assign({}, {});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Object.assign({}, {});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as util1 from 'util';
2+
Object.assign({}, {});
3+
util1.types.isDate(new Date());
4+
5+
import util2 from 'node:util';
6+
Object.assign({}, {});
7+
console.log(util2.inspect({}));
8+
9+
const util3 = require('util');
10+
Object.assign({}, {});
11+
registerPlugin(util3);
12+
13+
import util4 from 'util';
14+
Object.assign({}, {});
15+
util4.promisify(() => { });
16+
17+
Object.assign({}, {});
18+
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Object.assign({}, {});

0 commit comments

Comments
 (0)