Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions recipes/mock-module-exports/README.md
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there are a release note/docs link to add as references here ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the only resource for now is the PR: nodejs/node#61727

The idea is that we release the deprecation with this codemod, so I think the deprecation hasn’t been added to the docs yet, but I could be wrong.

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Mock Module Exports

This migration trasforming use of deprecated `options.defaultExport` and `options.namedExports` on
`node:test.mock`

## Example

```diff
mock.module('…', {
- defaultExport: …,
- namedExports: {
- foo: …
- },
+ exports: {
+ default: …,
+ foo: …,
+ },
});
```
21 changes: 21 additions & 0 deletions recipes/mock-module-exports/codemod.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
schema_version: "1.0"
name: "@nodejs/mock-module-exports"
version: 1.0.0
description: "Handle mock.module exports deprecation"
author: Bruno Rodrigues
license: MIT
workflow: workflow.yaml
category: migration

targets:
languages:
- javascript
- typescript

keywords:
- transformation
- migration

registry:
access: public
visibility: public
24 changes: 24 additions & 0 deletions recipes/mock-module-exports/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@nodejs/mock-module-exports",
"version": "1.0.0",
"description": "Handle mock.module exports deprecation",
"type": "module",
"scripts": {
"test": "npx codemod jssg test -l typescript ./src/workflow.ts ./tests/"
},
"repository": {
"type": "git",
"url": "git+https://github.com/nodejs/userland-migrations.git",
"directory": "recipes/mock-module-exports",
"bugs": "https://github.com/nodejs/userland-migrations/issues"
},
"author": "Bruno Rodrigues",
"license": "MIT",
"homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/mock-module-exports/README.md",
"devDependencies": {
"@codemod.com/jssg-types": "^1.3.1"
},
"dependencies": {
"@nodejs/codemod-utils": "*"
}
}
279 changes: 279 additions & 0 deletions recipes/mock-module-exports/src/workflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
import type { SgRoot, Edit, SgNode, Kinds } 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';
import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path';
import {
detectIndentUnit,
getLineIndent,
} from '@nodejs/codemod-utils/ast-grep/indent';
import { EOL } from 'node:os';

type QueueEvent = {
event: keyof typeof parsers;
handler: () => void;
};

const queue: QueueEvent[] = [];

type Pair = {
before: SgNode<JS, 'pair'> | SgNode<JS, 'spread_element'>;
after: string;
};

type ExportedValue = {
node: SgNode<JS, Kinds<JS>>;
default: Pair;
named: Pair[] | undefined;
};
const exportedValues: Map<number, ExportedValue> = new Map();

const parsers = {
parseOptions: (optionsNode: SgNode<JS, Kinds<JS>>) => {
switch (optionsNode.kind()) {
case 'object':
queue.unshift(
{
event: 'defaultExport',
handler: () => parsers.defaultExport(optionsNode),
},
{
event: 'namedExports',
handler: () => parsers.namedExports(optionsNode),
},
{
event: 'spreadElements',
handler: () => parsers.spreadElements(optionsNode),
},
);
break;
case 'identifier':
queue.unshift({
event: 'resolveVariables',
handler: () => parsers.resolveVariables(optionsNode),
});
break;
case 'call_expression':
queue.unshift({
event: 'resolveVariables',
handler: () =>
parsers.resolveVariables(optionsNode.field('function')),
});
break;
}
},
resolveVariables: (node: SgNode<JS, Kinds<JS>>) => {
const definition = node.definition();
if (!definition) return;

switch (definition.node.parent().kind()) {
case 'variable_declarator': {
const parent = definition.node.parent<'variable_declarator'>();
queue.unshift({
event: 'parseOptions',
handler: () => parsers.parseOptions(parent.field('value')),
});
break;
}
case 'function_declaration': {
const fnDeclaration = definition.node.parent<'variable_declarator'>();

const returns = fnDeclaration
.findAll<'return_statement'>({
rule: {
kind: 'return_statement',
},
})
.map((n) => n.child(1));

for (const ret of returns) {
if (ret) {
queue.unshift({
event: 'parseOptions',
handler: () => parsers.parseOptions(ret),
});
}
}

break;
}
default:
throw new Error('unhandled scenario');
}
},
defaultExport: (node: SgNode<JS, Kinds<JS>>): Edit[] => {
const edits: Edit[] = [];
const defaultExport = node.find<'pair'>({
rule: {
kind: 'pair',
has: {
field: 'key',
kind: 'property_identifier',
regex: 'defaultExport',
},
},
});
if (defaultExport) {
const change = {
before: defaultExport,
after: `default: ${defaultExport?.field('value').text()}`,
};

if (!exportedValues.has(node.id())) {
exportedValues.set(node.id(), {
node,
default: change,
named: [],
});
return;
}

const n = exportedValues.get(node.id());
n.default = change;
}
return edits;
},
namedExports: (node: SgNode<JS, Kinds<JS>>) => {
const namedExport = node.find<'pair'>({
rule: {
kind: 'pair',
has: {
field: 'key',
kind: 'property_identifier',
regex: 'namedExport',
},
},
});

if (namedExport) {
if (!exportedValues.has(node.id())) {
exportedValues.set(node.id(), {
node,
default: undefined,
named: [],
});
}

const pairs = exportedValues.get(node.id()).named;

const fieldValueNode = namedExport.field('value');

if (fieldValueNode.is('identifier')) {
pairs.push({
before: namedExport,
after: `...(${fieldValueNode.text()} || {})`,
});
}
for (const namedPair of fieldValueNode.children()) {
if (namedPair.is('pair')) {
pairs.push({
before: namedPair,
after: namedPair.text(),
});
}
}
}
},
spreadElements: (node: SgNode<JS, Kinds<JS>>): undefined => {
const spreadElements = node.findAll<'spread_element'>({
rule: {
kind: 'spread_element',
},
});

if (spreadElements) {
if (!exportedValues.has(node.id())) {
exportedValues.set(node.id(), {
node,
default: undefined,
named: [],
});
}

const pairs = exportedValues.get(node.id()).named;

for (const spread of spreadElements) {
pairs.push({
before: spread,
after: spread.text(),
});
}
}
},
} as const satisfies Record<string, (node: SgNode<JS, Kinds<JS>>) => void>;

export default function transform(root: SgRoot<JS>): string | null {
const rootNode = root.root();
const edits: Edit[] = [];

const deps = getModuleDependencies(root, 'test');
let moduleFnCalls: SgNode<JS, 'call_expression'>[] = [];

if (!deps.length) return null;

for (const dep of deps) {
const moduleFn = resolveBindingPath(dep, '$.mock.module');

const fnCallNodes = rootNode.findAll<'call_expression'>({
rule: {
kind: 'call_expression',
has: {
any: [
{
kind: 'member_expression',
pattern: moduleFn,
},
],
},
},
});

moduleFnCalls = moduleFnCalls.concat(fnCallNodes);
}

for (const moduleFnCall of moduleFnCalls) {
const argumentsNode = moduleFnCall.field<'arguments'>('arguments');
const args = argumentsNode.children().filter((node) => node.isNamed());

if (args.length < 2) continue;
const optionsArg = args[1];
queue.unshift({
event: 'parseOptions',
handler: () => parsers.parseOptions(optionsArg),
});
}

const indentUnit = detectIndentUnit(rootNode.text());
while (queue.length) {
const event = queue.at(-1);
event.handler();
queue.pop();
}

for (const [_nodeId, change] of Array.from(exportedValues)) {
const indentLevel = getLineIndent(
rootNode.text(),
change.node.range().start.index,
);

const exportsLevel = `${indentLevel}${indentUnit}`;
const innerExports = `${exportsLevel}${indentUnit}`;

let newValue = `{${EOL}` + `${exportsLevel}exports: {${EOL}`;

if (change.default?.after) {
newValue += `${innerExports}${change.default.after},${EOL}`;
}

if (change.named?.length) {
newValue += `${innerExports}${change.named.map((t) => t.after).join(`,${EOL}${innerExports}`)},${EOL}`;
}

newValue += `${exportsLevel}},${EOL}` + `${indentLevel}}`;

edits.push(change.node.replace(newValue));
}

if (!edits.length) return null;

return rootNode.commitEdits(edits);
}
Loading
Loading