diff --git a/package-lock.json b/package-lock.json index 45c99671..84e112e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1539,6 +1539,10 @@ "resolved": "recipes/process-assert-to-node-assert", "link": true }, + "node_modules/@nodejs/process-exit-coercion-to-integer": { + "resolved": "recipes/process-exit-coercion-to-integer", + "link": true + }, "node_modules/@nodejs/process-main-module": { "resolved": "recipes/process-main-module", "link": true @@ -4476,6 +4480,17 @@ "@codemod.com/jssg-types": "^1.5.0" } }, + "recipes/process-exit-coercion-to-integer": { + "name": "@nodejs/process-exit-coercion-to-integer", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.5.0" + } + }, "recipes/process-main-module": { "name": "@nodejs/process-main-module", "version": "1.0.2", diff --git a/recipes/process-exit-coercion-to-integer/README.md b/recipes/process-exit-coercion-to-integer/README.md new file mode 100644 index 00000000..cfe34388 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/README.md @@ -0,0 +1,31 @@ +# `process.exit(code)` / `process.exitCode` DEP0164 + +This recipe migrates non-integer values passed to `process.exit(code)` and assigned to `process.exitCode`. + +See [DEP0164](https://nodejs.org/api/deprecations.html#DEP0164). + +## What it changes + +- Preserves valid values: + - integer numbers + - integer strings + - `undefined` + - `null` +- Converts boolean values to explicit numeric exit codes. +- Wraps floating-point numeric expressions with `Math.floor(...)`. +- Converts non-integer string literals to `1`. +- For `process.exitCode = { code: ... }`, extracts `code` when possible and coerces when needed. + +## Example + +```diff +- process.exit(0.5 + 0.7) ++ process.exit(Math.floor(0.5 + 0.7)) +``` + +```diff +- const success = false; +- process.exitCode = success; ++ const success = false; ++ process.exitCode = success ? 0 : 1; +``` diff --git a/recipes/process-exit-coercion-to-integer/codemod.yaml b/recipes/process-exit-coercion-to-integer/codemod.yaml new file mode 100644 index 00000000..5b4921e2 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/codemod.yaml @@ -0,0 +1,23 @@ +schema_version: "1.0" +name: "@nodejs/process-exit-coercion-to-integer" +version: "1.0.0" +description: Handle DEP0164 by coercing process.exit(code) and process.exitCode assignments to integer-compatible values. +author: Kevin Sailema +license: MIT +workflow: workflow.yaml +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - dep0164 + - process.exit + - process.exitCode + - migration + +registry: + access: public + visibility: public diff --git a/recipes/process-exit-coercion-to-integer/package.json b/recipes/process-exit-coercion-to-integer/package.json new file mode 100644 index 00000000..8c67882e --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nodejs/process-exit-coercion-to-integer", + "version": "1.0.0", + "description": "Handle DEP0164 by coercing process.exit(code) and process.exitCode assignments to integer-compatible values.", + "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/process-exit-coercion-to-integer", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Kevin Sailema", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations", + "devDependencies": { + "@codemod.com/jssg-types": "^1.5.0" + }, + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} \ No newline at end of file diff --git a/recipes/process-exit-coercion-to-integer/src/workflow.ts b/recipes/process-exit-coercion-to-integer/src/workflow.ts new file mode 100644 index 00000000..b7b5c0f7 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/src/workflow.ts @@ -0,0 +1,274 @@ +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 ExitMode = 'exit' | 'exitCode'; + +type InferredIdentifierKind = + | 'boolean_true' + | 'boolean_false' + | 'integer_number' + | 'float_number' + | 'integer_string' + | 'non_integer_string' + | 'null' + | 'undefined' + | 'object' + | 'unknown'; + +type InferredIdentifier = { + kind: InferredIdentifierKind; + initializerNode: SgNode; +}; + +function isIntegerNumber(text: string): boolean { + const numeric = Number(text); + return Number.isFinite(numeric) && Number.isInteger(numeric); +} + +function isIntegerStringValue(value: string): boolean { + return /^-?\d+$/.test(value); +} + +function getStringLiteralValue(node: SgNode): string | null { + if (node.kind() !== 'string') return null; + const stringFragment = node.find({ + rule: { + kind: 'string_fragment', + }, + }); + return stringFragment?.text() ?? ''; +} + +function inferIdentifierKind(valueNode: SgNode): InferredIdentifierKind { + const nodeKind = valueNode.kind(); + if (nodeKind === 'true') return 'boolean_true'; + if (nodeKind === 'false') return 'boolean_false'; + if (nodeKind === 'null') return 'null'; + + if (nodeKind === 'identifier' && valueNode.text() === 'undefined') { + return 'undefined'; + } + + if (nodeKind === 'number') { + return isIntegerNumber(valueNode.text()) + ? 'integer_number' + : 'float_number'; + } + + if (nodeKind === 'string') { + const value = getStringLiteralValue(valueNode); + if (value === null) return 'unknown'; + return isIntegerStringValue(value) + ? 'integer_string' + : 'non_integer_string'; + } + + if (nodeKind === 'object') return 'object'; + + return 'unknown'; +} + +function floorWrap(expressionText: string): string { + return `Math.floor(${expressionText})`; +} + +function coerceBoolean(expressionText: string, mode: ExitMode): string { + if (mode === 'exit') return `${expressionText} ? 1 : 0`; + return `${expressionText} ? 0 : 1`; +} + +function getObjectCodeValue(objectNode: SgNode): SgNode | null { + const pairs = objectNode.findAll({ + rule: { + kind: 'pair', + }, + }); + + for (const pair of pairs) { + const key = pair.field('key'); + const value = pair.field('value'); + if (!key || !value) continue; + if (key.text() === 'code') return value; + } + + return null; +} + +function coerceFromObjectLiteral( + objectNode: SgNode, + mode: ExitMode, +): string { + if (mode !== 'exitCode') return '1'; + + const codeValue = getObjectCodeValue(objectNode); + if (!codeValue) return '1'; + + const kind = inferIdentifierKind(codeValue); + if (kind === 'integer_number' || kind === 'integer_string') { + return codeValue.text(); + } + if (kind === 'null' || kind === 'undefined') { + return codeValue.text(); + } + if (kind === 'boolean_true' || kind === 'boolean_false') { + return coerceBoolean(codeValue.text(), mode); + } + if (kind === 'float_number') { + return floorWrap(codeValue.text()); + } + + return '1'; +} + +function shouldFloorExpression(node: SgNode): boolean { + const nodeKind = node.kind(); + return ( + nodeKind === 'binary_expression' || + nodeKind === 'unary_expression' || + nodeKind === 'update_expression' + ); +} + +function coerceValueNode( + node: SgNode, + mode: ExitMode, + inferredIdentifiers: Map, +): string | null { + const inferredKind = inferIdentifierKind(node); + + if ( + inferredKind === 'undefined' || + inferredKind === 'null' || + inferredKind === 'integer_number' || + inferredKind === 'integer_string' + ) { + return null; + } + + if (inferredKind === 'boolean_true' || inferredKind === 'boolean_false') { + return mode === 'exit' + ? inferredKind === 'boolean_true' + ? '1' + : '0' + : inferredKind === 'boolean_true' + ? '0' + : '1'; + } + + if (inferredKind === 'non_integer_string') return '1'; + + if (inferredKind === 'float_number') return floorWrap(node.text()); + + if (inferredKind === 'object') return coerceFromObjectLiteral(node, mode); + + if (node.kind() === 'identifier') { + const inferred = inferredIdentifiers.get(node.text()); + if (!inferred) return null; + + if (inferred.kind === 'boolean_true' || inferred.kind === 'boolean_false') { + return coerceBoolean(node.text(), mode); + } + if (inferred.kind === 'float_number') return floorWrap(node.text()); + if (inferred.kind === 'non_integer_string') return '1'; + if (inferred.kind === 'object') { + return coerceFromObjectLiteral(inferred.initializerNode, mode); + } + return null; + } + + if (shouldFloorExpression(node)) { + return floorWrap(node.text()); + } + + return null; +} + +function collectInferredIdentifiers( + rootNode: SgNode, +): Map { + const inferred = new Map(); + const declarators = rootNode.findAll({ + rule: { + kind: 'variable_declarator', + }, + }); + + for (const declarator of declarators) { + const name = declarator.field('name'); + const value = declarator.field('value'); + if (!name || !value || name.kind() !== 'identifier') continue; + + inferred.set(name.text(), { + kind: inferIdentifierKind(value), + initializerNode: value, + }); + } + + return inferred; +} + +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + + const inferredIdentifiers = collectInferredIdentifiers(rootNode); + + const exitBindings = new Set(['process.exit']); + const exitCodeBindings = new Set(['process.exitCode']); + + const processDependencies = getModuleDependencies(root, 'process'); + + for (const dependency of processDependencies) { + const exitBinding = resolveBindingPath(dependency, '$.exit'); + if (exitBinding) exitBindings.add(exitBinding); + + const exitCodeBinding = resolveBindingPath(dependency, '$.exitCode'); + if (exitCodeBinding) exitCodeBindings.add(exitCodeBinding); + } + + for (const binding of exitBindings) { + const callNodes = rootNode.findAll({ + rule: { + pattern: `${binding}($ARG)`, + }, + }); + + for (const callNode of callNodes) { + const argNode = callNode.getMatch('ARG'); + if (!argNode) continue; + + const replacement = coerceValueNode(argNode, 'exit', inferredIdentifiers); + + if (!replacement || replacement === argNode.text()) continue; + edits.push(argNode.replace(replacement)); + } + } + + for (const binding of exitCodeBindings) { + const assignmentNodes = rootNode.findAll({ + rule: { + pattern: `${binding} = $VALUE`, + }, + }); + + for (const assignmentNode of assignmentNodes) { + const valueNode = assignmentNode.getMatch('VALUE'); + if (!valueNode) continue; + + const replacement = coerceValueNode( + valueNode, + 'exitCode', + inferredIdentifiers, + ); + + if (!replacement || replacement === valueNode.text()) continue; + edits.push(valueNode.replace(replacement)); + } + } + + if (!edits.length) return null; + + return rootNode.commitEdits(edits); +} diff --git a/recipes/process-exit-coercion-to-integer/tests/01-boolean-true-exit/expected.js b/recipes/process-exit-coercion-to-integer/tests/01-boolean-true-exit/expected.js new file mode 100644 index 00000000..6cee2e1e --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/01-boolean-true-exit/expected.js @@ -0,0 +1 @@ +process.exit(1); diff --git a/recipes/process-exit-coercion-to-integer/tests/01-boolean-true-exit/input.js b/recipes/process-exit-coercion-to-integer/tests/01-boolean-true-exit/input.js new file mode 100644 index 00000000..ac911f4e --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/01-boolean-true-exit/input.js @@ -0,0 +1 @@ +process.exit(true); diff --git a/recipes/process-exit-coercion-to-integer/tests/02-boolean-false-exit/expected.js b/recipes/process-exit-coercion-to-integer/tests/02-boolean-false-exit/expected.js new file mode 100644 index 00000000..dcbbff6c --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/02-boolean-false-exit/expected.js @@ -0,0 +1 @@ +process.exit(0); diff --git a/recipes/process-exit-coercion-to-integer/tests/02-boolean-false-exit/input.js b/recipes/process-exit-coercion-to-integer/tests/02-boolean-false-exit/input.js new file mode 100644 index 00000000..3f92ecfa --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/02-boolean-false-exit/input.js @@ -0,0 +1 @@ +process.exit(false); diff --git a/recipes/process-exit-coercion-to-integer/tests/03-floating-exit/expected.js b/recipes/process-exit-coercion-to-integer/tests/03-floating-exit/expected.js new file mode 100644 index 00000000..9936dc7f --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/03-floating-exit/expected.js @@ -0,0 +1 @@ +process.exit(Math.floor(1.5)); diff --git a/recipes/process-exit-coercion-to-integer/tests/03-floating-exit/input.js b/recipes/process-exit-coercion-to-integer/tests/03-floating-exit/input.js new file mode 100644 index 00000000..39924638 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/03-floating-exit/input.js @@ -0,0 +1 @@ +process.exit(1.5); diff --git a/recipes/process-exit-coercion-to-integer/tests/04-object-exitcode/expected.js b/recipes/process-exit-coercion-to-integer/tests/04-object-exitcode/expected.js new file mode 100644 index 00000000..8dc808b7 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/04-object-exitcode/expected.js @@ -0,0 +1 @@ +process.exitCode = 1; diff --git a/recipes/process-exit-coercion-to-integer/tests/04-object-exitcode/input.js b/recipes/process-exit-coercion-to-integer/tests/04-object-exitcode/input.js new file mode 100644 index 00000000..1f4b7864 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/04-object-exitcode/input.js @@ -0,0 +1 @@ +process.exitCode = { code: 1 }; diff --git a/recipes/process-exit-coercion-to-integer/tests/05-string-non-integer-exit/expected.js b/recipes/process-exit-coercion-to-integer/tests/05-string-non-integer-exit/expected.js new file mode 100644 index 00000000..6cee2e1e --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/05-string-non-integer-exit/expected.js @@ -0,0 +1 @@ +process.exit(1); diff --git a/recipes/process-exit-coercion-to-integer/tests/05-string-non-integer-exit/input.js b/recipes/process-exit-coercion-to-integer/tests/05-string-non-integer-exit/input.js new file mode 100644 index 00000000..3b4b6ef8 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/05-string-non-integer-exit/input.js @@ -0,0 +1 @@ +process.exit('error'); diff --git a/recipes/process-exit-coercion-to-integer/tests/06-conditional-boolean-exit/expected.js b/recipes/process-exit-coercion-to-integer/tests/06-conditional-boolean-exit/expected.js new file mode 100644 index 00000000..91ae0bd3 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/06-conditional-boolean-exit/expected.js @@ -0,0 +1,2 @@ +const hasError = true; +process.exit(hasError ? 1 : 0); diff --git a/recipes/process-exit-coercion-to-integer/tests/06-conditional-boolean-exit/input.js b/recipes/process-exit-coercion-to-integer/tests/06-conditional-boolean-exit/input.js new file mode 100644 index 00000000..a77fa998 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/06-conditional-boolean-exit/input.js @@ -0,0 +1,2 @@ +const hasError = true; +process.exit(hasError); diff --git a/recipes/process-exit-coercion-to-integer/tests/07-exitcode-boolean/expected.js b/recipes/process-exit-coercion-to-integer/tests/07-exitcode-boolean/expected.js new file mode 100644 index 00000000..6c2d3ac4 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/07-exitcode-boolean/expected.js @@ -0,0 +1,2 @@ +const success = false; +process.exitCode = success ? 0 : 1; diff --git a/recipes/process-exit-coercion-to-integer/tests/07-exitcode-boolean/input.js b/recipes/process-exit-coercion-to-integer/tests/07-exitcode-boolean/input.js new file mode 100644 index 00000000..0f4aba7a --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/07-exitcode-boolean/input.js @@ -0,0 +1,2 @@ +const success = false; +process.exitCode = success; diff --git a/recipes/process-exit-coercion-to-integer/tests/08-valid-integer-string/expected.js b/recipes/process-exit-coercion-to-integer/tests/08-valid-integer-string/expected.js new file mode 100644 index 00000000..21f30660 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/08-valid-integer-string/expected.js @@ -0,0 +1 @@ +process.exit('1'); diff --git a/recipes/process-exit-coercion-to-integer/tests/08-valid-integer-string/input.js b/recipes/process-exit-coercion-to-integer/tests/08-valid-integer-string/input.js new file mode 100644 index 00000000..21f30660 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/08-valid-integer-string/input.js @@ -0,0 +1 @@ +process.exit('1'); diff --git a/recipes/process-exit-coercion-to-integer/tests/09-valid-values/expected.js b/recipes/process-exit-coercion-to-integer/tests/09-valid-values/expected.js new file mode 100644 index 00000000..84bb8d78 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/09-valid-values/expected.js @@ -0,0 +1,6 @@ +process.exit(0); +process.exit(1); +process.exit(undefined); +process.exit(null); +process.exitCode = 0; +process.exitCode = undefined; diff --git a/recipes/process-exit-coercion-to-integer/tests/09-valid-values/input.js b/recipes/process-exit-coercion-to-integer/tests/09-valid-values/input.js new file mode 100644 index 00000000..84bb8d78 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/09-valid-values/input.js @@ -0,0 +1,6 @@ +process.exit(0); +process.exit(1); +process.exit(undefined); +process.exit(null); +process.exitCode = 0; +process.exitCode = undefined; diff --git a/recipes/process-exit-coercion-to-integer/tests/10-arithmetic-expression/expected.js b/recipes/process-exit-coercion-to-integer/tests/10-arithmetic-expression/expected.js new file mode 100644 index 00000000..65333084 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/10-arithmetic-expression/expected.js @@ -0,0 +1 @@ +process.exit(Math.floor(0.5 + 0.7)); diff --git a/recipes/process-exit-coercion-to-integer/tests/10-arithmetic-expression/input.js b/recipes/process-exit-coercion-to-integer/tests/10-arithmetic-expression/input.js new file mode 100644 index 00000000..b301cfd6 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/10-arithmetic-expression/input.js @@ -0,0 +1 @@ +process.exit(0.5 + 0.7); diff --git a/recipes/process-exit-coercion-to-integer/tests/11-import-require-aliases/expected.js b/recipes/process-exit-coercion-to-integer/tests/11-import-require-aliases/expected.js new file mode 100644 index 00000000..849c2b33 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/11-import-require-aliases/expected.js @@ -0,0 +1,6 @@ +import proc from 'node:process'; +const { exit } = require('node:process'); + +proc.exit(0); +exit(Math.floor(2.3)); +proc.exitCode = 0; diff --git a/recipes/process-exit-coercion-to-integer/tests/11-import-require-aliases/input.js b/recipes/process-exit-coercion-to-integer/tests/11-import-require-aliases/input.js new file mode 100644 index 00000000..6cbe6398 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/11-import-require-aliases/input.js @@ -0,0 +1,6 @@ +import proc from 'node:process'; +const { exit } = require('node:process'); + +proc.exit(false); +exit(2.3); +proc.exitCode = true; diff --git a/recipes/process-exit-coercion-to-integer/tests/12-unknown-identifier-no-change/expected.js b/recipes/process-exit-coercion-to-integer/tests/12-unknown-identifier-no-change/expected.js new file mode 100644 index 00000000..9ddd8c85 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/12-unknown-identifier-no-change/expected.js @@ -0,0 +1,3 @@ +const code = getCode(); +process.exit(code); +process.exitCode = code; diff --git a/recipes/process-exit-coercion-to-integer/tests/12-unknown-identifier-no-change/input.js b/recipes/process-exit-coercion-to-integer/tests/12-unknown-identifier-no-change/input.js new file mode 100644 index 00000000..9ddd8c85 --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/12-unknown-identifier-no-change/input.js @@ -0,0 +1,3 @@ +const code = getCode(); +process.exit(code); +process.exitCode = code; diff --git a/recipes/process-exit-coercion-to-integer/tests/13-exitcode-object-float-code/expected.js b/recipes/process-exit-coercion-to-integer/tests/13-exitcode-object-float-code/expected.js new file mode 100644 index 00000000..627d191e --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/13-exitcode-object-float-code/expected.js @@ -0,0 +1 @@ +process.exitCode = Math.floor(1.7); diff --git a/recipes/process-exit-coercion-to-integer/tests/13-exitcode-object-float-code/input.js b/recipes/process-exit-coercion-to-integer/tests/13-exitcode-object-float-code/input.js new file mode 100644 index 00000000..4928596b --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/tests/13-exitcode-object-float-code/input.js @@ -0,0 +1 @@ +process.exitCode = { code: 1.7 }; diff --git a/recipes/process-exit-coercion-to-integer/workflow.yaml b/recipes/process-exit-coercion-to-integer/workflow.yaml new file mode 100644 index 00000000..14af4a0c --- /dev/null +++ b/recipes/process-exit-coercion-to-integer/workflow.yaml @@ -0,0 +1,25 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod-com/codemod/refs/heads/main/schemas/workflow.json + +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + steps: + - name: Coerce process.exit(code) and process.exitCode to integer-compatible values. + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.cjs" + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript